feat(move): undoable moves + Lightroom-style move/copy dialog

Backend correctness was already sound (companion files travel as a
group, coordinated collision suffixes, EXDEV fallback, per-file scope
checks, blocking scoped reindex) — this pass adds reversibility and
brings the modal up to standard.

Sidecar:
- movePhotoFiles records per-file {from,to} pairs (move mode) —
  including siblings of photos that failed partway, since undo must
  restore whatever actually left its folder. Both POST /photos/move
  and POST /albums/:uid/convert return them as movedFiles.
- New POST /files/restore-moves plays those pairs backwards: both ends
  scope-checked (sources aren't quarantined like the duplicates
  restore), never clobbers an existing destination, EXDEV fallback,
  blocking reindex of affected parents so the client's refetch already
  sees the restored layout.

Dialog (all three subjects — photos, heap convert, folder reparent):
- Search field on top (autofocused) filtering the tree live: matches +
  ancestors, force-expanded without touching the sidebar's persisted
  open/collapse state (new FolderTree forceExpand prop).
- Arrow keys rove through visible rows with selection following focus
  (data-move-row attributes in FolderTree's readonly picker mode);
  Enter confirms from anywhere once a destination is set.
- Recent destinations as one-click chips (last 5, per library base).
- Live destination preview line and count-labeled confirm buttons
  ("Move 12 photos", "Move “2024”") with a disabled-reason tooltip.
- Client-side subfolder validation mirroring the sidecar's
  sanitizeFilename rules (inline error, aria-invalid, confirm gated).
- Pre-disables Move when every selected photo is already in the target.
- Undo everywhere it's safe: photo/heap moves restore via the new
  endpoint, folder moves invert to another folder move, copies stay
  toast-only (their inverse would be deletion). Success toasts carry
  an inline Undo action; ⌘Z works through the shared undo stack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 09:56:21 +02:00
parent 246d159d93
commit c6f31b5dfb
6 changed files with 577 additions and 133 deletions

View File

@@ -127,7 +127,7 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
return return
} }
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg)) moved, copied, movedPairs, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg))
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -157,6 +157,7 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"moved": moved, "moved": moved,
"copied": copied, "copied": copied,
"movedFiles": movedPairs,
"errors": errs, "errors": errs,
"heap_deleted": heapDeleted, "heap_deleted": heapDeleted,
}) })
@@ -173,17 +174,23 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
// `scopeAbs` is the caller's userScopeRoot — source files outside it fail // `scopeAbs` is the caller's userScopeRoot — source files outside it fail
// per-photo, so a UID that resolves outside the user's BasePath (however // per-photo, so a UID that resolves outside the user's BasePath (however
// PhotoPrism came to return it) can't be used to pull files across users. // PhotoPrism came to return it) can't be used to pull files across users.
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode, scopeAbs string) (moved, copied int, errs []heapErr, err error) { //
// `movedPairs` records every file that physically moved (move mode only —
// copies have no inverse pair) as originals-relative {from,to}, including
// siblings of photos that later failed partway: undo must restore whatever
// actually left its folder, not just fully-successful photos.
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode, scopeAbs string) (moved, copied int, movedPairs []dupMoved, errs []heapErr, err error) {
destAbs := targetAbs destAbs := targetAbs
if subfolder != "" { if subfolder != "" {
destAbs = filepath.Join(targetAbs, subfolder) destAbs = filepath.Join(targetAbs, subfolder)
if e := os.MkdirAll(destAbs, 0o755); e != nil { if e := os.MkdirAll(destAbs, 0o755); e != nil {
return 0, 0, nil, e return 0, 0, nil, nil, e
} }
} }
sourceParents := map[string]struct{}{} sourceParents := map[string]struct{}{}
errs = []heapErr{} errs = []heapErr{}
movedPairs = []dupMoved{}
for _, photo := range photos { for _, photo := range photos {
// Gather *every* originals-rooted file of the photo, not just the // Gather *every* originals-rooted file of the photo, not just the
@@ -282,6 +289,9 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
break break
} }
} }
if dstRel, relErr := filepath.Rel(cfg.OriginalsRoot, dstAbs); relErr == nil {
movedPairs = append(movedPairs, dupMoved{From: srcRel, To: dstRel})
}
} else { } else {
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil { if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
failure = cpErr.Error() failure = cpErr.Error()
@@ -329,7 +339,7 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
fireReindex(cfg, pp, token, reindex) fireReindex(cfg, pp, token, reindex)
} }
return moved, copied, errs, nil return moved, copied, movedPairs, errs, nil
} }
// resolveMoveTarget translates a targetFolder (Originals-relative; ""/"/"/"." // resolveMoveTarget translates a targetFolder (Originals-relative; ""/"/"/"."

