Files
phokus/src/components/sidebar/FolderItem.tsx
T
LyAhn b8d009c973
github/actions/ci GitHub Actions CI finished: success
fix(frontend): sync sidebar pause menu with per-worker background task state
Only require a worker to be paused if it currently has pending work for
that folder, instead of demanding all four worker flags be true. The
background tasks panel only lets you toggle stages it shows, so the old
check stayed out of sync after pausing everything visible.

Also grants start-dragging and start-resize-dragging window permissions.
2026-07-06 00:47:13 +01:00

293 lines
11 KiB
TypeScript

import { useState, type MouseEvent } from 'react'
import { Reorder, useDragControls } from 'framer-motion'
import { open } from '@tauri-apps/plugin-dialog'
import { useGalleryStore, type Folder, type IndexProgress } from '../../store'
import { ContextMenu, MenuItem, MenuSeparator } from '../menu'
import { InlineConfirm } from '../InlineConfirm'
import { InlineRename } from '../InlineRename'
import { Tooltip } from '../Tooltip'
import { CloseIcon, FolderIcon } from '../icons'
export function FolderItem({
folder,
selected,
progress,
customOrdering,
dragging,
onDragStart,
onDragEnd,
onKeyboardMove,
}: {
folder: Folder
selected: boolean
progress: IndexProgress | undefined
customOrdering: boolean
dragging: boolean
onDragStart: (pointerY: number) => void
onDragEnd: () => void
onKeyboardMove: (direction: -1 | 1) => void
}) {
const dragControls = useDragControls()
const {
selectFolder,
removeFolder,
reindexFolder,
renameFolder,
updateFolderPath,
toggleMutedFolder,
} = useGalleryStore()
const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds)
const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused)
const folderWorkers = useGalleryStore((state) => state.workerPaused[folder.id])
const folderJobs = useGalleryStore((state) => state.mediaJobProgress[folder.id])
const isMuted = mutedFolderIds.includes(folder.id)
// "Fully paused" means every worker that currently has pending work is
// paused. Workers with nothing pending are ignored — the background tasks
// panel only lets you toggle the stages it actually shows, so requiring
// every worker key to be literally true left this permanently out of sync
// (menu kept offering "Pause" after the visible work was already paused).
const hasPendingWork =
(folderJobs?.thumbnail_pending ?? 0) > 0 ||
(folderJobs?.metadata_pending ?? 0) > 0 ||
(folderJobs?.embedding_pending ?? 0) > 0 ||
(folderJobs?.tagging_pending ?? 0) > 0
const isPausedAll = hasPendingWork
? ((folderJobs?.thumbnail_pending ?? 0) === 0 || !!folderWorkers?.thumbnail) &&
((folderJobs?.metadata_pending ?? 0) === 0 || !!folderWorkers?.metadata) &&
((folderJobs?.embedding_pending ?? 0) === 0 || !!folderWorkers?.embedding) &&
((folderJobs?.tagging_pending ?? 0) === 0 || !!folderWorkers?.tagging)
: !!folderWorkers &&
folderWorkers.thumbnail &&
folderWorkers.metadata &&
folderWorkers.embedding &&
folderWorkers.tagging
const isIndexing = progress && !progress.done
const isMissing = !!folder.scan_error && !isIndexing
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
const [renaming, setRenaming] = useState(false)
const [confirmingRemoval, setConfirmingRemoval] = useState(false)
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({ x: e.clientX, y: e.clientY })
}
const handleLocateFolder = async () => {
const picked = await open({
directory: true,
multiple: false,
title: `Locate "${folder.name}"`,
})
if (picked && typeof picked === 'string') {
await updateFolderPath(folder.id, picked)
}
}
return (
<Reorder.Item
as="div"
value={folder.id}
drag={customOrdering ? 'y' : false}
dragControls={dragControls}
dragListener={false}
dragElastic={0.08}
onDragEnd={onDragEnd}
layout
transition={{ layout: { type: 'spring', stiffness: 520, damping: 38, mass: 0.55 } }}
className={`relative z-0 ${dragging ? 'z-20' : ''}`}
style={{ position: 'relative' }}
>
<div
className={`group relative flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 transition-all duration-150 ${
selected ? 'bg-white/8 text-white' : 'text-gray-500 hover:bg-white/5 hover:text-gray-200'
} ${dragging ? 'scale-[1.02] bg-white/[0.11] text-white shadow-xl ring-1 shadow-black/25 ring-white/15' : ''}`}
onClick={() => !renaming && selectFolder(folder.id)}
onContextMenu={handleContextMenu}
>
{customOrdering ? (
<Tooltip label={`Drag to reorder ${folder.name}`} followCursor delay={1000}>
<button
type="button"
aria-label={`Reorder ${folder.name}`}
className={`-ml-1 flex h-7 w-5 shrink-0 touch-none items-center justify-center rounded-md transition-colors ${
dragging
? 'cursor-grabbing bg-white/10 text-gray-300'
: 'cursor-grab text-gray-700 hover:bg-white/[0.06] hover:text-gray-400'
}`}
onPointerDown={(event) => {
event.stopPropagation()
onDragStart(event.clientY)
dragControls.start(event)
}}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
event.preventDefault()
event.stopPropagation()
onKeyboardMove(event.key === 'ArrowUp' ? -1 : 1)
}}
>
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
<circle cx="3" cy="3" r="1" />
<circle cx="9" cy="3" r="1" />
<circle cx="3" cy="9" r="1" />
<circle cx="9" cy="9" r="1" />
</svg>
</button>
</Tooltip>
) : null}
{isMissing ? (
<span className="shrink-0 text-amber-400">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
</span>
) : (
<FolderIcon className="h-3.5 w-3.5 shrink-0" />
)}
<div className="min-w-0 flex-1">
{renaming ? (
<InlineRename
name={folder.name}
onRename={(next) => renameFolder(folder.id, next)}
onClose={() => setRenaming(false)}
/>
) : (
<div
className={`truncate text-[13px] leading-tight font-medium ${selected ? 'text-white' : ''}`}
>
{folder.name}
</div>
)}
{isIndexing ? (
<>
<div className="mt-0.5 text-[11px] text-gray-600">
{progress.indexed}/{progress.total}
</div>
<div className="mt-1.5 h-px overflow-hidden rounded bg-white/10">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{
width: `${progress.total > 0 ? (progress.indexed / progress.total) * 100 : 0}%`,
}}
/>
</div>
</>
) : (
<div className="mt-0.5 text-[11px] text-gray-600">
{folder.image_count.toLocaleString()}
</div>
)}
</div>
{/* Hover action buttons */}
{!renaming &&
(confirmingRemoval ? (
<InlineConfirm
onConfirm={() => {
void removeFolder(folder.id)
setConfirmingRemoval(false)
}}
onCancel={() => setConfirmingRemoval(false)}
/>
) : (
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<Tooltip label="Reindex" anchorToCursor>
<button
className="rounded p-1 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-300"
onClick={(e) => {
e.stopPropagation()
void reindexFolder(folder.id)
}}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.75}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</Tooltip>
<Tooltip label="Remove from app" anchorToCursor>
<button
className="rounded p-1 text-gray-600 transition-colors hover:bg-red-500/10 hover:text-red-400"
onClick={(e) => {
e.stopPropagation()
setConfirmingRemoval(true)
}}
>
<CloseIcon className="h-3 w-3" strokeWidth={1.75} />
</button>
</Tooltip>
</div>
))}
</div>
{isMissing && (
<div className="mx-2 mb-1 rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2">
<p className="mb-1.5 text-[11px] font-medium text-amber-400">Folder not found</p>
<p className="mb-2 text-[10px] leading-snug text-gray-500">
This folder may have been moved or renamed. Locate it to resume, or remove it from the
app.
</p>
<div className="flex gap-1.5">
<button
className="flex-1 rounded-md bg-amber-500/20 px-2 py-1 text-[10px] font-medium text-amber-300 transition-colors hover:bg-amber-500/30 hover:text-amber-200"
onClick={(e) => {
e.stopPropagation()
void handleLocateFolder()
}}
>
Locate Folder
</button>
<button
className="flex-1 rounded-md bg-white/5 px-2 py-1 text-[10px] font-medium text-gray-400 transition-colors hover:bg-red-500/15 hover:text-red-400"
onClick={(e) => {
e.stopPropagation()
setConfirmingRemoval(true)
}}
>
Remove
</button>
</div>
</div>
)}
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
size="sm"
onClose={() => setContextMenu(null)}
>
<MenuItem label="Reindex" onSelect={() => void reindexFolder(folder.id)} />
<MenuItem label="Rename" onSelect={() => setRenaming(true)} />
<MenuItem
label={isPausedAll ? 'Resume background work' : 'Pause background work'}
onSelect={() => setAllWorkersPaused(folder.id, !isPausedAll)}
/>
<MenuItem
label={isMuted ? 'Unmute notifications' : 'Mute notifications'}
onSelect={() => toggleMutedFolder(folder.id)}
/>
{folder.scan_error ? (
<MenuItem label="Locate Folder" onSelect={() => void handleLocateFolder()} />
) : null}
<MenuSeparator />
<MenuItem label="Remove from app" danger onSelect={() => setConfirmingRemoval(true)} />
</ContextMenu>
)}
</Reorder.Item>
)
}