@cosmicdrift/kumiko-bundled-features 0.91.0 → 0.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.91.0",
3
+ "version": "0.92.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -90,11 +90,11 @@
90
90
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
91
91
  },
92
92
  "dependencies": {
93
- "@cosmicdrift/kumiko-dispatcher-live": "0.91.0",
94
- "@cosmicdrift/kumiko-framework": "0.91.0",
95
- "@cosmicdrift/kumiko-headless": "0.91.0",
96
- "@cosmicdrift/kumiko-renderer": "0.91.0",
97
- "@cosmicdrift/kumiko-renderer-web": "0.91.0",
93
+ "@cosmicdrift/kumiko-dispatcher-live": "0.92.0",
94
+ "@cosmicdrift/kumiko-framework": "0.92.0",
95
+ "@cosmicdrift/kumiko-headless": "0.92.0",
96
+ "@cosmicdrift/kumiko-renderer": "0.92.0",
97
+ "@cosmicdrift/kumiko-renderer-web": "0.92.0",
98
98
  "@mollie/api-client": "^4.5.0",
99
99
  "@node-rs/argon2": "^2.0.2",
100
100
  "@types/nodemailer": "^8.0.0",
@@ -0,0 +1,168 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ createStaticLocaleResolver,
4
+ LocaleProvider,
5
+ PrimitivesProvider,
6
+ } from "@cosmicdrift/kumiko-renderer";
7
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
8
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
9
+ import type { ReactNode } from "react";
10
+ import { FoldersHandlers, FoldersQueries } from "../../constants";
11
+ import { type FolderFiling, FolderManager } from "../folder-manager";
12
+ import { defaultTranslations } from "../i18n";
13
+
14
+ type FolderRow = { id: string; name: string; parentId: string | null; version: number };
15
+
16
+ let folderRows: readonly FolderRow[] = [];
17
+
18
+ beforeEach(() => {
19
+ folderRows = [];
20
+ dispatchSpy.mockClear();
21
+ });
22
+
23
+ const dispatchSpy = mock(async () => ({ isSuccess: true, data: undefined }));
24
+
25
+ const useQuerySpy = mock((type: string) => ({
26
+ data: type === FoldersQueries.folderList ? { rows: folderRows } : { rows: [] },
27
+ loading: false,
28
+ error: null,
29
+ refetch: mock(async () => {}),
30
+ }));
31
+
32
+ const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
33
+ mock.module("@cosmicdrift/kumiko-renderer", () => ({
34
+ ...actual_renderer,
35
+ useDispatcher: mock(() => ({ write: dispatchSpy, query: mock(), batch: mock() })),
36
+ useQuery: useQuerySpy,
37
+ }));
38
+
39
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
40
+ return (
41
+ <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
42
+ <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
43
+ </LocaleProvider>
44
+ );
45
+ }
46
+
47
+ const filingWith = (onReassigned: () => void): FolderFiling => ({
48
+ entityType: "credit",
49
+ leavesByFolder: new Map([["f1", [{ id: "c-1", label: "Credit 1" }]]]),
50
+ unfiled: [{ id: "c-2", label: "Credit 2" }],
51
+ unfiledLabel: "Unfiled",
52
+ onReassigned,
53
+ });
54
+
55
+ const dropLeaf = (targetTestId: string, entityId: string): void => {
56
+ fireEvent.drop(screen.getByTestId(targetTestId), {
57
+ dataTransfer: { getData: () => entityId },
58
+ });
59
+ };
60
+
61
+ describe("FolderManager filing mode", () => {
62
+ test("renders filed leaves under their folder and the unfiled bucket", () => {
63
+ folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
64
+ render(
65
+ <Wrapper>
66
+ <FolderManager filing={filingWith(() => {})} />
67
+ </Wrapper>,
68
+ );
69
+ expect(screen.getByTestId("folder-leaf-c-1")).toBeTruthy();
70
+ expect(screen.getByTestId("folder-node-unfiled")).toBeTruthy();
71
+ expect(screen.getByTestId("folder-leaf-c-2")).toBeTruthy();
72
+ });
73
+
74
+ test("dropping a leaf on another folder dispatches set-folder + refetches the host", async () => {
75
+ folderRows = [
76
+ { id: "f1", name: "A", parentId: null, version: 1 },
77
+ { id: "f2", name: "B", parentId: null, version: 1 },
78
+ ];
79
+ const onReassigned = mock(() => {});
80
+ render(
81
+ <Wrapper>
82
+ <FolderManager filing={filingWith(onReassigned)} />
83
+ </Wrapper>,
84
+ );
85
+ dropLeaf("folder-node-f2", "c-1");
86
+ await waitFor(() =>
87
+ expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.setFolder, {
88
+ folderId: "f2",
89
+ entityType: "credit",
90
+ entityId: "c-1",
91
+ }),
92
+ );
93
+ await waitFor(() => expect(onReassigned).toHaveBeenCalled());
94
+ });
95
+
96
+ test("dropping a leaf on the unfiled bucket dispatches clear-folder", async () => {
97
+ folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
98
+ render(
99
+ <Wrapper>
100
+ <FolderManager filing={filingWith(() => {})} />
101
+ </Wrapper>,
102
+ );
103
+ dropLeaf("folder-node-unfiled", "c-1");
104
+ await waitFor(() =>
105
+ expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.clearFolder, {
106
+ entityType: "credit",
107
+ entityId: "c-1",
108
+ }),
109
+ );
110
+ });
111
+
112
+ test("dropping a leaf on the folder it already lives in is a no-op (no write)", async () => {
113
+ folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
114
+ render(
115
+ <Wrapper>
116
+ <FolderManager filing={filingWith(() => {})} />
117
+ </Wrapper>,
118
+ );
119
+ dropLeaf("folder-node-f1", "c-1");
120
+ // give any (erroneous) async write a tick to land
121
+ await new Promise((r) => setTimeout(r, 0));
122
+ expect(dispatchSpy).not.toHaveBeenCalled();
123
+ });
124
+
125
+ test("without filing the manager renders no leaves or bucket (backward compatible)", () => {
126
+ folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
127
+ render(
128
+ <Wrapper>
129
+ <FolderManager />
130
+ </Wrapper>,
131
+ );
132
+ expect(screen.getByTestId("folder-node-f1")).toBeTruthy();
133
+ expect(screen.queryByTestId("folder-node-unfiled")).toBeNull();
134
+ expect(screen.queryByTestId("folder-leaf-c-1")).toBeNull();
135
+ });
136
+
137
+ test("delete is confirm-gated; confirming dispatches delete-folder", async () => {
138
+ folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
139
+ render(
140
+ <Wrapper>
141
+ <FolderManager />
142
+ </Wrapper>,
143
+ );
144
+ fireEvent.click(screen.getByTestId("folder-delete-f1"));
145
+ expect(dispatchSpy).not.toHaveBeenCalled(); // no write before confirm
146
+ fireEvent.click(await screen.findByTestId("folder-manager-delete-dialog-confirm"));
147
+ await waitFor(() =>
148
+ expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.deleteFolder, { id: "f1" }),
149
+ );
150
+ });
151
+
152
+ test("the in-tree new-folder row opens a draft; submitting (Enter) creates a root folder", async () => {
153
+ folderRows = [];
154
+ render(
155
+ <Wrapper>
156
+ <FolderManager />
157
+ </Wrapper>,
158
+ );
159
+ fireEvent.click(screen.getByTestId("folder-manager-new-root"));
160
+ fireEvent.change(document.getElementById("folder-manager-draft") as HTMLInputElement, {
161
+ target: { value: "Inbox" },
162
+ });
163
+ fireEvent.submit(screen.getByTestId("folder-manager-draft"));
164
+ await waitFor(() =>
165
+ expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.createFolder, { name: "Inbox" }),
166
+ );
167
+ });
168
+ });
@@ -5,21 +5,27 @@
5
5
  // rolled-up Restschuld/Rate) next to each node — the manager knows nothing about