View File

@@ -70,7 +70,7 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
return return
} }
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg)) moved, copied, movedPairs, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg))
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -89,6 +89,7 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"moved": moved, "moved": moved,
"copied": copied, "copied": copied,
"movedFiles": movedPairs,
"errors": errs, "errors": errs,
}) })
} }
@@ -203,3 +204,96 @@ func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
}) })
} }
} }
type restoreMovesBody struct {
// Moves mirror the movedFiles pairs from photos-move / heap-convert
// responses verbatim; the handler renames each `to` (current location)
// back to its `from` (original location).
Moves []dupMoved `json:"moves"`
}
// handleRestoreMoves is the generic inverse of movePhotoFiles: it moves
// files back to where they came from, powering ⌘Z undo for photo/heap
// moves. Unlike the duplicates restore (whose sources must live in the
// .duplicates/ quarantine), both ends here are arbitrary library paths —
// so BOTH are validated against the caller's scope, and existing
// destinations are never clobbered.
//
// Route: POST /api/sidecar/files/restore-moves (behind requireSession)
func handleRestoreMoves(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body restoreMovesBody
if err := c.ShouldBindJSON(&body); err != nil || len(body.Moves) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "moves[] required"})
return
}
scope := userScopeRoot(c, cfg)
type resolved struct {
srcAbs, dstAbs string
srcRel, dstRel string
}
items := make([]resolved, 0, len(body.Moves))
for _, m := range body.Moves {
srcAbs, err := resolveUnderRoot(cfg.OriginalsRoot, m.To, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid source path: " + m.To})
return
}
dstAbs, err := resolveUnderRoot(cfg.OriginalsRoot, m.From, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid destination path: " + m.From})
return
}
if !sameOrUnder(srcAbs, scope) || !sameOrUnder(dstAbs, scope) {
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library"})
return
}
items = append(items, resolved{srcAbs: srcAbs, dstAbs: dstAbs, srcRel: m.To, dstRel: m.From})
}
restored := []dupMoved{}
errs := []dupArchiveErr{}
parents := map[string]struct{}{}
for _, it := range items {
if _, err := os.Stat(it.dstAbs); err == nil {
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: "destination already exists"})
continue
} else if !os.IsNotExist(err) {
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: err.Error()})
continue
}
if err := os.MkdirAll(filepath.Dir(it.dstAbs), 0o755); err != nil {
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: err.Error()})
continue
}
if err := os.Rename(it.srcAbs, it.dstAbs); err != nil {
if err2 := copyFile(it.srcAbs, it.dstAbs); err2 != nil {
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: err.Error()})
continue
}
if err2 := os.Remove(it.srcAbs); err2 != nil {
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: "restored but source remove failed: " + err2.Error()})
continue
}
}
restored = append(restored, dupMoved{From: it.srcRel, To: it.dstRel})
parents[filepath.Dir(it.srcRel)] = struct{}{}
parents[filepath.Dir(it.dstRel)] = struct{}{}
slog.Info("files.restore-moves", "from", it.srcRel, "to", it.dstRel)
}
// Block on the reindex like movePhotoFiles does — the client
// invalidates its photo queries right after this returns, and the
// refetch must already see the restored locations.
for p := range parents {
reindex := "/"
if p != "" && p != "." {
reindex = "/" + p
}
fireReindex(cfg, pp, token, reindex)
}
c.JSON(http.StatusOK, gin.H{"restored": restored, "errors": errs})
}
}

View File

@@ -99,6 +99,7 @@ func main() {
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp)) auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.POST("/photos/move", handlePhotosMove(cfg, pp)) auth.POST("/photos/move", handlePhotosMove(cfg, pp))
auth.POST("/files/restore-moves", handleRestoreMoves(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db)) auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db)) auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))

View File

