Skip to content

Commit 3da5624

Browse files
committed
feat: integrate user resources into flows and polish file manager
- Replace resources feature mocks with real GraphQL + REST integration (Apollo subscriptions-backed cache, dedicated hooks for search, upload, copy, move, mkdir, delete, plus conflict / mkdir / copy / move dialogs and a shared FileDropZone UI component). - Let flows attach user resources on creation and in chat messages: FlowForm exposes resourceIds as a form field with a multi-select dropdown (file/folder icons), wired through createFlow, createAssistant, putUserInput and callAssistant mutations. - Add attach-resources and save-as-resource (promote) dialogs to the flow files page; unify drag-and-drop via a shared hooks/use-files-drag-and-drop. - Harden file manager: align skeleton layout (grid + column config) with real rows, extract use-file-manager-dnd, add group selection state and tree-node accessibility fixes. - Normalize numeric id/userId from REST /resources/ responses to strings so GraphQL ID-typed consumers (zod-validated resource picker) work. Marked with TODO(backend) for removal once the REST endpoint matches the GraphQL ID scalar. - Ignore *.tsbuildinfo artifacts. Made-with: Cursor
1 parent 0c412ce commit 3da5624

47 files changed

Lines changed: 3342 additions & 1276 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dist
1313
dist-ssr
1414
ssl
1515
*.local
16+
*.tsbuildinfo
1617

1718
# Editor directories and files
1819
.DS_Store

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,4 @@ export const deleteAction = (onDelete: (file: FileNode) => void): FileManagerAct
4646
label: 'Delete',
4747
onSelect: onDelete,
4848
separatorBefore: true,
49-
variant: 'destructive',
5049
});

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

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { cn } from '@/lib/utils';
2121

2222
import type { FileManagerAction, FileManagerInternalNode } from './file-manager-types';
23+
import type { FileManagerNodeDndHandlers } from './use-file-manager-dnd';
2324