6
6
  // what's filed in a folder, only the folder tree itself.
7
7
  //
8
+ // Optional `filing` mode: when a host hands in its entities (grouped by folder +
9
+ // an unfiled bucket) the manager interleaves them as draggable leaf rows and
10
+ // becomes a full filing tree — drag a leaf onto a folder to file it (set-folder),
11
+ // onto the unfiled bucket to unfile it (clear-folder). The host owns the entity
12
+ // data + stats; the manager owns the folder writes AND the reassignment writes,
13
+ // then refetches its own catalog and calls the host's onReassigned to refresh the
14
+ // host's assignment-derived data. ponytail: native HTML5 DnD, desktop-only;
15
+ // pointer-based (dnd-kit) only if touch filing is ever needed — keyboard filing
16
+ // stays available via <FolderSection>.
17
+ //
8
18
  // Finder/Explorer-style: the tree is flattened to its visible rows in DFS order
9
19
  // and rendered as full-width rows with (a) alternating zebra striping, (b) one
10
20
  // vertical guide line per ancestor depth, and (c) a disclosure chevron + folder
11
- // icon. Icons are lucide-react (the same set the nav's NAV_ICONS uses), so they
12
- // match the rest of the app. Actions are compact always-visible icon buttons (NOT
13
- // hover-only static screenshots can't hover). Flattening (vs nested divs) is
14
- // what lets the zebra + guide lines run edge-to-edge and stay aligned regardless
15
- // of depth.
21
+ // icon. The chevron+name toggle a node; expansion state persists in localStorage
22
+ // so a reload doesn't re-expand everything. Create/rename happen inline (Enter
23
+ // saves); delete asks for confirmation; root folders are added via the in-tree
24
+ // "+ new folder" row (no floating toolbar button). Icons are lucide-react.
16
25
  //
