feat: watcher source-root resolution, folder rename, alt-drag copy

Three small phase-11 follow-ups in one commit since they all touch the
same surface area.

1. Watcher source-root resolution
   The watch_folders task previously called scan_folder.delay(parent_dir)
   when files arrived, with no source_root_id. scan_folder would then
   auto-create a fresh SourceRoot for that arbitrary subdir, polluting
   the source_root list. Now the watcher loads (path, id) pairs at
   startup, defines find_source_root_for() that walks the parent chain,
   and dispatches with the resolved id. Events under no known root are
   logged at debug and ignored instead of creating stale rows.

2. Folder rename via UI
   - Backend: PATCH /folders/{id} accepts { name } and updates the
     SourceRoot display label only. The on-disk path is controlled by
     the docker mount and intentionally not editable from the UI.
   - Frontend: double-click a folder row in the LeftSidebar to start
     editing; Enter or blur commits, Esc reverts. New renamingId /
     renameDraft local state and a renameMutation that invalidates
     ['folders']. The click handler ignores clicks while the row is
     in edit mode so it doesn't navigate.
   - api.ts: new sourceFolders.rename(id, name) helper.

3. Bulk copy via Alt-drag onto folder
   - Backend: new POST /photos/copy that mirrors /photos/move but uses
     shutil.copy2 and creates fresh Photo rows with is_duplicate=true.
     Name collisions are resolved by appending " (copy)", " (copy 2)",
     etc., up to 100 tries before erroring. Same target_id resolution
     as /move (folder id or source root id).
   - Frontend: photos.copy(ids, targetId) helper. LeftSidebar's
     handleDrop now takes a `copy` flag derived from e.altKey on the
     drop event; folder targets dispatch copyDropMutation when held,
     moveDropMutation otherwise. The drop-effect cursor flips to
     'copy' on dragover when Alt is pressed so the user gets visual
     confirmation. Discard target ignores the modifier.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 00:58:49 +02:00
parent a8750afef0
commit 63383ecf1c
5 changed files with 290 additions and 24 deletions

View File

@@ -31,6 +31,10 @@ export function LeftSidebar() {
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
const [isScanning, setIsScanning] = useState(false)
// Inline rename state for source-root rows. Stores the id being edited
// and the draft name. Double-click a folder row to start.
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameDraft, setRenameDraft] = useState('')
const queryClient = useQueryClient()
const clearAllFilters = useFilterStore((s) => s.clearAll)
@@ -76,6 +80,28 @@ export function LeftSidebar() {
toast.error('Move failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
// Bulk copy mutation — Alt-drag uses this instead of move.
const copyDropMutation = useMutation({
mutationFn: ({ targetId, photoIds }: { targetId: string; photoIds: string[] }) =>
photosApi.copy(photoIds, targetId),
onSuccess: (data) => {
const copied = data?.copied ?? 0
const errCount = data?.errors?.length ?? 0
if (copied > 0) {
toast.success(
'Copied',
`${copied} photo${copied > 1 ? 's' : ''}${errCount ? ` (${errCount} skipped)` : ''}`
)
} else if (errCount > 0) {
toast.error('Copy failed', `${errCount} file${errCount > 1 ? 's' : ''} could not be copied`)
}
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
},
onError: (e: any) =>
toast.error('Copy failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
// Reads the dragged ids out of a drop event payload.
const readDragIds = (e: React.DragEvent): string[] | null => {
const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME)
@@ -121,6 +147,16 @@ export function LeftSidebar() {
queryFn: sourceFolders.list,
})
const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
sourceFolders.rename(id, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['folders'] })
},
onError: (e: any) =>
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
// Mutation for scanning all folders
const scanLibraryMutation = useMutation({
mutationFn: library.scan,
@@ -199,14 +235,18 @@ export function LeftSidebar() {
return id === 'discarded' || id.startsWith('folder-')
}
const handleDrop = (id: string, ids: string[]) => {
const handleDrop = (id: string, ids: string[], copy: boolean) => {
if (id === 'discarded') {
discardDropMutation.mutate(ids)
return
}
if (id.startsWith('folder-')) {
const targetId = id.slice('folder-'.length)
moveDropMutation.mutate({ targetId, photoIds: ids })
if (copy) {
copyDropMutation.mutate({ targetId, photoIds: ids })
} else {
moveDropMutation.mutate({ targetId, photoIds: ids })
}
}
}
@@ -230,6 +270,7 @@ export function LeftSidebar() {
)}
style={{ paddingLeft: `${8 + depth * 16}px` }}
onClick={() => {
if (renamingId === item.id) return
setSelectedItem(item.id)
if (hasChildren) {
toggleExpanded(item.id)
@@ -237,10 +278,22 @@ export function LeftSidebar() {
applyLibraryNode(item.id)
}
}}
onDoubleClick={
item.id.startsWith('folder-')
? (e) => {
e.stopPropagation()
setRenamingId(item.id)
setRenameDraft(item.label)
}
: undefined
}
onDragOver={acceptsDrop ? (e) => {
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
e.preventDefault()
e.dataTransfer.dropEffect = item.id === 'discarded' ? 'move' : 'move'
// Alt held → copy (only meaningful for folder targets;
// discarding doesn't copy).
const wantCopy = e.altKey && item.id.startsWith('folder-')
e.dataTransfer.dropEffect = wantCopy ? 'copy' : 'move'
if (dropTargetId !== item.id) setDropTargetId(item.id)
}
} : undefined}
@@ -253,7 +306,7 @@ export function LeftSidebar() {
e.preventDefault()
setDropTargetId(null)
const ids = readDragIds(e)
if (ids) handleDrop(item.id, ids)
if (ids) handleDrop(item.id, ids, e.altKey)
} : undefined}
>
{/* Expand/Collapse Icon */}
@@ -282,8 +335,34 @@ export function LeftSidebar() {
</span>
)}
{/* Label */}
<span className="flex-1 truncate">{item.label}</span>
{/* Label (or inline rename input for folder rows) */}
{renamingId === item.id ? (
<input
autoFocus
type="text"
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onBlur={() => {
const next = renameDraft.trim()
const id = item.id.slice('folder-'.length)
if (next && next !== item.label) {
renameMutation.mutate({ id, name: next })
}
setRenamingId(null)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setRenamingId(null)
}
}}
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
/>
) : (
<span className="flex-1 truncate">{item.label}</span>
)}
{/* Count Badge */}
{item.count !== undefined && item.count > 0 && (