2425
import { FileManagerHighlightedName } from './file-manager-highlighted-name';
2526
import { getFileTypeIcon } from './file-manager-icons';
@@ -36,6 +37,8 @@ const skipRowClickProps = { [SKIP_ROW_CLICK_ATTR]: '' };
3637
interface FileManagerRowProps {
3738
actions: readonly FileManagerAction[];
3839
activeRowPath: null | string;
40+
/** Drag/drop handlers for this row. `null` when intra-tree DnD is disabled. */
41+
dnd: FileManagerNodeDndHandlers | null;
3942
file: FileManagerInternalNode;
4043
formatModified?: (modifiedAt: Date | string | undefined) => string;
4144
gridTemplate: string;
@@ -68,6 +71,7 @@ const buildVisibleActions = (
6871
const FileManagerRowImpl = ({
6972
actions,
7073
activeRowPath,
74+
dnd,
7175
file,
7276
formatModified = defaultFormatModified,
7377
gridTemplate,
@@ -171,6 +175,10 @@ const FileManagerRowImpl = ({
171175
gridTemplateColumns: gridTemplate,
172176
} as CSSProperties & Record<'--fm-depth', number>;
173177

178+
const isDraggable = !!dnd && !file.isGroupRoot;
179+
const isDropTarget = dnd?.isDropTarget ?? false;
180+
const isBeingDragged = dnd?.isBeingDragged ?? false;
181+
174182
const row = (
175183
<div
176184
aria-expanded={file.isDir ? isExpanded : undefined}
@@ -179,12 +187,30 @@ const FileManagerRowImpl = ({
179187
aria-selected={isSelected}
180188
aria-setsize={setSize}
181189
className={cn(
182-
'group hover:bg-muted/50 grid cursor-pointer items-center gap-3 px-3 py-1.5 transition-colors outline-none',
190+
'group hover:bg-accent grid cursor-pointer items-center gap-3 px-3 py-1.5 transition-colors outline-none',
183191
'focus-visible:bg-muted/70 focus-visible:ring-ring focus-visible:ring-1',
184192
isSelected && 'bg-muted',
193+
isDropTarget && 'bg-primary/10 ring-primary/40 ring-1 ring-inset',
194+
// Ghost every row that's part of the in-flight drag (the grabbed row
195+
// plus any other selected rows being moved together) so the user sees
196+
// the entire batch on the move, not just the row whose drag image the
197+
// browser is rendering. Combine reduced opacity + dashed outline so
198+
// the effect is unmistakable even when the row is already selected
199+
// (`bg-muted` would otherwise mute the opacity contrast).
200+
isBeingDragged && 'border-muted-foreground/40 border border-dashed opacity-40',
201+
// Counter-act the 1px border above to keep the row from shifting layout
202+
// when the dashed border kicks in.
203+
!isBeingDragged && 'border border-transparent',
185204
)}
186205
data-path={file.path}
206+
draggable={isDraggable}
187207
onClick={handleRowClick}
208+
onDragEnd={dnd?.onDragEnd}
209+
onDragEnter={dnd?.onDragEnter}
210+
onDragLeave={dnd?.onDragLeave}
211+
onDragOver={dnd?.onDragOver}
212+
onDragStart={dnd?.onDragStart}
213+
onDrop={dnd?.onDrop}
188214
onFocus={() => onFocusRow(file.path)}
189215
role="treeitem"
190216
style={rowStyle}
@@ -208,7 +234,15 @@ const FileManagerRowImpl = ({
208234
/>
209235
)}
210236

211-
<div className="flex min-w-0 items-center gap-1.5 pl-[calc(var(--fm-depth)*16px)]">
237+
<div className="relative flex min-w-0 items-center gap-1.5 self-stretch pl-[calc(var(--fm-depth)*16px)]">
238+
{Array.from({ length: file.depth }, (_, i) => (
239+
<span
240+
aria-hidden="true"
241+
className="bg-border pointer-events-none absolute -inset-y-1.5 w-px"
242+
key={i}
243+
style={{ left: `${i * 16 + 6}px` }}
244+
/>
245+
))}
212246
{file.isDir ? (
213247
<span
214248
aria-hidden="true"
@@ -221,7 +255,7 @@ const FileManagerRowImpl = ({
221255
) : (
222256
<span
223257
aria-hidden="true"
224-
className="size-4 shrink-0"
258+
className="-mx-0.5 size-4 shrink-0"
225259
/>
226260
)}
227261
<Icon className={cn('size-4 shrink-0', tone)} />
Lines changed: 114 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,120 @@
1+
import { type CSSProperties } from 'react';
2+
13
import { Skeleton } from '@/components/ui/skeleton';
24
import { cn } from '@/lib/utils';
35

4-
const ROOT_NAME_WIDTHS = ['w-28', 'w-36', 'w-24', 'w-32'];
5-
const NESTED_NAME_WIDTHS = ['w-44', 'w-36', 'w-28'];
6-
7-
export const FileManagerSkeleton = () => (
8-
<div className="bg-card overflow-hidden rounded-lg border">
9-
<div className="bg-muted/30 flex items-center gap-3 border-b px-3 py-2">
10-
<Skeleton className="size-4 shrink-0 rounded-sm" />
11-
<Skeleton className="h-3 w-12" />
12-
<div className="ml-auto flex items-center gap-3">
13-
<Skeleton className="h-3 w-10" />
14-
<Skeleton className="h-3 w-16" />
15-
<div className="size-7" />
6+
import type { FileManagerColumnsConfig } from './file-manager-types';
7+
8+
import { buildFileManagerGridTemplate } from './file-manager-utils';
9+
10+
interface FileManagerSkeletonRow {
11+
depth: number;
12+
isDir: boolean;
13+
nameWidth: string;
14+
sizeWidth: string;
15+
}
16+
17+
const SKELETON_ROWS: readonly FileManagerSkeletonRow[] = [
18+
{ depth: 0, isDir: true, nameWidth: 'w-28', sizeWidth: 'w-10' },
19+
{ depth: 1, isDir: false, nameWidth: 'w-44', sizeWidth: 'w-12' },
20+
{ depth: 1, isDir: false, nameWidth: 'w-36', sizeWidth: 'w-10' },
21+
{ depth: 1, isDir: false, nameWidth: 'w-28', sizeWidth: 'w-14' },
22+
{ depth: 0, isDir: true, nameWidth: 'w-36', sizeWidth: 'w-10' },
23+
{ depth: 0, isDir: false, nameWidth: 'w-24', sizeWidth: 'w-14' },
24+
{ depth: 0, isDir: false, nameWidth: 'w-32', sizeWidth: 'w-12' },
25+
];
26+
27+
interface FileManagerSkeletonProps {
28+
columns?: FileManagerColumnsConfig;
29+
hasActions?: boolean;
30+
isCheckboxVisible?: boolean;
31+
}
32+
33+
export const FileManagerSkeleton = ({
34+
columns,
35+
hasActions = false,
36+
isCheckboxVisible = false,
37+
}: FileManagerSkeletonProps) => {
38+
const isSizeVisible = columns?.isSizeVisible ?? true;
39+
const isModifiedVisible = columns?.isModifiedVisible ?? true;
40+
const gridTemplate = buildFileManagerGridTemplate(isSizeVisible, isModifiedVisible, hasActions);
41+
42+
return (
43+
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
44+
<div
45+
className="bg-muted/30 grid items-center gap-3 border-b px-3 py-2"
46+
style={{ gridTemplateColumns: gridTemplate }}
47+
>
48+
{isCheckboxVisible ? (
49+
<Skeleton className="size-4 shrink-0 rounded-sm" />
50+
) : (
51+
<span
52+
aria-hidden="true"
53+
className="size-4"
54+
/>
55+
)}
56+
<Skeleton className="h-3 w-12" />
57+
{isSizeVisible && <Skeleton className="h-3 w-10" />}
58+
{isModifiedVisible && <Skeleton className="h-3 w-16" />}
59+
{hasActions && (
60+
<span
61+
aria-hidden="true"
62+
className="size-7"
63+
/>
64+
)}
1665
</div>
17-
</div>
18-
<div className="flex flex-col gap-px py-1">
19-
<div className="flex items-center gap-3 px-3 py-1.5">
20-
<Skeleton className="size-4 shrink-0 rounded-sm" />
21-
<Skeleton className="size-3.5 shrink-0" />
22-
<Skeleton className="size-4 shrink-0" />
23-
<Skeleton className="h-4 w-20" />
66+
67+
<div className="flex flex-col py-1">
68+
{SKELETON_ROWS.map((row, index) => {
69+
const rowStyle = {
70+
'--fm-depth': row.depth,
71+
gridTemplateColumns: gridTemplate,
72+
} as CSSProperties & Record<'--fm-depth', number>;
73+
74+
return (
75+
<div
76+
className="grid items-center gap-3 border border-transparent px-3 py-1.5"
77+
key={index}
78+
style={rowStyle}
79+
>
80+
{isCheckboxVisible ? (
81+
<Skeleton className="size-4 shrink-0 rounded-sm" />
82+
) : (
83+
<span
84+
aria-hidden="true"
85+
className="size-4"
86+
/>
87+
)}
88+
89+
<div className="flex min-w-0 items-center gap-1.5 pl-[calc(var(--fm-depth)*16px)]">
90+
{row.isDir ? (
91+
<Skeleton className="-mx-0.5 size-4 shrink-0 rounded-sm" />
92+
) : (
93+
<span
94+
aria-hidden="true"
95+
className="-mx-0.5 size-4 shrink-0"
96+
/>
97+
)}
98+
<Skeleton className="size-4 shrink-0 rounded-sm" />
99+
<Skeleton className={cn('h-4', row.nameWidth)} />
100+
</div>
101+
102+
{isSizeVisible && (
103+
<Skeleton
104+
className={cn('h-3 shrink-0', row.isDir ? 'w-0 opacity-0' : row.sizeWidth)}
105+
/>
106+
)}
107+
{isModifiedVisible && <Skeleton className="h-3 w-16 shrink-0" />}
108+
{hasActions && (
109+
<span
110+
aria-hidden="true"
111+
className="size-7"
112+
/>
113+
)}
114+
</div>
115+
);
116+
})}
24117
</div>
25-
{NESTED_NAME_WIDTHS.map((width, index) => (
26-
<div
27-
className="flex items-center gap-3 px-3 py-1.5 pl-9"
28-
key={`nested-${index}`}
29-
>
30-
<Skeleton className="size-4 shrink-0 rounded-sm" />
31-
<Skeleton className="size-4 shrink-0" />
32-
<Skeleton className={cn('h-4', width)} />
33-
<Skeleton className="ml-auto h-3 w-10" />
34-
<Skeleton className="h-3 w-16" />
35-
</div>
36-
))}
37-
{ROOT_NAME_WIDTHS.map((width, index) => (
38-
<div
39-
className="flex items-center gap-3 px-3 py-1.5"
40-
key={`root-${index}`}
41-
>
42-
<Skeleton className="size-4 shrink-0 rounded-sm" />
43-
<Skeleton className="size-3.5 shrink-0" />
44-
<Skeleton className="size-4 shrink-0" />
45-
<Skeleton className={cn('h-4', width)} />
46-
<Skeleton className="ml-auto h-3 w-10" />
47-
<Skeleton className="h-3 w-16" />
48-
</div>
49-
))}
50118
</div>
51-
</div>
52-
);
119+
);
120+
};

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { Fragment, type MouseEvent as ReactMouseEvent } from 'react';
22

33
import type { FileManagerAction, FileManagerInternalNode } from './file-manager-types';
4+
import type { FileManagerNodeDndHandlers } from './use-file-manager-dnd';
45

56
import { FileManagerRow } from './file-manager-row';
67

78
interface FileManagerTreeNodeProps {
89
actions: readonly FileManagerAction[];
910
activeRowPath: null | string;
11+
/** Returns drag/drop handlers for a node, or `null` when DnD is disabled. */
12+
bindNodeDnd: (node: FileManagerInternalNode) => FileManagerNodeDndHandlers | null;
1013
expandedPaths: Set<string>;
1114
formatModified?: (modifiedAt: Date | string | undefined) => string;
1215
gridTemplate: string;
@@ -36,6 +39,7 @@ interface FileManagerTreeNodeProps {
3639
export const FileManagerTreeNode = ({
3740
actions,
3841
activeRowPath,
42+
bindNodeDnd,
3943
expandedPaths,
4044
formatModified,
4145
gridTemplate,
@@ -56,12 +60,14 @@ export const FileManagerTreeNode = ({
5660
const isExpanded = expandedPaths.has(node.path);
5761
const isSelected = selectedPaths.has(node.path);
5862
const renderChildren = node.isDir && isExpanded && node.children.length > 0;
63+
const dnd = bindNodeDnd(node);
5964

6065
return (
6166
<Fragment>
6267
<FileManagerRow
6368
actions={actions}
6469
activeRowPath={activeRowPath}
70+
dnd={dnd}
6571
file={node}
6672
formatModified={formatModified}
6773
gridTemplate={gridTemplate}
@@ -84,6 +90,7 @@ export const FileManagerTreeNode = ({
8490
<FileManagerTreeNode
8591
actions={actions}
8692
activeRowPath={activeRowPath}
93+
bindNodeDnd={bindNodeDnd}
8794
expandedPaths={expandedPaths}
8895
formatModified={formatModified}
8996
gridTemplate={gridTemplate}

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ export interface FileManagerProps {
7676
columns?: FileManagerColumnsConfig;
7777
/** Empty state node (rendered when files.length === 0 and not loading). */
7878
emptyState?: ReactNode;
79+
/**
80+
* Forces row checkboxes to be visible / hidden, independently of `onBulkDelete`.
81+
* - `true` → checkboxes shown even without a bulk-delete handler (e.g. picker dialogs).
82+
* - `false` → checkboxes hidden even when `onBulkDelete` is provided.
83+
* - `undefined` (default) → checkboxes shown whenever `onBulkDelete` is set.
84+
*/
85+
enableSelection?: boolean;
7986
files: FileNode[];
8087
isLoading?: boolean;
8188
/** Localizable user-facing strings. */
@@ -85,6 +92,23 @@ export interface FileManagerProps {
8592
* if both a directory and one of its descendants were selected, only the directory is included.
8693
*/
8794
onBulkDelete?: (files: FileNode[]) => Promise<void> | void;
95+
/**
96+
* Enables internal drag-and-drop: rows become draggable and directories accept drops.
97+
* Invoked with the dragged item(s) and the destination directory path (`''` for root).
98+
*
99+
* `FileManager` does **not** mutate its own list — the caller is expected to refresh
100+
* the data (or rely on subscriptions) so the new positions become visible.
101+
*/
102+
onMoveItems?: (sources: FileNode[], destinationDir: string) => Promise<void> | void;
103+
/**
104+
* Fires whenever the multi-selection changes. Use it from selection-only
105+
* flows (e.g. resource pickers) where the parent owns its own confirm button
106+
* and needs to know which items are currently checked.
107+
*
108+
* The supplied callback is read through a ref, so it does not need to be
109+
* memoized — only meaningful selection changes will trigger it.
110+
*/
111+
onSelectionChange?: (selectedPaths: Set<string>) => void;
88112
/** Synthetic top-level groups (e.g. Uploads / Container). When omitted, root is flat. */
89113
rootGroups?: FileManagerRootGroup[];
90114
/** Search query and matching empty state. Provide `query` to enable filtering. */

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,14 +232,14 @@ export const walkTree = (
232232
return result;
233233
};
234234

235-
/**
236-
* Collect paths of *file* nodes only (no synthetic group roots, no directories).
237-
* Used as the universe for "select all".
238-
*/
235+
/** Collect paths of *file* nodes only (no synthetic group roots, no directories). */
239236
export const collectAllFilePaths = (nodes: FileManagerInternalNode[]): string[] =>
240237
walkTree(nodes, { include: (node) => !node.isGroupRoot && !node.isDir });
241238

242-
/** Collect paths of every selectable node (files + real directories), used for bulk-delete validation. */
239+
/**
240+
* Collect paths of every selectable node (files + real directories), excluding
241+
* synthetic group roots. Used as the universe for "select all" / bulk operations.
242+
*/
243243
export const collectAllNodePaths = (nodes: FileManagerInternalNode[]): string[] =>
244244
walkTree(nodes, { include: (node) => !node.isGroupRoot });
245245

0 commit comments

Comments
 (0)