17
26
  // NOTE on Tailwind: a host's Tailwind v4 build must `@source`-scan this package's
18
27
  // src (node_modules is ignored by default) or these classes never compile. money-
19
28
  // horse already does; that scan is a real shipping requirement for any host.
20
- //
21
- // Folder writes are immediate; the manager owns its catalog query and refetches
22
- // after each action.
23
29
 
24
30
  import {
25
31
  useDispatcher,
@@ -27,21 +33,51 @@ import {
27
33
  useQuery,
28
34
  useTranslation,
29
35
  } from "@cosmicdrift/kumiko-renderer";
30
- import {
31
- ChevronDown,
32
- ChevronRight,
33
- Folder,
34
- type LucideIcon,
35
- Pencil,
36
- Plus,
37
- Trash2,
38
- } from "lucide-react";
39
- import { type ReactNode, useState } from "react";
36
+ import { ChevronRight, File, Folder, type LucideIcon, Pencil, Plus, Trash2 } from "lucide-react";
37
+ import { type DragEvent, type ReactNode, useEffect, useState } from "react";
40
38
  import { FoldersHandlers, FoldersQueries } from "../constants";
41
39
  import { buildFolderTree, type FolderNode, type FolderRow } from "./tree";
42
40
 
43
41
  type FolderListResponse = { readonly rows: readonly FolderRow[] };
44
42
 
43
+ // One filed entity, rendered as a draggable leaf under its folder. The host maps
44
+ // its rows into these (label + an optional trailing slot for a KPI/amount).
45
+ export type FolderLeaf = {
46
+ readonly id: string;
47
+ readonly label: string;
48
+ readonly trailing?: ReactNode;
49
+ readonly onOpen?: () => void;
50
+ };
51
+
52
+ // Opt-in filing binding. Host computes the grouping (it already holds the
53
+ // assignments for its stats); the manager renders + drives the set/clear-folder
54
+ // writes and tells the host to refetch via onReassigned.
55
+ export type FolderFiling = {
56
+ readonly entityType: string;
57
+ readonly leavesByFolder: ReadonlyMap<string, readonly FolderLeaf[]>;
58
+ readonly unfiled: readonly FolderLeaf[];
59
+ readonly unfiledLabel: string;
60
+ readonly unfiledMeta?: ReactNode;
61
+ readonly leafIcon?: LucideIcon;
62
+ readonly onReassigned: () => Promise<void> | void;
63
+ };
64
+
65
+ const UNFILED = "__unfiled__";
66
+ const DRAG_MIME = "text/plain";
67
+ // ponytail: one global key — folder expansion is low-stakes UI state shared
68
+ // across every folder tree in the app; a per-host key only if trees ever diverge.
69
+ const COLLAPSED_KEY = "kumiko:folders:collapsed";
70
+
71
+ const loadCollapsed = (): ReadonlySet<string> => {
72
+ if (typeof window === "undefined") return new Set();
73
+ try {
74
+ const raw = window.localStorage.getItem(COLLAPSED_KEY);
75
+ return new Set(raw ? (JSON.parse(raw) as string[]) : []);
76
+ } catch {
77
+ return new Set();
78
+ }
79
+ };
80
+
45
81
  type Pending =
46
82
  | { readonly mode: "create"; readonly parentId: string | null }
47
83
  | { readonly mode: "rename"; readonly id: string; readonly version: number }
@@ -49,20 +85,35 @@ type Pending =
49
85
 
50
86
  export function FolderManager({
51
87
  renderMeta,
88
+ filing,
52
89
  }: {
53
90
  // Optional per-folder slot (right side of each row). The host owns the data;
54
91
  // the manager just gives it a place to render.
55
92
  readonly renderMeta?: (folder: FolderRow) => ReactNode;
93
+ // Optional filing mode: interleave the host's draggable entities + DnD.
94
+ readonly filing?: FolderFiling;
56
95
  }): ReactNode {
57
- const { Banner, Button, Input, Text } = usePrimitives();
96
+ const { Banner, Button, Dialog, Input, Text } = usePrimitives();
58
97
  const t = useTranslation();
59
98
  const dispatcher = useDispatcher();
60
99
  const catalog = useQuery<FolderListResponse>(FoldersQueries.folderList, {});
61
100
  const [pending, setPending] = useState<Pending>(null);
62
101
  const [draftName, setDraftName] = useState("");
63
- const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
102
+ const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(loadCollapsed);
64
103
  const [busy, setBusy] = useState(false);
65
104
  const [errorKey, setErrorKey] = useState<string | null>(null);
105
+ const [dragOverKey, setDragOverKey] = useState<string | null>(null);
106
+ const [pendingDelete, setPendingDelete] = useState<FolderNode | null>(null);
107
+
108
+ // Persist expand/collapse so a reload (F5) doesn't re-expand everything.
109
+ useEffect(() => {
110
+ if (typeof window === "undefined") return;
111
+ try {
112
+ window.localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...collapsed]));
113
+ } catch {
114
+ // storage unavailable (private mode / quota) — expansion just won't persist
115
+ }
116
+ }, [collapsed]);
66
117
 
