Skip to content

Commit 11db9c2

Browse files
sirozhaclaude
andcommitted
feat(webui): merge file-manager folder/chevron into one hover toggle
The directory expand/collapse control now shows the folder icon by default and crossfades to a chevron on hover (motion, reduced-motion aware); the single element toggles expansion on click. Nesting indent gains the icon->text gap (22px/level) so a child's icon lines up under its parent's label, and the header expand-all control moves to the shared Button. Folder and chevron share the text-blue-400 accent set on the parent (header chevron picks it up on hover). Also prunes ~46 restatement/justification comments and a dead collectAllFilePaths export (plus its tests) to match the house style. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b6db0b5 commit 11db9c2

14 files changed

Lines changed: 55 additions & 198 deletions

frontend/src/components/shared/file-manager/file-manager-actions.tsx

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ export const downloadAction = (
2626
};
2727
};
2828

29-
/** Built-in copy-path action. */
3029
export const copyPathAction = (onCopyPath: (file: FileNode) => void): FileManagerAction => ({
3130
appliesToDirs: true,
3231
icon: ClipboardCopy,
@@ -48,14 +47,6 @@ export const deleteAction = (onDelete: (file: FileNode) => void): FileManagerAct
4847
separatorBefore: true,
4948
});
5049

51-
// ── Bulk-action helpers ─────────────────────────────────────────────────────
52-
//
53-
// Each helper produces a `FileManagerBulkAction` with sensible defaults; the
54-
// caller passes any callback / config it needs and lets the bar handle the
55-
// rendering, confirmation and dedup. Mirrors the row-action helpers above so
56-
// consumers compose `bulkActions={[bulkXAction(...), bulkYAction(...)]}` the
57-
// same way they compose `actions={[xAction(...), yAction(...)]}`.
58-
5950
interface BulkDeleteOptions {
6051
/** Confirm-dialog body formatter. Default: "This will delete N items. This action cannot be undone." */
6152
confirmDescription?: (countLabel: string) => string;
@@ -202,10 +193,6 @@ export const bulkDownloadAction = (
202193

203194
const href = getDownloadHref(files);
204195

205-
// Trigger the download via a transient anchor so the browser respects
206-
// the `download` attribute and the backend's `Content-Disposition`.
207-
// `window.open` would do, but it can be blocked as a popup and doesn't
208-
// honour the filename hint the same way.
209196
const anchor = document.createElement('a');
210197

211198
anchor.href = href;

frontend/src/components/shared/file-manager/file-manager-bulk-actions-bar.tsx

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,6 @@ export function FileManagerBulkActionsBar({
6060
}: FileManagerBulkActionsBarProps) {
6161
const [pendingAction, setPendingAction] = useState<FileManagerBulkAction | null>(null);
6262

63-
// Resolve the deduped FileNode[] once per render — every action callback,
64-
// confirm-dialog title and isDisabled / isHidden predicate share it, so
65-
// recomputing per call would be wasteful.
6663
const dedupedFiles = useMemo(() => {
6764
if (selectedPaths.size === 0) {
6865
return [];
@@ -89,9 +86,6 @@ export function FileManagerBulkActionsBar({
8986

9087
const runAction = useCallback(
9188
async (action: FileManagerBulkAction) => {
92-
// `dedupedFiles` is captured at click time — the state used by the
93-
// bar at that moment matches what the host receives, even if the
94-
// selection mutates while an async confirm is open.
9589
await action.onSelect(dedupedFiles);
9690
},
9791
[dedupedFiles],
@@ -215,11 +209,6 @@ export function FileManagerBulkActionsBar({
215209
);
216210
}
217211

218-
/**
219-
* Inline button for a single non-overflow bulk action. Extracted so the icon-only
220-
* variant (no `label` text on narrow screens) can be added later without bloating
221-
* the parent's JSX.
222-
*/
223212
function BulkActionButton({ action, isDisabled, onClick }: BulkActionButtonProps) {
224213
const Icon = action.icon as ComponentType<{ className?: string }> | undefined;
225214
const button = (
@@ -235,9 +224,6 @@ function BulkActionButton({ action, isDisabled, onClick }: BulkActionButtonProps
235224
</Button>
236225
);
237226

238-
// Tooltip surfaces the label when the inline text is hidden on narrow
239-
// viewports (icon-only mode). On wider screens the label is already visible,
240-
// so the tooltip is a harmless duplicate but still helps with keyboard focus.
241227
if (!action.icon) {
242228
return button;
243229
}

frontend/src/components/shared/file-manager/file-manager-row.tsx

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ChevronRight, Ellipsis } from 'lucide-react';
2+
import { motion, type Transition, useReducedMotion, type Variants } from 'motion/react';
23
import {
34
type CSSProperties,
45
memo,
@@ -44,6 +45,21 @@ import { formatModifiedRelative as defaultFormatModified, formatFileSize } from
4445
const SKIP_ROW_CLICK_ATTR = 'data-fm-skip-row-click';
4546
const skipRowClickProps = { [SKIP_ROW_CLICK_ATTR]: '' };
4647

48+
// The `rest`/`hover` labels are set by the toggle's `whileHover` and reach both
49+
// icon layers via motion variant propagation — the layers declare no gesture of
50+
// their own.
51+
const ICON_SWAP_TRANSITION: Transition = { duration: 0.15, ease: 'easeOut' };
52+
const FOLDER_ICON_VARIANTS: Variants = {
53+
hover: { opacity: 0, scale: 0.8 },
54+
rest: { opacity: 1, scale: 1 },
55+
};
56+
const CHEVRON_ICON_VARIANTS: Variants = {
57+
hover: { opacity: 1, scale: 1 },
58+
rest: { opacity: 0, scale: 0.8 },
59+
};
60+
const FOLDER_ICON_VARIANTS_REDUCED: Variants = { hover: { opacity: 0 }, rest: { opacity: 1 } };
61+
const CHEVRON_ICON_VARIANTS_REDUCED: Variants = { hover: { opacity: 1 }, rest: { opacity: 0 } };
62+
4763
/**
4864
* Layout/visibility/i18n props that are identical for every row in the tree.
4965
* `FileManager` builds this object once with `useMemo` so memoized rows do not
@@ -190,6 +206,10 @@ function FileManagerRowImpl({
190206
[file.groupIcon, file.isDir, file.name, isExpanded],
191207
);
192208

209+
const prefersReducedMotion = useReducedMotion();
210+
const folderIconVariants = prefersReducedMotion ? FOLDER_ICON_VARIANTS_REDUCED : FOLDER_ICON_VARIANTS;
211+
const chevronIconVariants = prefersReducedMotion ? CHEVRON_ICON_VARIANTS_REDUCED : CHEVRON_ICON_VARIANTS;
212+
193213
const visibleActions = useMemo(() => buildVisibleActions(actions, file), [actions, file]);
194214

195215
const handleRowClick = (event: ReactMouseEvent) => {
@@ -219,9 +239,7 @@ function FileManagerRowImpl({
219239
// browsers can override this by passing `onOpenDirectory`, which gets called
220240
// instead — typical for drilling into a remote container directory by
221241
// replacing the listing rather than expanding inline. Files always forward
222-
// to `onOpen` — typically wired to download / preview / open-in-tab. The
223-
// chevron icon on the row's left edge always toggles expand/collapse for
224-
// directories, regardless of `onOpenDirectory`.
242+
// to `onOpen` — typically wired to download / preview / open-in-tab.
225243
const handleRowDoubleClick = (event: ReactMouseEvent) => {
226244
// Same React-tree-bubbling guard as `handleRowClick` — a double-click
227245
// on a portaled menu item must not be treated as a row "open" gesture
@@ -442,42 +460,52 @@ function FileManagerRowImpl({
442460
/>
443461
)}
444462

445-
<div className="relative flex min-w-0 items-center gap-1.5 self-stretch pl-[calc(var(--fm-depth)*16px)]">
463+
<div className="relative flex min-w-0 items-center gap-1.5 self-stretch pl-[calc(var(--fm-depth)*22px)]">
446464
{Array.from({ length: file.depth }, (_, i) => (
447465
<span
448466
aria-hidden="true"
449467
className="bg-border pointer-events-none absolute -inset-y-1.75 w-px"
450468
key={i}
451-
style={{ left: `${i * 16 + 6}px` }}
469+
style={{ left: `${i * 22 + 6}px` }}
452470
/>
453471
))}
454472
{file.isDir ? (
455-
<span
473+
<motion.span
474+
animate="rest"
456475
aria-hidden="true"
457-
className="text-muted-foreground hover:bg-muted -mx-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded transition-colors"
476+
className="relative -mx-0.5 inline-flex size-4 shrink-0 items-center justify-center text-blue-400"
477+
initial="rest"
458478
onClick={() => {
459-
// Mirror the double-click / Enter semantics here so the
460-
// chevron stays consistent with the row-level "open"
461-
// gesture: navigation-style consumers (e.g. the remote
462-
// container browser) drill into the folder instead of
463-
// toggling expansion that has no children to show.
479+
// Mirror the double-click / Enter "open" gesture: navigation-style
480+
// consumers (e.g. the remote container browser) drill into the folder
481+
// via onOpenDirectory instead of toggling inline expansion.
464482
if (onOpenDirectory) {
465483
onOpenDirectory(file);
466484
} else {
467485
onToggleExpand(file.path, isExpanded);
468486
}
469487
}}
488+
whileHover="hover"
470489
{...skipRowClickProps}
471490
>
472-
<ChevronRight className={cn('size-3.5 transition-transform', isExpanded && 'rotate-90')} />
473-
</span>
491+
<motion.span
492+
className="absolute inset-0 flex items-center justify-center"
493+
transition={ICON_SWAP_TRANSITION}
494+
variants={folderIconVariants}
495+
>
496+
<Icon className="size-4 shrink-0" />
497+
</motion.span>
498+
<motion.span
499+
className="absolute inset-0 flex items-center justify-center"
500+
transition={ICON_SWAP_TRANSITION}
501+
variants={chevronIconVariants}
502+
>
503+
<ChevronRight className={cn('size-4 transition-transform', isExpanded && 'rotate-90')} />
504+
</motion.span>
505+
</motion.span>
474506
) : (
475-
<span
476-
aria-hidden="true"
477-
className="-mx-0.5 size-4 shrink-0"
478-
/>
507+
<Icon className={cn('-mx-0.5 size-4 shrink-0', tone)} />
479508
)}
480-
<Icon className={cn('size-4 shrink-0', tone)} />
481509
<FileManagerHighlightedName
482510
className={cn('text-sm', file.isGroupRoot && 'font-semibold')}
483511
name={file.name}

frontend/src/components/shared/file-manager/file-manager-skeleton.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,8 @@ export function FileManagerSkeleton({
8989
/>
9090
)}
9191

92-
<div className="flex min-w-0 items-center gap-1.5 pl-[calc(var(--fm-depth)*16px)]">
93-
{row.isDir ? (
94-
<Skeleton className="-mx-0.5 size-4 shrink-0 rounded-sm" />
95-
) : (
96-
<span
97-
aria-hidden="true"
98-
className="-mx-0.5 size-4 shrink-0"
99-
/>
100-
)}
101-
<Skeleton className="size-4 shrink-0 rounded-sm" />
92+
<div className="flex min-w-0 items-center gap-1.5 pl-[calc(var(--fm-depth)*22px)]">
93+
<Skeleton className="-mx-0.5 size-4 shrink-0 rounded-sm" />
10294
<Skeleton className={cn('h-4', row.nameWidth)} />
10395
</div>
10496

frontend/src/components/shared/file-manager/file-manager-storage.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,6 @@ const fileManagerSortingSchema = z
1919
})
2020
.nullable();
2121

22-
/**
23-
* Loader for the FileManager sort descriptor. Both "nothing stored" and
24-
* "stored as null" surface as `null` (no-sort) — the two cases are
25-
* indistinguishable from the consumer's point of view.
26-
*/
2722
export const loadFileManagerSorting = (key: string): FileManagerSortState =>
2823
getStorageItem(key, fileManagerSortingSchema);
2924

frontend/src/components/shared/file-manager/file-manager-tree-node.tsx

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,6 @@ interface FileManagerTreeNodeProps {
2626
setSize: number;
2727
}
2828

29-
/**
30-
* Recursive tree node renderer. The component itself is not memoized — `FileManagerRow`
31-
* is, which is where per-row reconciliation savings actually matter. Extracting the
32-
* recursion into a component (instead of an inline `renderNode` function) keeps
33-
* `FileManager` lean and gives React DevTools a real boundary to inspect.
34-
*/
3529
export function FileManagerTreeNode({
3630
actions,
3731
activeRowPath,
@@ -51,11 +45,6 @@ export function FileManagerTreeNode({
5145
const renderChildren = node.isDir && isExpanded && node.children.length > 0;
5246
const dnd = bindNodeDnd(node);
5347

54-
// Pre-resolve tri-state + subtree paths per row: keeping the lookup outside
55-
// the memoized `FileManagerRow` lets each row receive primitive/stable props
56-
// (`'indeterminate' | true | false | undefined` plus a `Map`-stored array
57-
// reference) so a selection change only re-renders the rows whose computed
58-
// state actually flipped.
5948
const dirCheckboxState = node.isDir ? (dirSelectionStates.get(node.path) ?? false) : undefined;
6049
const subtreePaths = node.isDir ? dirSubtreePaths.get(node.path) : undefined;
6150

frontend/src/components/shared/file-manager/file-manager-types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ export interface FileManagerAction {
3434
label: string;
3535
/** Triggered on item activation. Ignored when `getHref` is set. */
3636
onSelect: (file: FileNode) => void;
37-
/** When true, separator is rendered before this action. */
3837
separatorBefore?: boolean;
3938
variant?: 'default' | 'destructive';
4039
}
@@ -118,7 +117,6 @@ export interface FileManagerEmptyAreaAction {
118117
id: string;
119118
label: string;
120119
onSelect: () => void;
121-
/** When true, separator is rendered before this item. */
122120
separatorBefore?: boolean;
123121
variant?: 'default' | 'destructive';
124122
}

frontend/src/components/shared/file-manager/file-manager-utils.test.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
buildFileManagerGridTemplate,
99
buildFileManagerTree,
1010
clamp,
11-
collectAllFilePaths,
1211
collectAllNodePaths,
1312
collectDirectoryPaths,
1413
collectSubtreePaths,
@@ -256,26 +255,6 @@ describe('buildFileManagerTree', () => {
256255
});
257256
});
258257

259-
describe('collectAllFilePaths', () => {
260-
it('returns only file paths (no directories, no group roots)', () => {
261-
const tree = buildFileManagerTree(
262-
[file('a/b.txt'), file('a/c.txt'), file('d/e.txt')],
263-
[
264-
{ id: 'a', label: 'A', pathPrefix: 'a' },
265-
{ id: 'd', label: 'D', pathPrefix: 'd' },
266-
],
267-
);
268-
269-
expect(collectAllFilePaths(tree).sort()).toEqual(['a/b.txt', 'a/c.txt', 'd/e.txt']);
270-
});
271-
272-
it('omits synthetic intermediate directories from the result', () => {
273-
const tree = buildFileManagerTree([file('foo/bar/baz.txt')]);
274-
275-
expect(collectAllFilePaths(tree)).toEqual(['foo/bar/baz.txt']);
276-
});
277-
});
278-
279258
describe('collectVisibleFlat', () => {
280259
it('walks only expanded directories', () => {
281260
const tree = buildFileManagerTree([file('a/b.txt'), file('c.txt')]);
@@ -411,7 +390,6 @@ describe('walkTree', () => {
411390
});
412391

413392
it('powers all collect* helpers consistently', () => {
414-
expect(collectAllFilePaths(tree).sort()).toEqual(['a/b/c.txt', 'a/d.txt', 'e.txt']);
415393
expect(collectAllNodePaths(tree).sort()).toEqual(['a', 'a/b', 'a/b/c.txt', 'a/d.txt', 'e.txt'].sort());
416394
expect(collectDirectoryPaths(tree).sort()).toEqual(['a', 'a/b']);
417395
});

frontend/src/components/shared/file-manager/file-manager-utils.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,6 @@ export const formatModifiedRelative = (modifiedAt?: Date | string): string => {
5555
}
5656

5757
const now = Date.now();
58-
// All `differenceIn*` helpers are computed up-front so each branch's
59-
// fallback (`!minutes`, `!hours`, …) can guard against the upper unit
60-
// rounding down to zero — without it, an edge case like 59 minutes 30s
61-
// could fall through to the next branch and render as `0h ago`.
6258
const seconds = differenceInSeconds(now, date);
6359
const minutes = differenceInMinutes(now, date);
6460
const hours = differenceInHours(now, date);
@@ -191,7 +187,6 @@ export const buildFileManagerTree = (
191187
const groups = normalizeRootGroups(rootGroups);
192188
const roots: FileManagerInternalNode[] = groups?.length ? groups.map(buildSyntheticGroupRoot) : [];
193189

194-
// O(1) folder lookup by absolute path; replaces O(n) `siblings.find(...)`.
195190
const folderByPath = new Map<string, FileManagerInternalNode>();
196191

197192
for (const file of sorted) {
@@ -301,10 +296,6 @@ export const walkTree = (
301296
return result;
302297
};
303298

304-
/** Collect paths of *file* nodes only (no synthetic group roots, no directories). */
305-
export const collectAllFilePaths = (nodes: FileManagerInternalNode[]): string[] =>
306-
walkTree(nodes, { include: (node) => !node.isGroupRoot && !node.isDir });
307-
308299
/**
309300
* Collect paths of every selectable node (files + real directories), excluding
310301
* synthetic group roots. Used as the universe for "select all" / bulk operations.
@@ -455,9 +446,6 @@ export const sortFileManagerTree = (
455446
}
456447

457448
const reordered = [...recursed].sort((a, b) => {
458-
// `isFoldersFirst` is treated as a primary partition before any
459-
// other criterion so directories always stay above files
460-
// regardless of the active comparator (or its absence).
461449
if (isFoldersFirst) {
462450
const aIsDir = a.isDir || a.isGroupRoot;
463451
const bIsDir = b.isDir || b.isGroupRoot;
@@ -781,7 +769,6 @@ interface ComputeRowClickSelectionArgs {
781769
/** Visible nodes in DFS order; range modifier resolves anchor/target indices against this list. */
782770
flatVisible: readonly string[];
783771
modifier: 'range' | 'single' | 'toggle';
784-
/** Path of the row that was clicked. */
785772
path: string;
786773
/** Current selection. Pure reducer — never mutated. */
787774
prev: ReadonlySet<string>;
@@ -938,7 +925,6 @@ export const computeToggleSelection = ({ path, prev, subtreePaths }: ComputeTogg
938925
};
939926

940927
interface ComputeToggleSelectAllArgs {
941-
/** Universe of every selectable path in the current visible tree. */
942928
allSelectablePaths: readonly string[];
943929
prev: ReadonlySet<string>;
944930
}

0 commit comments

Comments
 (0)