@@ -69,6 +69,10 @@
* "{n} photos" affordance. Undefined keeps the badge off entirely * "{n} photos" affordance. Undefined keeps the badge off entirely
* (the picker dialog doesn't need it). */ * (the picker dialog doesn't need it). */
counts?: Record<string, number>; counts?: Record<string, number>;
/** Render every branch expanded regardless of the persisted openSet —
* the picker turns this on while a search filter is active so matches
* buried in collapsed branches stay visible. */
forceExpand?: boolean;
} }
let { let {
nodes, nodes,
@@ -80,7 +84,8 @@
onMove, onMove,
readonly = false, readonly = false,
selectedPath, selectedPath,
counts counts,
forceExpand = false
}: Props = $props(); }: Props = $props();
// Auto-expanded folders, persisted to localStorage so the tree state // Auto-expanded folders, persisted to localStorage so the tree state
@@ -145,7 +150,7 @@
<ul> <ul>
{#each nodes as node (node.path)} {#each nodes as node (node.path)}
{@const open = openSet.has(node.path)} {@const open = forceExpand || openSet.has(node.path)}
{@const active = isActive(node.path)} {@const active = isActive(node.path)}
{@const hasChildren = node.children.length > 0} {@const hasChildren = node.children.length > 0}
<li> <li>
@@ -185,11 +190,17 @@
+ badge) is one hit target — the badge was previously a dead + badge) is one hit target — the badge was previously a dead
zone right where the user's eye lands. zone right where the user's eye lands.
--> -->
<!-- In readonly (picker) mode the row carries data attributes the
move dialog uses for roving arrow-key focus, plus aria-pressed
so screen readers hear the current selection. -->
<button <button
class="flex min-w-0 flex-1 items-center pl-1 text-left" class="flex min-w-0 flex-1 items-center pl-1 text-left"
onclick={() => onPick(node.path)} onclick={() => onPick(node.path)}
ondblclick={readonly ? undefined : () => onRename?.(node.path)} ondblclick={readonly ? undefined : () => onRename?.(node.path)}
title={node.path} title={node.path}
data-move-row={readonly ? '' : undefined}
data-path={readonly ? node.path : undefined}
aria-pressed={readonly ? active : undefined}
> >
<span class="truncate">{node.name}</span> <span class="truncate">{node.name}</span>
{#if counts && counts[node.path] !== undefined} {#if counts && counts[node.path] !== undefined}
@@ -256,6 +267,7 @@
{readonly} {readonly}
{selectedPath} {selectedPath}
{counts} {counts}
{forceExpand}
/> />
{/if} {/if}
</li> </li>

View File

@@ -14,26 +14,36 @@
folder keeps its own name. The picker excludes the folder folder keeps its own name. The picker excludes the folder
itself and its descendants. itself and its descendants.
Picker reuses the readonly FolderTree; the dialog owns the selection UX model (Lightroom-style): tree is the primary surface, with a search
(`pickedPath`) so it never fights the global folderPath filter. field on top that filters it live (matches + their ancestors, force-
expanded). Arrow keys rove through visible rows with selection following
focus; Enter confirms; recent destinations render as one-click chips.
Moves are undoable via ⌘Z / the toast's Undo action — the sidecar returns
per-file {from,to} pairs and /files/restore-moves plays them backwards.
--> -->
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { Dialog } from 'bits-ui'; import { Dialog } from 'bits-ui';
import { createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { FolderInput, FolderOpen, Loader2 } from 'lucide-svelte'; import { FolderInput, FolderOpen, History, Loader2, Search } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback'; import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { import {
convertHeap, convertHeap,
movePhotosToFolder, movePhotosToFolder,
moveFolder, moveFolder,
restoreMoves,
listFolders, listFolders,
type PpFolder type PpFolder
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { cachedPhoto } from '$lib/services/photoActions';
import { photoNameAndDir } from '$lib/types/photoprism';
import { filters, setSection, setFolderPath } from '$lib/stores/filters.svelte'; import { filters, setSection, setFolderPath } from '$lib/stores/filters.svelte';
import { isAuthenticated, toOriginalsPath, userLibraryBase } from '$lib/stores/session.svelte'; import { isAuthenticated, toOriginalsPath, userLibraryBase } from '$lib/stores/session.svelte';
import { moveDialog, closeMove } from '$lib/stores/moveDialog.svelte'; import { moveDialog, closeMove } from '$lib/stores/moveDialog.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte'; import FolderTree, { buildTree } from './FolderTree.svelte';
const qc = useQueryClient(); const qc = useQueryClient();
@@ -50,16 +60,74 @@
const kind = $derived(subject?.kind); const kind = $derived(subject?.kind);
const open = $derived(subject !== null); const open = $derived(subject !== null);
let pickedPath = $state<string | null>(null);
let mode = $state<'move' | 'copy'>('move');
let subfolder = $state('');
let deleteHeap = $state(false);
let submitting = $state(false);
let filterText = $state('');
let searchEl = $state<HTMLInputElement | undefined>();
let contentEl = $state<HTMLElement | undefined>();
let recents = $state<string[]>([]);
// ── Recent destinations (Lightroom's "recent folders" affordance) ───
const RECENTS_KEY = $derived(`mule_move_recents:${userLibraryBase()}`);
function loadRecents(): string[] {
if (!browser) return [];
try {
const raw = localStorage.getItem(RECENTS_KEY);
const arr = raw ? (JSON.parse(raw) as string[]) : [];
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
function saveRecent(path: string) {
if (!browser) return;
const next = [path, ...recents.filter((p) => p !== path)].slice(0, 5);
recents = next;
try {
localStorage.setItem(RECENTS_KEY, JSON.stringify(next));
} catch {
/* quota — recents are a nicety */
}
}
// Only offer recents that still exist (or the root sentinel '').
const liveRecents = $derived.by(() => {
const paths = new Set((foldersQuery.data ?? []).map((f) => f.Path));
return recents.filter((p) => p === '' || paths.has(p));
});
// ── Tree building: subject exclusion + search filter ─────────────────
// For folder reparent, exclude the folder itself and everything under it — // For folder reparent, exclude the folder itself and everything under it —
// you can't move a directory into its own subtree. // you can't move a directory into its own subtree.
const folderTree = $derived.by(() => { const basePaths = $derived.by(() => {
const paths = (foldersQuery.data ?? []).map((f) => f.Path); const paths = (foldersQuery.data ?? []).map((f) => f.Path);
if (subject?.kind === 'folder') { if (subject?.kind === 'folder') {
const self = subject.path; const self = subject.path;
return buildTree(paths.filter((p) => p !== self && !p.startsWith(self + '/'))); return paths.filter((p) => p !== self && !p.startsWith(self + '/'));
} }
return buildTree(paths); return paths;
}); });
const filtering = $derived(filterText.trim().length > 0);
const folderTree = $derived.by(() => {
if (!filtering) return buildTree(basePaths);
// Keep matches plus every ancestor so the hit's branch renders whole;
// forceExpand on the tree makes the branch visible without touching
// the sidebar's persisted open/collapse state.
const q = filterText.trim().toLowerCase();
const keep = new Set<string>();
for (const p of basePaths) {
if (!p.toLowerCase().includes(q)) continue;
const parts = p.split('/');
for (let i = 1; i <= parts.length; i++) {
keep.add(parts.slice(0, i).join('/'));
}
}
// Intersect with basePaths so folder-subject exclusion survives.
return buildTree(basePaths.filter((p) => keep.has(p)));
});
const treeIsEmpty = $derived((foldersQuery.data ?? []).length === 0);
const showOptions = $derived(kind === 'heap' || kind === 'photos'); const showOptions = $derived(kind === 'heap' || kind === 'photos');
const showDeleteHeap = $derived(kind === 'heap'); const showDeleteHeap = $derived(kind === 'heap');
@@ -67,6 +135,11 @@
const folderName = $derived( const folderName = $derived(
subject?.kind === 'folder' ? (subject.path.split('/').pop() ?? subject.path) : '' subject?.kind === 'folder' ? (subject.path.split('/').pop() ?? subject.path) : ''
); );
const photoCount = $derived.by(() => {
if (subject?.kind === 'heap') return subject.heap.PhotoCount ?? 0;
if (subject?.kind === 'photos') return subject.uids.length;
return 0;
});
const headerTitle = $derived.by(() => { const headerTitle = $derived.by(() => {
if (subject?.kind === 'folder') return 'Move folder'; if (subject?.kind === 'folder') return 'Move folder';
const verb = mode === 'copy' ? 'Copy' : 'Move'; const verb = mode === 'copy' ? 'Copy' : 'Move';
@@ -75,25 +148,72 @@
}); });
const headerDesc = $derived.by(() => { const headerDesc = $derived.by(() => {
if (subject?.kind === 'heap') { if (subject?.kind === 'heap') {
const n = subject.heap.PhotoCount ?? 0; const n = photoCount;
return `${subject.heap.Title ?? ''} · ${n} photo${n === 1 ? '' : 's'}`; return `${subject?.kind === 'heap' ? (subject.heap.Title ?? '') : ''} · ${n} photo${n === 1 ? '' : 's'}`;
} }
if (subject?.kind === 'photos') { if (subject?.kind === 'photos') {
const n = subject.uids.length; return `${photoCount} photo${photoCount === 1 ? '' : 's'} selected`;
return `${n} photo${n === 1 ? '' : 's'} selected`;
} }
if (subject?.kind === 'folder') return `${folderName} → pick a destination`; if (subject?.kind === 'folder') return `${folderName} → pick a destination`;
return ''; return '';
}); });
let pickedPath = $state<string | null>(null); // ── Validation ───────────────────────────────────────────────────────
let mode = $state<'move' | 'copy'>('move'); /** Mirrors the sidecar's sanitizeFilename rules so bad names are caught
let subfolder = $state(''); * before the request instead of surfacing as a failed toast. */
let deleteHeap = $state(false); const subfolderError = $derived.by(() => {
let submitting = $state(false); const t = subfolder.trim();
if (!t) return null;
if (t.length > 240) return 'Name is too long';
if (t.startsWith('.')) return "Can't start with a dot";
if (/[/\\\u0000]/.test(t)) return 'Slashes arent allowed — one level only';
return null;
});
// Pre-disable "Move" when every selected photo already sits in the target
// folder (only decidable when all photos are in the query cache — unknown
// photos fail open and the sidecar reports "already in target" per photo).
const allAlreadyInTarget = $derived.by(() => {
if (subject?.kind !== 'photos' || mode !== 'move') return false;
if (pickedPath === null || subfolder.trim()) return false;
const dest = toOriginalsPath(pickedPath);
let known = 0;
for (const uid of subject.uids) {
const p = cachedPhoto(uid);
if (!p) return false;
known++;
if (photoNameAndDir(p).path !== dest) return false;
}
return known > 0;
});
const canSubmit = $derived(
pickedPath !== null && !submitting && !subfolderError && !allAlreadyInTarget
);
const disabledReason = $derived.by(() => {
if (pickedPath === null) return 'Pick a destination folder first';
if (subfolderError) return subfolderError;
if (allAlreadyInTarget) return 'Everything is already in this folder';
return undefined;
});
const confirmLabel = $derived.by(() => {
if (kind === 'folder') return `Move “${folderName}”`;
const verb = mode === 'copy' ? 'Copy' : 'Move';
return `${verb} ${photoCount} photo${photoCount === 1 ? '' : 's'}`;
});
// Live destination preview under the tree.
const destPreview = $derived.by(() => {
if (pickedPath === null) return null;
const base = pickedPath === '' ? '/' : pickedPath;
const sub = !subfolderError && subfolder.trim() ? subfolder.trim() : '';
return sub ? (pickedPath === '' ? sub : `${base}/${sub}`) : base;
});
// Reset draft state whenever a new subject is picked (or the dialog closes // Reset draft state whenever a new subject is picked (or the dialog closes
// and reopens), so the form is blank on every fresh open. // and reopens), so the form is blank on every fresh open. Autofocus the
// search field once the portal has rendered.
$effect(() => { $effect(() => {
void subject; void subject;
pickedPath = null; pickedPath = null;
@@ -101,6 +221,11 @@
subfolder = ''; subfolder = '';
deleteHeap = false; deleteHeap = false;
submitting = false; submitting = false;
filterText = '';
if (subject !== null) {
recents = loadRecents();
void tick().then(() => searchEl?.focus());
}
}); });
// Copy mode doesn't change membership, so "delete heap after" is // Copy mode doesn't change membership, so "delete heap after" is
@@ -109,16 +234,96 @@
if (mode === 'copy' && deleteHeap) deleteHeap = false; if (mode === 'copy' && deleteHeap) deleteHeap = false;
}); });
// ── Roving arrow-key focus: selection follows focus ─────────────────
function visibleRows(): HTMLElement[] {
if (!contentEl) return [];
return Array.from(contentEl.querySelectorAll<HTMLElement>('[data-move-row]'));
}
function onContentKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
// Enter confirms from anywhere in the dialog once a destination is
// picked — including the search and subfolder inputs. Row buttons
// also fire their own click (re-picking themselves) first, which
// is harmless.
const inSearch = e.target === searchEl;
if (canSubmit && !(inSearch && pickedPath === null)) {
e.preventDefault();
void submit();
}
return;
}
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
const rows = visibleRows();
if (rows.length === 0) return;
const active = document.activeElement as HTMLElement | null;
const idx = rows.findIndex((r) => r === active);
let next: HTMLElement | undefined;
if (idx === -1) {
// Entering the tree from the search box (or anywhere else).
next = e.key === 'ArrowDown' ? rows[0] : rows[rows.length - 1];
} else {
const ni = idx + (e.key === 'ArrowDown' ? 1 : -1);
if (ni < 0) {
// Off the top — hand focus back to the search field.
e.preventDefault();
searchEl?.focus();
return;
}
next = rows[Math.min(ni, rows.length - 1)];
}
if (next) {
e.preventDefault();
next.focus();
next.scrollIntoView({ block: 'nearest' });
// Selection follows focus (ARIA listbox convention) — arrowing
// through the tree is the same as clicking each row.
pickedPath = next.dataset.path ?? null;
}
}
function moveSummary(verb: string, count: number, errors: number): string { function moveSummary(verb: string, count: number, errors: number): string {
const tail = errors > 0 ? ` · ${errors} skipped` : ''; const tail = errors > 0 ? ` · ${errors} skipped` : '';
return `${verb} ${count} photo${count === 1 ? '' : 's'}${tail}`; return `${verb} ${count} photo${count === 1 ? '' : 's'}${tail}`;
} }
/** Register an undo that plays the sidecar's moved pairs backwards, and
* attach it to the success toast. Runs at most once. */
function registerMoveUndo(
label: string,
moves: { from: string; to: string }[],
extraInvalidate?: () => void
): (() => void) | undefined {
if (moves.length === 0) return undefined;
let undone = false;
const undo = async () => {
if (undone) return;
undone = true;
try {
const res = await restoreMoves(moves);
if (res.errors.length > 0) {
toast.error(`Restore failed for ${res.errors.length} file(s)`, {
description: res.errors[0].error
});
} else {
toast.success(`Moved back ${res.restored.length} file(s)`);
}
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
extraInvalidate?.();
} catch (err) {
undone = false; // network failure — files unmoved, allow retry
toast.error(err instanceof Error ? err.message : 'Undo failed');
}
};
pushUndo(label, undo);
return () => void undo();
}
async function submit() { async function submit() {
const s = moveDialog.subject; const s = moveDialog.subject;
// pickedPath === '' is the root selection; distinguish it from `null` // pickedPath === '' is the root selection; distinguish it from `null`
// (nothing picked) so a falsy check doesn't wrongly block root. // (nothing picked) so a falsy check doesn't wrongly block root.
if (!s || pickedPath === null || submitting) return; if (!s || pickedPath === null || submitting || !canSubmit) return;
submitting = true; submitting = true;
// Snapshot the draft before closing — closeMove() nulls the subject, // Snapshot the draft before closing — closeMove() nulls the subject,
@@ -128,6 +333,7 @@
const sub = subfolder.trim() || null; const sub = subfolder.trim() || null;
const delHeap = mode === 'move' && deleteHeap; const delHeap = mode === 'move' && deleteHeap;
const labelName = folderName; const labelName = folderName;
saveRecent(dest);
// Close the dialog immediately and run the move in the background. The // Close the dialog immediately and run the move in the background. The
// move can be slow (a folder/heap with many files triggers a real // move can be slow (a folder/heap with many files triggers a real
@@ -149,9 +355,14 @@
qc.invalidateQueries({ queryKey: ['photos'] }); qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] }); qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['heaps'] }); qc.invalidateQueries({ queryKey: ['heaps'] });
const runUndo = registerMoveUndo(
`Moved heap “${s.heap.Title ?? ''}” (${r.moved} photos)${r.heap_deleted ? ' — heap itself not restored' : ''}`,
opMode === 'move' ? (r.movedFiles ?? []) : [],
() => qc.invalidateQueries({ queryKey: ['heaps'] })
);
toast.success( toast.success(
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length), moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
{ id: tid } { id: tid, action: runUndo ? { label: 'Undo', onClick: runUndo } : undefined }
); );
if (r.heap_deleted && filters.section === 'heap' && filters.heapUid === s.heap.UID) { if (r.heap_deleted && filters.section === 'heap' && filters.heapUid === s.heap.UID) {
setSection('all-photos'); setSection('all-photos');
@@ -166,19 +377,49 @@
}); });
qc.invalidateQueries({ queryKey: ['photos'] }); qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] }); qc.invalidateQueries({ queryKey: ['folders'] });
const runUndo = registerMoveUndo(
`Moved ${r.moved} photo${r.moved === 1 ? '' : 's'}`,
opMode === 'move' ? (r.movedFiles ?? []) : []
);
toast.success( toast.success(
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length), moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
{ id: tid } { id: tid, action: runUndo ? { label: 'Undo', onClick: runUndo } : undefined }
); );
} else { } else {
// Folder reparent (move only). Translate both the folder's own // Folder reparent (move only). Translate both the folder's own
// path and the destination parent to originals-relative for the // path and the destination parent to originals-relative for the
// sidecar, which moves real directories on disk. // sidecar, which moves real directories on disk.
await moveFolder(toOriginalsPath(s.path), toOriginalsPath(dest)); const r = await moveFolder(toOriginalsPath(s.path), toOriginalsPath(dest));
qc.invalidateQueries({ queryKey: ['photos'] }); qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] }); qc.invalidateQueries({ queryKey: ['folders'] });
const newUiPath = dest === '' ? labelName : `${dest}/${labelName}`; const newUiPath = dest === '' ? labelName : `${dest}/${labelName}`;
toast.success(`Moved ${labelName}${dest === '' ? '/' : dest}`, { id: tid }); const oldUiPath = s.path;
// Inverse of a folder move is another folder move, back under
// the old parent (both paths originals-relative from the
// response — independent of UI base-path prefixes).
let undone = false;
const undo = async () => {
if (undone) return;
undone = true;
try {
await moveFolder(
r.newPath,
r.oldPath.includes('/') ? r.oldPath.slice(0, r.oldPath.lastIndexOf('/')) : ''
);
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
if (filters.folderPath === newUiPath) setFolderPath(oldUiPath);
toast.success(`Moved “${labelName}” back`);
} catch (err) {
undone = false;
toast.error(err instanceof Error ? err.message : 'Undo failed');
}
};
pushUndo(`Moved folder “${labelName}”`, undo);
toast.success(`Moved ${labelName}${dest === '' ? '/' : dest}`, {
id: tid,
action: { label: 'Undo', onClick: () => void undo() }
});
// If we just moved the folder the timeline is showing, follow it. // If we just moved the folder the timeline is showing, follow it.
if (filters.folderPath === s.path) setFolderPath(newUiPath); if (filters.folderPath === s.path) setFolderPath(newUiPath);
} }
@@ -199,8 +440,10 @@
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/> />
<Dialog.Content <Dialog.Content
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[520px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95" class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[520px] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
> >
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div bind:this={contentEl} onkeydown={onContentKeydown} class="grid gap-3" aria-busy={submitting}>
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<FolderInput class="mt-0.5 h-4 w-4 text-muted-foreground" /> <FolderInput class="mt-0.5 h-4 w-4 text-muted-foreground" />
<div class="flex-1"> <div class="flex-1">
@@ -213,16 +456,51 @@
</div> </div>
</div> </div>
<!-- Search over the tree — autofocused, filters live. -->
<div class="relative">
<Search
class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
<input
bind:this={searchEl}
type="text"
placeholder="Search folders…"
aria-label="Search folders"
bind:value={filterText}
class="w-full rounded border border-input bg-background py-1.5 pl-7 pr-2 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<!-- Recent destinations — one-click chips. -->
{#if liveRecents.length > 0 && !filtering}
<div class="flex flex-wrap items-center gap-1" aria-label="Recent destinations">
<History class="h-3 w-3 text-muted-foreground" />
{#each liveRecents as r (r)}
<button
type="button"
class="max-w-[160px] truncate rounded-full border px-2 py-0.5 text-[10px] transition-colors
{pickedPath === r
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-secondary/60 text-muted-foreground hover:bg-accent hover:text-foreground'}"
onclick={() => (pickedPath = r)}
title={r === '' ? '/' : r}
>
{r === '' ? '/' : r.split('/').pop()}
</button>
{/each}
</div>
{/if}
<!-- Folder picker. Readonly FolderTree so the user can't kebab/rename <!-- Folder picker. Readonly FolderTree so the user can't kebab/rename
their way out of the picker mid-flow. --> their way out of the picker mid-flow. -->
<div class="rounded-md border border-border bg-background p-2"> <div class="rounded-md border border-border bg-background p-2">
<div class="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground"> <div class="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{kind === 'folder' ? 'Destination parent' : 'Destination'} {kind === 'folder' ? 'Destination parent' : 'Destination'}
</div> </div>
<div class="max-h-[200px] overflow-y-auto"> <div class="max-h-[220px] overflow-y-auto">
{#if foldersQuery.isPending} {#if foldersQuery.isPending}
<InlineLoader size="sm" label="Loading folders…" /> <InlineLoader size="sm" label="Loading folders…" />
{:else if (foldersQuery.data ?? []).length === 0} {:else if treeIsEmpty}
<EmptyState <EmptyState
size="compact" size="compact"
icon={FolderOpen} icon={FolderOpen}
@@ -240,16 +518,26 @@
class:text-primary-foreground={pickedPath === ''} class:text-primary-foreground={pickedPath === ''}
class:hover:bg-primary={pickedPath === ''} class:hover:bg-primary={pickedPath === ''}
onclick={() => (pickedPath = '')} onclick={() => (pickedPath = '')}
data-move-row=""
data-path=""
aria-pressed={pickedPath === ''}
> >
/ /
</button> </button>
{#if filtering && folderTree.length === 0}
<p class="px-2 py-2 text-[11px] text-muted-foreground">
No folders match “{filterText.trim()}”.
</p>
{:else}
<FolderTree <FolderTree
nodes={folderTree} nodes={folderTree}
onPick={(p) => (pickedPath = p)} onPick={(p) => (pickedPath = p)}
selectedPath={pickedPath} selectedPath={pickedPath}
readonly readonly
forceExpand={filtering}
/> />
{/if} {/if}
{/if}
</div> </div>
</div> </div>
@@ -257,7 +545,11 @@
that keeps the folder's own name). --> that keeps the folder's own name). -->
{#if showOptions} {#if showOptions}
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center gap-4 text-[12px]"> <div
class="flex items-center gap-4 text-[12px]"
role="radiogroup"
aria-label="Move or copy"
>
<label class="flex items-center gap-1.5"> <label class="flex items-center gap-1.5">
<input type="radio" bind:group={mode} value="move" /> <input type="radio" bind:group={mode} value="move" />
Move Move
@@ -272,9 +564,15 @@
<input <input
type="text" type="text"
placeholder="e.g. 2024-summer" placeholder="e.g. 2024-summer"
aria-label="New subfolder name"
aria-invalid={Boolean(subfolderError)}
bind:value={subfolder} bind:value={subfolder}
class="rounded border border-input bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring" class="rounded border bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring
{subfolderError ? 'border-destructive' : 'border-input'}"
/> />
{#if subfolderError}
<span class="text-[11px] text-destructive" role="alert">{subfolderError}</span>
{/if}
</label> </label>
{#if showDeleteHeap} {#if showDeleteHeap}
<label class="flex items-center gap-1.5 text-[12px]"> <label class="flex items-center gap-1.5 text-[12px]">
@@ -285,6 +583,14 @@
</div> </div>
{/if} {/if}
<!-- Live destination preview -->
{#if destPreview !== null}
<p class="truncate text-[11px] text-muted-foreground" aria-live="polite">
{kind === 'folder' ? `Moving “${folderName}` : `${mode === 'copy' ? 'Copying' : 'Moving'} ${photoCount} photo${photoCount === 1 ? '' : 's'}`}
<span class="text-foreground/70">{destPreview}</span>
</p>
{/if}
<div class="flex items-center justify-end gap-2 pt-1"> <div class="flex items-center justify-end gap-2 pt-1">
<button <button
type="button" type="button"
@@ -298,14 +604,16 @@
type="button" type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={submit} onclick={submit}
disabled={pickedPath === null || submitting} disabled={!canSubmit}
title={disabledReason}
> >
{#if submitting} {#if submitting}
<Loader2 class="h-3 w-3 animate-spin" /> <Loader2 class="h-3 w-3 animate-spin" />
{/if} {/if}
{kind === 'folder' ? 'Move' : mode === 'copy' ? 'Copy' : 'Move'} {confirmLabel}
</button> </button>
</div> </div>
</div>
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root> </Dialog.Root>

View File

@@ -1051,6 +1051,8 @@ export interface HeapConvertBody {
export interface HeapConvertResult { export interface HeapConvertResult {
moved: number; moved: number;
copied: number; copied: number;
/** Per-file {from,to} pairs for move mode — the undo payload. */
movedFiles: { from: string; to: string }[];
errors: { uid: string; reason: string }[]; errors: { uid: string; reason: string }[];
heap_deleted: boolean; heap_deleted: boolean;
} }
@@ -1078,6 +1080,8 @@ export interface PhotosMoveBody {
export interface PhotosMoveResult { export interface PhotosMoveResult {
moved: number; moved: number;
copied: number; copied: number;
/** Per-file {from,to} pairs for move mode — the undo payload. */
movedFiles: { from: string; to: string }[];
errors: { uid: string; reason: string }[]; errors: { uid: string; reason: string }[];
} }
@@ -1085,6 +1089,21 @@ export async function movePhotosToFolder(body: PhotosMoveBody): Promise<PhotosMo
return callSidecar('POST', '/photos/move', body) as Promise<PhotosMoveResult>; return callSidecar('POST', '/photos/move', body) as Promise<PhotosMoveResult>;
} }
export interface RestoreMovesResult {
restored: { from: string; to: string }[];
errors: { path: string; error: string }[];
}
/** Inverse of a photo/heap move: pass the `movedFiles` pairs from the
* move response verbatim and the sidecar renames each file back to its
* original folder (both ends scope-checked, no clobbering). Powers ⌘Z
* undo for moves. */
export async function restoreMoves(
moves: { from: string; to: string }[]
): Promise<RestoreMovesResult> {
return callSidecar('POST', '/files/restore-moves', { moves }) as Promise<RestoreMovesResult>;
}
// ── Reparent a folder (move the directory under a different parent) ────────── // ── Reparent a folder (move the directory under a different parent) ──────────
export interface FolderMoveResult { export interface FolderMoveResult {