67
118
  if (catalog.loading && catalog.data === null) {
68
119
  return (
@@ -82,6 +133,14 @@ export function FolderManager({
82
133
  const rows = catalog.data?.rows ?? [];
83
134
  const tree = buildFolderTree(rows);
84
135
 
136
+ // entityId → current folder (null = unfiled) for the drop no-op guard.
137
+ const currentFolderByEntity = new Map<string, string | null>();
138
+ if (filing !== undefined) {
139
+ for (const [folderId, leaves] of filing.leavesByFolder)
140
+ for (const leaf of leaves) currentFolderByEntity.set(leaf.id, folderId);
141
+ for (const leaf of filing.unfiled) currentFolderByEntity.set(leaf.id, null);
142
+ }
143
+
85
144
  const toggleCollapse = (id: string): void =>
86
145
  setCollapsed((prev) => {
87
146
  const next = new Set(prev);
@@ -144,16 +203,68 @@ export function FolderManager({
144
203
  );
145
204
  };
146
205
 
147
- const deleteFolder = (node: FolderNode): void => {
206
+ // Delete goes through a confirm dialog — a folder can hold filed entries that
207
+ // fall back to "unfiled" when it's gone. Child folders still block outright.
208
+ const requestDelete = (node: FolderNode): void => {
148
209
  if (node.children.length > 0) {
149
210
  setErrorKey("folders.manager.deleteBlocked");
150
211
  return;
151
212
  }
152
- void apply(() => writeOk(FoldersHandlers.deleteFolder, { id: node.id }));
213
+ setErrorKey(null);
214
+ setPendingDelete(node);
215
+ };
216
+
217
+ const confirmDelete = async (): Promise<void> => {
218
+ const node = pendingDelete;
219
+ setPendingDelete(null);
220
+ if (node === null) return;
221
+ await apply(() => writeOk(FoldersHandlers.deleteFolder, { id: node.id }));
222
+ };
223
+
224
+ // Drop a leaf into a folder (set-folder) or the unfiled bucket (clear-folder).
225
+ // No-op if it already lives there (both handlers are idempotent, but a re-set
226
+ // would burn a redundant event).
227
+ const reassign = async (entityId: string, folderId: string | null): Promise<void> => {
228
+ if (filing === undefined) return;
229
+ if ((currentFolderByEntity.get(entityId) ?? null) === folderId) return;
230
+ setErrorKey(null);
231
+ const ok =
232
+ folderId === null
233
+ ? await writeOk(FoldersHandlers.clearFolder, { entityType: filing.entityType, entityId })
234
+ : await writeOk(FoldersHandlers.setFolder, {
235
+ folderId,
236
+ entityType: filing.entityType,
237
+ entityId,
238
+ });
239
+ if (ok) {
240
+ await catalog.refetch();
241
+ await filing.onReassigned();
242
+ }
153
243
  };
154
244
 
155
- // One vertical guide line per ancestor depth, centered in a chevron-width column;
156
- // self-stretch makes consecutive rows' lines join into continuous rails.
245
+ // Drop-target handlers, attached only in filing mode (so plain folder
246
+ // management keeps non-interactive rows).
247
+ const dropProps = (key: string, folderId: string | null) =>
248
+ filing === undefined
249
+ ? {}
250
+ : {
251
+ onDragOver: (e: DragEvent<HTMLDivElement>) => {
252
+ e.preventDefault();
253
+ e.dataTransfer.dropEffect = "move";
254
+ setDragOverKey(key);
255
+ },
256
+ onDragLeave: () => setDragOverKey((cur) => (cur === key ? null : cur)),
257
+ onDrop: (e: DragEvent<HTMLDivElement>) => {
258
+ e.preventDefault();
259
+ setDragOverKey(null);
260
+ const id = e.dataTransfer.getData(DRAG_MIME);
261
+ if (id !== "") void reassign(id, folderId);
262
+ },
263
+ };
264
+
265
+ // One vertical guide line per ancestor depth, centered in a chevron-width
266
+ // column; self-stretch fills the padding-free min-h-9 row edge-to-edge, so
267
+ // consecutive rows' lines join into continuous, gapless rails.
157
268
  const rails = (depth: number): ReactNode =>
158
269
  Array.from({ length: depth }, (_, i) => (
159
270
  // biome-ignore lint/suspicious/noArrayIndexKey: positional rail, no identity
@@ -162,10 +273,10 @@ export function FolderManager({
162
273
  </span>
163
274
  ));
164
275
 
165
- const rowClass = (stripe: boolean): string =>
166
- `flex items-center gap-1.5 px-2 py-1.5 transition-colors hover:bg-muted/60 ${
276
+ const rowClass = (stripe: boolean, dropActive: boolean): string =>
277
+ `flex min-h-9 items-center gap-1.5 px-2 transition-colors hover:bg-muted/60 ${
167
278
  stripe ? "bg-muted/40" : ""
168
- }`;
279
+ } ${dropActive ? "ring-1 ring-inset ring-primary/40" : ""}`;
169
280
 
170
281
  const actionButton = (
171
282
  label: string,
@@ -182,7 +293,7 @@ export function FolderManager({
182
293
  aria-label={label}
183
294
  title={label}
184
295
  data-testid={testId}
185
- className={`rounded p-1 text-muted-foreground transition-colors hover:bg-background disabled:opacity-40 ${
296
+ className={`cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-background disabled:opacity-40 ${
186
297
  danger ? "hover:text-destructive" : "hover:text-foreground"
187
298
  }`}
188
299
  >
@@ -190,8 +301,52 @@ export function FolderManager({
190
301
  </button>
191
302
  );
192
303
 
304
+ // Chevron + folder icon + name = one click target that toggles (cursor-pointer).
305
+ // A non-expandable folder renders the same layout without the toggle affordance.
306
+ const toggleArea = (
307
+ id: string,
308
+ label: string,
309
+ expanded: boolean,
310
+ expandable: boolean,
311
+ ): ReactNode =>
312
+ expandable ? (
313
+ // kumiko-lint-ignore primitives-discipline: dense Finder-row toggle (chevron + name); the Button primitive would break the single-row layout
314
+ <button
315
+ type="button"
316
+ aria-expanded={expanded}
317
+ onClick={() => toggleCollapse(id)}
318
+ data-testid={`folder-toggle-${id}`}
319
+ className="flex min-w-0 flex-1 cursor-pointer items-center gap-1.5 text-left"
320
+ >
321
+ <ChevronRight
322
+ size={16}
323
+ aria-hidden="true"
324
+ className={`shrink-0 text-muted-foreground transition-transform ${
325
+ expanded ? "rotate-90" : ""
326
+ }`}
327
+ />
328
+ <Folder size={16} aria-hidden="true" className="shrink-0 text-muted-foreground" />
329
+ <span className="flex-1 truncate font-medium">{label}</span>
330
+ </button>
331
+ ) : (
332
+ <div className="flex min-w-0 flex-1 items-center gap-1.5">
333
+ <span className="w-4 shrink-0" />
334
+ <Folder size={16} aria-hidden="true" className="shrink-0 text-muted-foreground" />
335
+ <span className="flex-1 truncate font-medium">{label}</span>
336
+ </div>
337
+ );
338
+
193
339
  const draftRow = (depth: number, key: string, stripe: boolean): ReactNode => (
194
- <div key={key} className={rowClass(stripe)} data-testid="folder-manager-draft">
340
+ // kumiko-lint-ignore primitives-discipline: native form only to capture Enter-to-submit on the inline draft field
341
+ <form
342
+ key={key}
343
+ className={rowClass(stripe, false)}
344
+ data-testid="folder-manager-draft"
345
+ onSubmit={(e) => {
346
+ e.preventDefault();
347
+ saveDraft();
348
+ }}
349
+ >
195
350
  {rails(depth)}
196
351
  <Folder size={16} aria-hidden="true" className="shrink-0 text-muted-foreground/50" />
197
352
  <div className="flex-1">
@@ -215,35 +370,47 @@ export function FolderManager({
215
370
  <Button variant="secondary" disabled={busy} onClick={() => setPending(null)}>
216
371
  {t("folders.manager.cancel")}
217
372
  </Button>
218
- </div>
373
+ </form>
219
374
  );
220
375
 
376
+ const leafRow = (leaf: FolderLeaf, depth: number, stripe: boolean): ReactNode => {
377
+ const LeafIcon = filing?.leafIcon ?? File;
378
+ return (
379
+ // kumiko-lint-ignore primitives-discipline: draggable Finder-style leaf row; the Button primitive can't be a dense, full-row drag source
380
+ <button
381
+ key={`leaf-${leaf.id}`}
382
+ type="button"
383
+ draggable
384
+ onDragStart={(e) => {
385
+ e.dataTransfer.setData(DRAG_MIME, leaf.id);
386
+ e.dataTransfer.effectAllowed = "move";
387
+ }}
388
+ onClick={leaf.onOpen}
389
+ className={`${rowClass(stripe, false)} w-full cursor-pointer text-left`}
390
+ data-testid={`folder-leaf-${leaf.id}`}
391
+ >
392
+ {rails(depth)}
393
+ <span className="w-5 shrink-0" />
394
+ <LeafIcon size={16} aria-hidden="true" className="shrink-0 text-muted-foreground/70" />
395
+ <span className="flex-1 truncate">{leaf.label}</span>
396
+ {leaf.trailing}
397
+ </button>
398
+ );
399
+ };
400
+
221
401
  const folderRow = (node: FolderNode, depth: number, stripe: boolean): ReactNode => {
222
- const hasChildren = node.children.length > 0;
223
- const expanded = hasChildren && !collapsed.has(node.id);
402
+ const leaves = filing?.leavesByFolder.get(node.id) ?? [];
403
+ const expandable = node.children.length > 0 || leaves.length > 0;
404
+ const expanded = expandable && !collapsed.has(node.id);
224
405
  return (
225
- <div key={node.id} data-testid={`folder-node-${node.id}`} className={rowClass(stripe)}>
406
+ <div
407
+ key={node.id}
408
+ data-testid={`folder-node-${node.id}`}
409
+ className={rowClass(stripe, dragOverKey === node.id)}
410
+ {...dropProps(node.id, node.id)}
411
+ >
226
412
  {rails(depth)}
227
- {hasChildren ? (
228
- // kumiko-lint-ignore primitives-discipline: compact disclosure chevron, not a form button
229
- <button
230
- type="button"
231
- className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
232
- aria-expanded={expanded}
233
- onClick={() => toggleCollapse(node.id)}
234
- data-testid={`folder-toggle-${node.id}`}
235
- >
236
- {expanded ? (
237
- <ChevronDown size={16} aria-hidden="true" />
238
- ) : (
239
- <ChevronRight size={16} aria-hidden="true" />
240
- )}
241
- </button>
242
- ) : (
243
- <span className="w-5 shrink-0" />
244
- )}
245
- <Folder size={16} aria-hidden="true" className="shrink-0 text-muted-foreground" />
246
- <span className="flex-1 truncate font-medium">{node.name}</span>
413
+ {toggleArea(node.id, node.name, expanded, expandable)}
247
414
  {renderMeta?.(node)}
248
415
  <div className="flex items-center gap-0.5">
249
416
  {actionButton(t("folders.manager.addChild"), `folder-add-child-${node.id}`, Plus, () =>
@@ -256,7 +423,7 @@ export function FolderManager({
256
423
  t("folders.manager.delete"),
257
424
  `folder-delete-${node.id}`,
258
425
  Trash2,
259
- () => deleteFolder(node),
426
+ () => requestDelete(node),
260
427
  true,
261
428
  )}
262
429
  </div>
@@ -264,9 +431,43 @@ export function FolderManager({
264
431
  );
265
432
  };
266
433
 
434
+ const bucketRow = (stripe: boolean): ReactNode => {
435
+ if (filing === undefined) return null;
436
+ const expanded = !collapsed.has(UNFILED);
437
+ return (
438
+ <div
439
+ key={UNFILED}
440
+ data-testid="folder-node-unfiled"
441
+ className={rowClass(stripe, dragOverKey === UNFILED)}
442
+ {...dropProps(UNFILED, null)}
443
+ >
444
+ {toggleArea(UNFILED, filing.unfiledLabel, expanded, true)}
445
+ {filing.unfiledMeta}
446
+ </div>
447
+ );
448
+ };
449
+
450
+ // Subtle in-tree row to add a root folder (replaces the old floating toolbar
451
+ // button, which caused an ugly header jump in hosts with their own actions).
452
+ const newRootRow = (): ReactNode => (
453
+ // kumiko-lint-ignore primitives-discipline: dense Finder-row action (add root folder); the Button primitive would break the row layout
454
+ <button
455
+ type="button"
456
+ disabled={busy}
457
+ onClick={() => openCreate(null)}
458
+ data-testid="folder-manager-new-root"
459
+ className="flex min-h-9 w-full cursor-pointer items-center gap-1.5 px-2 text-left text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground disabled:opacity-40"
460
+ >
461
+ <span className="w-5 shrink-0" />
462
+ <Plus size={16} aria-hidden="true" className="shrink-0" />
463
+ <span className="font-medium">{t("folders.manager.newRoot")}</span>
464
+ </button>
465
+ );
466
+
267
467
  // Flatten the visible tree (DFS) into ordered rows so the zebra index + guide
268
468
  // rails stay correct across depth. A pending rename swaps a node's row in place;
269
- // a pending create appends a draft row at the end of its parent's children.
469
+ // a pending create appends a draft row at the end of its parent's children. In
470
+ // filing mode a folder's leaf rows render before its subfolders.
270
471
  const out: ReactNode[] = [];
271
472
  let idx = 0;
272
473
  const stripeNext = (): boolean => {
@@ -282,42 +483,54 @@ export function FolderManager({
282
483
  ? draftRow(depth, `rename-${node.id}`, stripeNext())
283
484
  : folderRow(node, depth, stripeNext()),
284
485
  );
285
- if (node.children.length > 0 && !collapsed.has(node.id)) walk(node.children, depth + 1);
486
+ const leaves = filing?.leavesByFolder.get(node.id) ?? [];
487
+ const expandable = node.children.length > 0 || leaves.length > 0;
488
+ if (expandable && !collapsed.has(node.id)) {
489
+ for (const leaf of leaves) out.push(leafRow(leaf, depth + 1, stripeNext()));
490
+ if (node.children.length > 0) walk(node.children, depth + 1);
491
+ }
286
492
  if (pending?.mode === "create" && pending.parentId === node.id)
287
493
  out.push(draftRow(depth + 1, `create-under-${node.id}`, stripeNext()));
288
494
  }
289
495
  };
290
496
  walk(tree, 0);
291
497
 
498
+ const hasUnfiled = filing !== undefined && filing.unfiled.length > 0;
499
+ if (hasUnfiled) {
500
+ out.push(bucketRow(stripeNext()));
501
+ if (!collapsed.has(UNFILED))
502
+ for (const leaf of filing.unfiled) out.push(leafRow(leaf, 1, stripeNext()));
503
+ }
504
+
292
505
  const creatingRoot = pending?.mode === "create" && pending.parentId === null;
293
506
  if (creatingRoot) out.unshift(draftRow(0, "create-root", false));
294
507
 
295
508
  return (
296
509
  <div data-testid="folder-manager" className="flex flex-col gap-2">
297
- <div className="flex justify-end">
298
- <Button
299
- variant="primary"
300
- disabled={busy || creatingRoot}
301
- onClick={() => openCreate(null)}
302
- testId="folder-manager-new-root"
303
- >
304
- {t("folders.manager.newRoot")}
305
- </Button>
510
+ <div className="overflow-hidden rounded-md border">
511
+ {out}
512
+ {!creatingRoot && newRootRow()}
306
513
  </div>
307
514
 
308
- {tree.length === 0 && !creatingRoot ? (
309
- <Banner variant="info" testId="folder-manager-empty">
310
- <Text>{t("folders.manager.empty")}</Text>
311
- </Banner>
312
- ) : (
313
- <div className="overflow-hidden rounded-md border">{out}</div>
314
- )}
315
-
316
515
  {errorKey !== null && (
317
516
  <Banner variant="error" testId="folder-manager-action-error">
318
517
  <Text>{t(errorKey)}</Text>
319
518
  </Banner>
320
519
  )}
520
+
521
+ <Dialog
522
+ open={pendingDelete !== null}
523
+ onOpenChange={(o) => {
524
+ if (!o) setPendingDelete(null);
525
+ }}
526
+ title={t("folders.manager.deleteConfirmTitle")}
527
+ description={t("folders.manager.deleteConfirmBody")}
528
+ confirmLabel={t("folders.manager.delete")}
529
+ cancelLabel={t("folders.manager.cancel")}
530
+ variant="danger"
531
+ onConfirm={confirmDelete}
532
+ testId="folder-manager-delete-dialog"
533
+ />
321
534
  </div>
322
535
  );
323
536
  }
@@ -28,6 +28,9 @@ export const defaultTranslations: TranslationsByLocale = {
28
28
  "folders.manager.cancel": "Abbrechen",
29
29
  "folders.manager.working": "Speichert…",
30
30
  "folders.manager.deleteBlocked": "Erst Unterordner entfernen.",
31
+ "folders.manager.deleteConfirmTitle": "Ordner löschen?",
32
+ "folders.manager.deleteConfirmBody":
33
+ "Der Ordner wird entfernt. Abgelegte Einträge wandern zurück zu „Ohne Ordner“.",
31
34
  },
32
35
  en: {
33
36
  "folders.section.createMode": "Save the entity first to pick a folder.",
@@ -51,5 +54,8 @@ export const defaultTranslations: TranslationsByLocale = {
51
54
  "folders.manager.cancel": "Cancel",
52
55
  "folders.manager.working": "Saving…",
53
56
  "folders.manager.deleteBlocked": "Remove subfolders first.",
57
+ "folders.manager.deleteConfirmTitle": "Delete folder?",
58
+ "folders.manager.deleteConfirmBody":
59
+ "The folder will be removed. Filed entries move back to Unfiled.",
54
60
  },
55
61
  };
@@ -1,6 +1,6 @@
1
1
  // @runtime client
2
2
  export { FOLDER_SECTION_EXTENSION_NAME, FoldersHandlers, FoldersQueries } from "../constants";
3
3
  export { foldersClient } from "./client-plugin";
4
- export { FolderManager } from "./folder-manager";
4
+ export { type FolderFiling, type FolderLeaf, FolderManager } from "./folder-manager";
5
5
  export { FolderSection } from "./folder-section";
6
6
  export { buildFolderTree, type FolderNode, type FolderRow, folderPath } from "./tree";