@gmickel/gno 0.34.1 → 0.35.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.
@@ -18,6 +18,7 @@ import {
18
18
  parseTargetParts,
19
19
  stripWikiMdExt,
20
20
  } from "../../../../core/links";
21
+ import { slugifySectionTitle } from "../../../../core/sections";
21
22
  import {
22
23
  extractMarkdownCodeLanguage,
23
24
  resolveCodeLanguage,
@@ -126,9 +127,34 @@ const Link: FC<ComponentProps<"a"> & { node?: unknown }> = ({
126
127
  );
127
128
  };
128
129
 
130
+ function flattenNodeText(node: ReactNode): string {
131
+ if (typeof node === "string" || typeof node === "number") {
132
+ return String(node);
133
+ }
134
+ if (!node || typeof node === "boolean") {
135
+ return "";
136
+ }
137
+ if (Array.isArray(node)) {
138
+ return node.map((child) => flattenNodeText(child)).join("");
139
+ }
140
+ if (
141
+ typeof node === "object" &&
142
+ "props" in node &&
143
+ node.props &&
144
+ typeof node.props === "object" &&
145
+ "children" in node.props
146
+ ) {
147
+ return flattenNodeText(node.props.children as ReactNode);
148
+ }
149
+ return "";
150
+ }
151
+
129
152
  // Heading styles with proper hierarchy
130
153
  const createHeading =
131
- (level: 1 | 2 | 3 | 4 | 5 | 6): FC<{ children?: ReactNode }> =>
154
+ (
155
+ level: 1 | 2 | 3 | 4 | 5 | 6,
156
+ anchorCounts: Map<string, number>
157
+ ): FC<{ children?: ReactNode }> =>
132
158
  ({ children }) => {
133
159
  const Tag = `h${level}` as const;
134
160
  const sizes = {
@@ -139,8 +165,15 @@ const createHeading =
139
165
  5: "text-base mt-3 mb-1 font-semibold",
140
166
  6: "text-sm mt-3 mb-1 font-semibold text-muted-foreground",
141
167
  };
168
+ const baseAnchor = slugifySectionTitle(flattenNodeText(children));
169
+ const seen = (anchorCounts.get(baseAnchor) ?? 0) + 1;
170
+ anchorCounts.set(baseAnchor, seen);
171
+ const anchor = seen === 1 ? baseAnchor : `${baseAnchor}-${seen}`;
142
172
  return (
143
- <Tag className={cn("font-serif tracking-tight", sizes[level])}>
173
+ <Tag
174
+ className={cn("scroll-mt-24 font-serif tracking-tight", sizes[level])}
175
+ id={anchor}
176
+ >
144
177
  {children}
145
178
  </Tag>
146
179
  );
@@ -343,30 +376,6 @@ const Image: FC<ComponentProps<"img"> & { node?: unknown }> = ({
343
376
  />
344
377
  );
345
378
 
346
- // Component mapping for react-markdown
347
- const components = {
348
- h1: createHeading(1),
349
- h2: createHeading(2),
350
- h3: createHeading(3),
351
- h4: createHeading(4),
352
- h5: createHeading(5),
353
- h6: createHeading(6),
354
- p: Paragraph,
355
- a: Link,
356
- code: InlineCode,
357
- pre: Pre,
358
- blockquote: Blockquote,
359
- ul: UnorderedList,
360
- ol: OrderedList,
361
- table: Table,
362
- thead: TableHead,
363
- tr: TableRow,
364
- td: TableCell,
365
- th: TableHeaderCell,
366
- hr: Hr,
367
- img: Image,
368
- };
369
-
370
379
  /**
371
380
  * Renders markdown content with syntax highlighting and proper styling.
372
381
  * Sanitizes HTML to prevent XSS attacks.
@@ -386,6 +395,29 @@ export const MarkdownPreview = memo(
386
395
  collection,
387
396
  wikiLinks
388
397
  );
398
+ const anchorCounts = new Map<string, number>();
399
+ const components = {
400
+ h1: createHeading(1, anchorCounts),
401
+ h2: createHeading(2, anchorCounts),
402
+ h3: createHeading(3, anchorCounts),
403
+ h4: createHeading(4, anchorCounts),
404
+ h5: createHeading(5, anchorCounts),
405
+ h6: createHeading(6, anchorCounts),
406
+ p: Paragraph,
407
+ a: Link,
408
+ code: InlineCode,
409
+ pre: Pre,
410
+ blockquote: Blockquote,
411
+ ul: UnorderedList,
412
+ ol: OrderedList,
413
+ table: Table,
414
+ thead: TableHead,
415
+ tr: TableRow,
416
+ td: TableCell,
417
+ th: TableHeaderCell,
418
+ hr: Hr,
419
+ img: Image,
420
+ };
389
421
 
390
422
  return (
391
423
  <div
@@ -19,9 +19,16 @@ import {
19
19
  import { CaptureModal } from "../components/CaptureModal";
20
20
  import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
21
21
 
22
+ export interface CaptureModalOpenOptions {
23
+ draftTitle?: string;
24
+ defaultCollection?: string;
25
+ defaultFolderPath?: string;
26
+ presetId?: string;
27
+ }
28
+
22
29
  interface CaptureModalContextValue {
23
30
  /** Open the capture modal */
24
- openCapture: (draftTitle?: string) => void;
31
+ openCapture: (options?: string | CaptureModalOpenOptions) => void;
25
32
  /** Whether the modal is open */
26
33
  isOpen: boolean;
27
34
  }
@@ -42,11 +49,27 @@ export function CaptureModalProvider({
42
49
  }: CaptureModalProviderProps) {
43
50
  const [open, setOpen] = useState(false);
44
51
  const [draftTitle, setDraftTitle] = useState("");
52
+ const [defaultCollection, setDefaultCollection] = useState("");
53
+ const [defaultFolderPath, setDefaultFolderPath] = useState("");
54
+ const [presetId, setPresetId] = useState("");
45
55
 
46
- const openCapture = useCallback((nextDraftTitle?: string) => {
47
- setDraftTitle(nextDraftTitle ?? "");
48
- setOpen(true);
49
- }, []);
56
+ const openCapture = useCallback(
57
+ (options?: string | CaptureModalOpenOptions) => {
58
+ if (typeof options === "string") {
59
+ setDraftTitle(options);
60
+ setDefaultCollection("");
61
+ setDefaultFolderPath("");
62
+ setPresetId("");
63
+ } else {
64
+ setDraftTitle(options?.draftTitle ?? "");
65
+ setDefaultCollection(options?.defaultCollection ?? "");
66
+ setDefaultFolderPath(options?.defaultFolderPath ?? "");
67
+ setPresetId(options?.presetId ?? "");
68
+ }
69
+ setOpen(true);
70
+ },
71
+ []
72
+ );
50
73
 
51
74
  // 'n' global shortcut (single-key, skips when in text input)
52
75
  const shortcuts = useMemo(
@@ -73,10 +96,13 @@ export function CaptureModalProvider({
73
96
  <CaptureModalContext.Provider value={value}>
74
97
  {children}
75
98
  <CaptureModal
99
+ defaultCollection={defaultCollection}
100
+ defaultFolderPath={defaultFolderPath}
76
101
  draftTitle={draftTitle}
77
102
  onOpenChange={setOpen}
78
103
  onSuccess={onSuccess}
79
104
  open={open}
105
+ presetId={presetId}
80
106
  />
81
107
  </CaptureModalContext.Provider>
82
108
  );
@@ -0,0 +1,226 @@
1
+ import { parseBrowseLocation } from "./browse";
2
+ import { emitWorkspaceActionRequest } from "./workspace-events";
3
+
4
+ export type WorkspaceActionId =
5
+ | "new-note"
6
+ | "new-note-in-context"
7
+ | "create-folder-here"
8
+ | "rename-current-note"
9
+ | "move-current-note"
10
+ | "duplicate-current-note"
11
+ | "go-home"
12
+ | "go-search"
13
+ | "go-browse"
14
+ | "go-ask"
15
+ | "go-graph"
16
+ | "go-collections"
17
+ | "go-connectors";
18
+
19
+ export interface WorkspaceAction {
20
+ id: WorkspaceActionId;
21
+ group: "Create" | "Go To";
22
+ label: string;
23
+ description?: string;
24
+ keywords: string[];
25
+ available: boolean;
26
+ }
27
+
28
+ export interface WorkspaceActionContext {
29
+ location: string;
30
+ }
31
+
32
+ export interface WorkspaceActionHandlers {
33
+ navigate: (to: string) => void;
34
+ openCapture: (options?: {
35
+ draftTitle?: string;
36
+ defaultCollection?: string;
37
+ defaultFolderPath?: string;
38
+ presetId?: string;
39
+ }) => void;
40
+ closePalette: () => void;
41
+ }
42
+
43
+ export function getWorkspaceActions(
44
+ context: WorkspaceActionContext
45
+ ): WorkspaceAction[] {
46
+ const selection = parseBrowseLocation(
47
+ context.location.includes("?")
48
+ ? `?${context.location.split("?")[1] ?? ""}`
49
+ : ""
50
+ );
51
+ const hasBrowseContext = Boolean(selection.collection);
52
+ const isDocView = context.location.startsWith("/doc?");
53
+
54
+ return [
55
+ {
56
+ id: "new-note",
57
+ group: "Create",
58
+ label: "New note",
59
+ description: "Open note capture",
60
+ keywords: ["new", "note", "capture", "create"],
61
+ available: true,
62
+ },
63
+ {
64
+ id: "new-note-in-context",
65
+ group: "Create",
66
+ label: "New note in current location",
67
+ description: hasBrowseContext
68
+ ? `Create in ${selection.collection}${
69
+ selection.path ? ` / ${selection.path}` : ""
70
+ }`
71
+ : "Requires a selected collection in Browse",
72
+ keywords: ["new", "note", "folder", "browse", "collection", "create"],
73
+ available: hasBrowseContext,
74
+ },
75
+ {
76
+ id: "create-folder-here",
77
+ group: "Create",
78
+ label: "Create folder here",
79
+ description: hasBrowseContext
80
+ ? "Create a folder in the current Browse location"
81
+ : "Requires a selected collection in Browse",
82
+ keywords: ["folder", "browse", "create", "directory"],
83
+ available: hasBrowseContext,
84
+ },
85
+ {
86
+ id: "rename-current-note",
87
+ group: "Create",
88
+ label: "Rename current note",
89
+ description: "Open rename dialog for the active note",
90
+ keywords: ["rename", "current", "note", "document"],
91
+ available: isDocView,
92
+ },
93
+ {
94
+ id: "move-current-note",
95
+ group: "Create",
96
+ label: "Move current note",
97
+ description: "Open move dialog for the active note",
98
+ keywords: ["move", "current", "note", "document"],
99
+ available: isDocView,
100
+ },
101
+ {
102
+ id: "duplicate-current-note",
103
+ group: "Create",
104
+ label: "Duplicate current note",
105
+ description: "Open duplicate dialog for the active note",
106
+ keywords: ["duplicate", "copy", "current", "note", "document"],
107
+ available: isDocView,
108
+ },
109
+ {
110
+ id: "go-home",
111
+ group: "Go To",
112
+ label: "Home",
113
+ keywords: ["home", "dashboard"],
114
+ available: true,
115
+ },
116
+ {
117
+ id: "go-search",
118
+ group: "Go To",
119
+ label: "Search",
120
+ keywords: ["search", "find", "query"],
121
+ available: true,
122
+ },
123
+ {
124
+ id: "go-browse",
125
+ group: "Go To",
126
+ label: "Browse",
127
+ keywords: ["browse", "tree", "folders"],
128
+ available: true,
129
+ },
130
+ {
131
+ id: "go-ask",
132
+ group: "Go To",
133
+ label: "Ask",
134
+ keywords: ["ask", "answer", "rag"],
135
+ available: true,
136
+ },
137
+ {
138
+ id: "go-graph",
139
+ group: "Go To",
140
+ label: "Graph",
141
+ keywords: ["graph", "links", "relationships"],
142
+ available: true,
143
+ },
144
+ {
145
+ id: "go-collections",
146
+ group: "Go To",
147
+ label: "Collections",
148
+ keywords: ["collections", "sources", "folders"],
149
+ available: true,
150
+ },
151
+ {
152
+ id: "go-connectors",
153
+ group: "Go To",
154
+ label: "Connectors",
155
+ keywords: ["connectors", "mcp", "skills", "agents"],
156
+ available: true,
157
+ },
158
+ ];
159
+ }
160
+
161
+ export function runWorkspaceAction(
162
+ action: WorkspaceAction,
163
+ context: WorkspaceActionContext,
164
+ handlers: WorkspaceActionHandlers,
165
+ query?: string
166
+ ): void {
167
+ const selection = parseBrowseLocation(
168
+ context.location.includes("?")
169
+ ? `?${context.location.split("?")[1] ?? ""}`
170
+ : ""
171
+ );
172
+
173
+ switch (action.id) {
174
+ case "new-note":
175
+ handlers.openCapture({ draftTitle: query?.trim() || undefined });
176
+ handlers.closePalette();
177
+ return;
178
+ case "new-note-in-context":
179
+ handlers.openCapture({
180
+ draftTitle: query?.trim() || undefined,
181
+ defaultCollection: selection.collection || undefined,
182
+ defaultFolderPath: selection.path || undefined,
183
+ });
184
+ handlers.closePalette();
185
+ return;
186
+ case "create-folder-here":
187
+ emitWorkspaceActionRequest("create-folder-here");
188
+ handlers.closePalette();
189
+ return;
190
+ case "rename-current-note":
191
+ emitWorkspaceActionRequest("rename-current-note");
192
+ handlers.closePalette();
193
+ return;
194
+ case "move-current-note":
195
+ emitWorkspaceActionRequest("move-current-note");
196
+ handlers.closePalette();
197
+ return;
198
+ case "duplicate-current-note":
199
+ emitWorkspaceActionRequest("duplicate-current-note");
200
+ handlers.closePalette();
201
+ return;
202
+ case "go-home":
203
+ handlers.navigate("/");
204
+ break;
205
+ case "go-search":
206
+ handlers.navigate("/search");
207
+ break;
208
+ case "go-browse":
209
+ handlers.navigate("/browse");
210
+ break;
211
+ case "go-ask":
212
+ handlers.navigate("/ask");
213
+ break;
214
+ case "go-graph":
215
+ handlers.navigate("/graph");
216
+ break;
217
+ case "go-collections":
218
+ handlers.navigate("/collections");
219
+ break;
220
+ case "go-connectors":
221
+ handlers.navigate("/connectors");
222
+ break;
223
+ }
224
+
225
+ handlers.closePalette();
226
+ }
@@ -0,0 +1,39 @@
1
+ export type WorkspaceActionRequestType =
2
+ | "create-folder-here"
3
+ | "rename-current-note"
4
+ | "move-current-note"
5
+ | "duplicate-current-note";
6
+
7
+ const WORKSPACE_ACTION_EVENT = "gno:workspace-action";
8
+
9
+ export function emitWorkspaceActionRequest(
10
+ type: WorkspaceActionRequestType
11
+ ): void {
12
+ window.dispatchEvent(
13
+ new CustomEvent(WORKSPACE_ACTION_EVENT, {
14
+ detail: { type },
15
+ })
16
+ );
17
+ }
18
+
19
+ export function subscribeWorkspaceActionRequest(
20
+ type: WorkspaceActionRequestType,
21
+ handler: () => void
22
+ ): () => void {
23
+ const listener = (event: Event) => {
24
+ if (!(event instanceof CustomEvent)) {
25
+ return;
26
+ }
27
+ if (
28
+ typeof event.detail === "object" &&
29
+ event.detail &&
30
+ "type" in event.detail &&
31
+ event.detail.type === type
32
+ ) {
33
+ handler();
34
+ }
35
+ };
36
+
37
+ window.addEventListener(WORKSPACE_ACTION_EVENT, listener);
38
+ return () => window.removeEventListener(WORKSPACE_ACTION_EVENT, listener);
39
+ }
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  ArrowLeft,
3
+ FilePlus2,
3
4
  FolderOpen,
4
5
  HomeIcon,
6
+ FolderPlus,
5
7
  RefreshCw,
6
8
  StarIcon,
7
9
  } from "lucide-react";
@@ -26,6 +28,7 @@ import {
26
28
  DialogHeader,
27
29
  DialogTitle,
28
30
  } from "../components/ui/dialog";
31
+ import { Input } from "../components/ui/input";
29
32
  import {
30
33
  Select,
31
34
  SelectContent,
@@ -35,6 +38,7 @@ import {
35
38
  } from "../components/ui/select";
36
39
  import { apiFetch } from "../hooks/use-api";
37
40
  import { useDocEvents } from "../hooks/use-doc-events";
41
+ import { useCaptureModal } from "../hooks/useCaptureModal";
38
42
  import { useWorkspace } from "../hooks/useWorkspace";
39
43
  import {
40
44
  buildBrowseCrumbs,
@@ -51,6 +55,7 @@ import {
51
55
  toggleFavoriteCollection,
52
56
  toggleFavoriteDocument,
53
57
  } from "../lib/navigation-state";
58
+ import { subscribeWorkspaceActionRequest } from "../lib/workspace-events";
54
59
 
55
60
  interface PageProps {
56
61
  navigate: (to: string | number) => void;
@@ -61,10 +66,18 @@ interface SyncResponse {
61
66
  jobId: string;
62
67
  }
63
68
 
69
+ interface CreateFolderResponse {
70
+ success: boolean;
71
+ collection: string;
72
+ folderPath: string;
73
+ path: string;
74
+ }
75
+
64
76
  type SyncTarget = { kind: "all" } | { kind: "collection"; name: string } | null;
65
77
 
66
78
  export default function Browse({ navigate, location }: PageProps) {
67
79
  const { activeTab, updateActiveTabBrowseState } = useWorkspace();
80
+ const { openCapture } = useCaptureModal();
68
81
  const latestDocEvent = useDocEvents();
69
82
  const resolvedLocation =
70
83
  location ?? `${window.location.pathname}${window.location.search}`;
@@ -93,6 +106,12 @@ export default function Browse({ navigate, location }: PageProps) {
93
106
  const [favoriteDocHrefs, setFavoriteDocHrefs] = useState<string[]>([]);
94
107
  const [favoriteCollections, setFavoriteCollections] = useState<string[]>([]);
95
108
  const [mobileTreeOpen, setMobileTreeOpen] = useState(false);
109
+ const [createFolderOpen, setCreateFolderOpen] = useState(false);
110
+ const [createFolderName, setCreateFolderName] = useState("");
111
+ const [createFolderError, setCreateFolderError] = useState<string | null>(
112
+ null
113
+ );
114
+ const [creatingFolder, setCreatingFolder] = useState(false);
96
115
  const limit = 25;
97
116
 
98
117
  const selectedCollection = selection.collection;
@@ -100,9 +119,28 @@ export default function Browse({ navigate, location }: PageProps) {
100
119
  const selectedNodeId = selectedCollection
101
120
  ? createBrowseNodeId(selectedCollection, selectedPath)
102
121
  : null;
103
- const selectedNode = tree
122
+ const resolvedSelectedNode = tree
104
123
  ? findBrowseNode(tree.collections, selectedCollection, selectedPath)
105
124
  : null;
125
+ const selectedNode =
126
+ resolvedSelectedNode ??
127
+ (selectedCollection
128
+ ? {
129
+ id:
130
+ selectedNodeId ??
131
+ createBrowseNodeId(selectedCollection, selectedPath),
132
+ kind: selectedPath ? "folder" : "collection",
133
+ collection: selectedCollection,
134
+ path: selectedPath,
135
+ name: selectedPath.split("/").at(-1) ?? selectedCollection,
136
+ depth: selectedPath
137
+ ? selectedPath.split("/").filter(Boolean).length
138
+ : 0,
139
+ documentCount: docs.length,
140
+ directDocumentCount: docs.length,
141
+ children: [],
142
+ }
143
+ : null);
106
144
 
107
145
  const expandedNodeIds = useMemo(() => {
108
146
  const persisted = activeTab?.browseState?.expandedNodeIds ?? [];
@@ -176,13 +214,14 @@ export default function Browse({ navigate, location }: PageProps) {
176
214
  if (node) {
177
215
  return;
178
216
  }
179
-
217
+ if (selectedPath) {
218
+ return;
219
+ }
180
220
  const collectionRoot = findBrowseNode(tree.collections, selectedCollection);
181
221
  if (collectionRoot) {
182
222
  navigate(buildBrowseLocation(selectedCollection));
183
223
  return;
184
224
  }
185
-
186
225
  navigate("/browse");
187
226
  }, [navigate, selectedCollection, selectedPath, tree]);
188
227
 
@@ -241,6 +280,16 @@ export default function Browse({ navigate, location }: PageProps) {
241
280
  setRefreshToken((current) => current + 1);
242
281
  }, [latestDocEvent?.changedAt]);
243
282
 
283
+ useEffect(() => {
284
+ return subscribeWorkspaceActionRequest("create-folder-here", () => {
285
+ if (!selectedCollection) {
286
+ return;
287
+ }
288
+ setCreateFolderError(null);
289
+ setCreateFolderOpen(true);
290
+ });
291
+ }, [selectedCollection]);
292
+
244
293
  useEffect(() => {
245
294
  const persisted = activeTab?.browseState?.expandedNodeIds ?? [];
246
295
  const ancestors = selectedPath
@@ -328,6 +377,38 @@ export default function Browse({ navigate, location }: PageProps) {
328
377
  }
329
378
  };
330
379
 
380
+ const handleCreateFolder = async () => {
381
+ if (!selectedCollection || !createFolderName.trim()) {
382
+ return;
383
+ }
384
+
385
+ setCreatingFolder(true);
386
+ setCreateFolderError(null);
387
+ const { data, error } = await apiFetch<CreateFolderResponse>(
388
+ "/api/folders",
389
+ {
390
+ method: "POST",
391
+ body: JSON.stringify({
392
+ collection: selectedCollection,
393
+ parentPath: selectedPath || undefined,
394
+ name: createFolderName.trim(),
395
+ }),
396
+ }
397
+ );
398
+ setCreatingFolder(false);
399
+
400
+ if (error) {
401
+ setCreateFolderError(error);
402
+ return;
403
+ }
404
+ if (data?.folderPath) {
405
+ setCreateFolderOpen(false);
406
+ setCreateFolderName("");
407
+ navigate(buildBrowseLocation(selectedCollection, data.folderPath));
408
+ setRefreshToken((current) => current + 1);
409
+ }
410
+ };
411
+
331
412
  const renderSidebar = () => (
332
413
  <BrowseTreeSidebar
333
414
  collections={tree?.collections ?? []}
@@ -412,6 +493,37 @@ export default function Browse({ navigate, location }: PageProps) {
412
493
  <FolderOpen className="size-4" />
413
494
  Collections
414
495
  </Button>
496
+ {selectedCollection && (
497
+ <Button
498
+ className="gap-2"
499
+ onClick={() =>
500
+ openCapture({
501
+ defaultCollection: selectedCollection,
502
+ defaultFolderPath: selectedPath || undefined,
503
+ draftTitle: "",
504
+ })
505
+ }
506
+ size="sm"
507
+ variant="outline"
508
+ >
509
+ <FilePlus2 className="size-4" />
510
+ New Note
511
+ </Button>
512
+ )}
513
+ {selectedCollection && (
514
+ <Button
515
+ className="gap-2"
516
+ onClick={() => {
517
+ setCreateFolderOpen(true);
518
+ setCreateFolderError(null);
519
+ }}
520
+ size="sm"
521
+ variant="outline"
522
+ >
523
+ <FolderPlus className="size-4" />
524
+ New Folder
525
+ </Button>
526
+ )}
415
527
  {selectedCollection && (
416
528
  <Button
417
529
  className="gap-2"
@@ -542,6 +654,45 @@ export default function Browse({ navigate, location }: PageProps) {
542
654
  </div>
543
655
  </DialogContent>
544
656
  </Dialog>
657
+
658
+ <Dialog onOpenChange={setCreateFolderOpen} open={createFolderOpen}>
659
+ <DialogContent className="max-w-md">
660
+ <DialogHeader>
661
+ <DialogTitle>Create folder</DialogTitle>
662
+ </DialogHeader>
663
+ <div className="space-y-3">
664
+ <Input
665
+ autoFocus
666
+ onChange={(event) => setCreateFolderName(event.target.value)}
667
+ placeholder="research"
668
+ value={createFolderName}
669
+ />
670
+ {selectedCollection && (
671
+ <p className="font-mono text-xs text-muted-foreground">
672
+ {selectedCollection}
673
+ {selectedPath ? ` / ${selectedPath}` : ""}
674
+ </p>
675
+ )}
676
+ {createFolderError && (
677
+ <p className="text-destructive text-sm">{createFolderError}</p>
678
+ )}
679
+ <div className="flex justify-end gap-2">
680
+ <Button
681
+ onClick={() => setCreateFolderOpen(false)}
682
+ variant="outline"
683
+ >
684
+ Cancel
685
+ </Button>
686
+ <Button
687
+ disabled={!createFolderName.trim() || creatingFolder}
688
+ onClick={() => void handleCreateFolder()}
689
+ >
690
+ {creatingFolder ? "Creating..." : "Create Folder"}
691
+ </Button>
692
+ </div>
693
+ </div>
694
+ </DialogContent>
695
+ </Dialog>
545
696
  </div>
546
697
  );
547
698
  }