@gmickel/gno 0.34.1 → 0.36.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.
Files changed (37) hide show
  1. package/README.md +37 -26
  2. package/assets/screenshots/webui-collections.jpg +0 -0
  3. package/assets/screenshots/webui-graph.jpg +0 -0
  4. package/package.json +1 -1
  5. package/src/core/file-ops.ts +12 -1
  6. package/src/core/file-refactors.ts +180 -0
  7. package/src/core/note-creation.ts +137 -0
  8. package/src/core/note-presets.ts +183 -0
  9. package/src/core/sections.ts +62 -0
  10. package/src/core/validation.ts +4 -3
  11. package/src/mcp/tools/capture.ts +71 -12
  12. package/src/mcp/tools/index.ts +82 -1
  13. package/src/mcp/tools/workspace-write.ts +321 -0
  14. package/src/sdk/client.ts +341 -0
  15. package/src/sdk/types.ts +67 -0
  16. package/src/serve/CLAUDE.md +3 -1
  17. package/src/serve/browse-tree.ts +60 -12
  18. package/src/serve/public/app.tsx +8 -3
  19. package/src/serve/public/components/BrowseDetailPane.tsx +18 -1
  20. package/src/serve/public/components/CaptureModal.tsx +135 -13
  21. package/src/serve/public/components/QuickSwitcher.tsx +408 -46
  22. package/src/serve/public/components/ShortcutHelpModal.tsx +54 -1
  23. package/src/serve/public/components/editor/MarkdownPreview.tsx +58 -26
  24. package/src/serve/public/components/ui/command.tsx +19 -9
  25. package/src/serve/public/components/ui/dialog.tsx +1 -1
  26. package/src/serve/public/globals.built.css +2 -2
  27. package/src/serve/public/globals.css +47 -0
  28. package/src/serve/public/hooks/useCaptureModal.tsx +31 -5
  29. package/src/serve/public/lib/browse.ts +1 -0
  30. package/src/serve/public/lib/workspace-actions.ts +226 -0
  31. package/src/serve/public/lib/workspace-events.ts +39 -0
  32. package/src/serve/public/pages/Browse.tsx +154 -3
  33. package/src/serve/public/pages/DocView.tsx +472 -10
  34. package/src/serve/public/pages/DocumentEditor.tsx +52 -0
  35. package/src/serve/routes/api.ts +712 -13
  36. package/src/serve/server.ts +74 -0
  37. package/src/store/sqlite/adapter.ts +19 -19
@@ -5,6 +5,7 @@ import {
5
5
  CheckIcon,
6
6
  ChevronRightIcon,
7
7
  CodeIcon,
8
+ CopyIcon,
8
9
  FileText,
9
10
  FolderOpen,
10
11
  HardDrive,
@@ -18,6 +19,7 @@ import {
18
19
  } from "lucide-react";
19
20
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
20
21
 
22
+ import { extractSections } from "../../../core/sections";
21
23
  import {
22
24
  CodeBlock,
23
25
  CodeBlockCopyButton,
@@ -46,6 +48,7 @@ import {
46
48
  DialogHeader,
47
49
  DialogTitle,
48
50
  } from "../components/ui/dialog";
51
+ import { Input } from "../components/ui/input";
49
52
  import { Separator } from "../components/ui/separator";
50
53
  import { apiFetch } from "../hooks/use-api";
51
54
  import { useDocEvents } from "../hooks/use-doc-events";
@@ -55,6 +58,7 @@ import {
55
58
  parseDocumentDeepLink,
56
59
  } from "../lib/deep-links";
57
60
  import { waitForDocumentAvailability } from "../lib/document-availability";
61
+ import { subscribeWorkspaceActionRequest } from "../lib/workspace-events";
58
62
 
59
63
  interface PageProps {
60
64
  navigate: (to: string | number) => void;
@@ -99,6 +103,29 @@ interface RenameDocResponse {
99
103
  uri: string;
100
104
  path: string;
101
105
  relPath: string;
106
+ refactorWarnings?: {
107
+ warnings: string[];
108
+ };
109
+ }
110
+
111
+ interface MoveDocResponse {
112
+ success: boolean;
113
+ uri: string;
114
+ path: string;
115
+ relPath: string;
116
+ refactorWarnings?: {
117
+ warnings: string[];
118
+ };
119
+ }
120
+
121
+ interface DuplicateDocResponse {
122
+ success: boolean;
123
+ uri: string;
124
+ path: string;
125
+ relPath: string;
126
+ refactorWarnings?: {
127
+ warnings: string[];
128
+ };
102
129
  }
103
130
 
104
131
  interface UpdateDocResponse {
@@ -219,6 +246,12 @@ function parseBreadcrumbs(
219
246
  return segments;
220
247
  }
221
248
 
249
+ function getParentPath(relPath: string): string {
250
+ const parts = relPath.split("/").filter(Boolean);
251
+ parts.pop();
252
+ return parts.join("/");
253
+ }
254
+
222
255
  export default function DocView({ navigate }: PageProps) {
223
256
  const [doc, setDoc] = useState<DocData | null>(null);
224
257
  const [error, setError] = useState<string | null>(null);
@@ -230,6 +263,19 @@ export default function DocView({ navigate }: PageProps) {
230
263
  const [renaming, setRenaming] = useState(false);
231
264
  const [renameError, setRenameError] = useState<string | null>(null);
232
265
  const [renameValue, setRenameValue] = useState("");
266
+ const [renameWarnings, setRenameWarnings] = useState<string[]>([]);
267
+ const [moveDialogOpen, setMoveDialogOpen] = useState(false);
268
+ const [moving, setMoving] = useState(false);
269
+ const [moveError, setMoveError] = useState<string | null>(null);
270
+ const [moveFolderPath, setMoveFolderPath] = useState("");
271
+ const [moveName, setMoveName] = useState("");
272
+ const [moveWarnings, setMoveWarnings] = useState<string[]>([]);
273
+ const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
274
+ const [duplicating, setDuplicating] = useState(false);
275
+ const [duplicateError, setDuplicateError] = useState<string | null>(null);
276
+ const [duplicateFolderPath, setDuplicateFolderPath] = useState("");
277
+ const [duplicateName, setDuplicateName] = useState("");
278
+ const [duplicateWarnings, setDuplicateWarnings] = useState<string[]>([]);
233
279
  const [showRawView, setShowRawView] = useState(false);
234
280
  const [creatingCopy, setCreatingCopy] = useState(false);
235
281
  const [copyError, setCopyError] = useState<string | null>(null);
@@ -246,6 +292,9 @@ export default function DocView({ navigate }: PageProps) {
246
292
  const [resolvedWikiLinks, setResolvedWikiLinks] = useState<OutgoingLink[]>(
247
293
  []
248
294
  );
295
+ const [activeSectionAnchor, setActiveSectionAnchor] = useState<string | null>(
296
+ null
297
+ );
249
298
 
250
299
  // Request sequencing - ignore stale responses on rapid navigation
251
300
  const requestIdRef = useRef(0);
@@ -257,6 +306,10 @@ export default function DocView({ navigate }: PageProps) {
257
306
  []
258
307
  );
259
308
  const currentUri = currentTarget.uri;
309
+ const currentHash = useMemo(
310
+ () => window.location.hash.replace(/^#/u, ""),
311
+ []
312
+ );
260
313
  const highlightedLines = useMemo(() => {
261
314
  if (!currentTarget.lineStart) return [];
262
315
  const end = currentTarget.lineEnd ?? currentTarget.lineStart;
@@ -371,7 +424,51 @@ export default function DocView({ navigate }: PageProps) {
371
424
  }
372
425
  }, [currentTarget.lineStart, currentTarget.view]);
373
426
 
427
+ useEffect(() => {
428
+ if (!currentHash || showRawView || loading) {
429
+ return;
430
+ }
431
+
432
+ requestAnimationFrame(() => {
433
+ document
434
+ .getElementById(currentHash)
435
+ ?.scrollIntoView({ behavior: "smooth", block: "start" });
436
+ setActiveSectionAnchor(currentHash);
437
+ });
438
+ }, [currentHash, loading, showRawView]);
439
+
374
440
  const breadcrumbs = doc ? parseBreadcrumbs(doc.collection, doc.relPath) : [];
441
+ const sections = useMemo(
442
+ () => extractSections(parsedContent.body),
443
+ [parsedContent.body]
444
+ );
445
+
446
+ useEffect(() => {
447
+ if (showRawView || sections.length === 0) {
448
+ setActiveSectionAnchor(sections[0]?.anchor ?? null);
449
+ return;
450
+ }
451
+
452
+ const updateActiveSection = () => {
453
+ let current = sections[0]?.anchor ?? null;
454
+ for (const section of sections) {
455
+ const element = document.getElementById(section.anchor);
456
+ if (!element) {
457
+ continue;
458
+ }
459
+ if (element.getBoundingClientRect().top <= 160) {
460
+ current = section.anchor;
461
+ }
462
+ }
463
+ setActiveSectionAnchor(current);
464
+ };
465
+
466
+ updateActiveSection();
467
+ window.addEventListener("scroll", updateActiveSection, { passive: true });
468
+ return () => {
469
+ window.removeEventListener("scroll", updateActiveSection);
470
+ };
471
+ }, [sections, showRawView]);
375
472
 
376
473
  const handleEdit = () => {
377
474
  if (doc?.capabilities.editable) {
@@ -445,9 +542,29 @@ export default function DocView({ navigate }: PageProps) {
445
542
  const filename = doc.relPath.split("/").pop() ?? doc.relPath;
446
543
  setRenameValue(filename);
447
544
  setRenameError(null);
545
+ setRenameWarnings([]);
448
546
  setRenameDialogOpen(true);
449
547
  }, [doc]);
450
548
 
549
+ useEffect(() => {
550
+ if (!renameDialogOpen || !doc || !renameValue.trim()) {
551
+ return;
552
+ }
553
+ void apiFetch<{ refactorWarnings?: { warnings: string[] } }>(
554
+ `/api/docs/${encodeURIComponent(doc.docid)}/refactor-plan`,
555
+ {
556
+ method: "POST",
557
+ body: JSON.stringify({
558
+ operation: "rename",
559
+ name: renameValue,
560
+ uri: doc.uri,
561
+ }),
562
+ }
563
+ ).then(({ data }) => {
564
+ setRenameWarnings(data?.refactorWarnings?.warnings ?? []);
565
+ });
566
+ }, [doc, renameDialogOpen, renameValue]);
567
+
451
568
  const handleRename = useCallback(async () => {
452
569
  if (!doc) {
453
570
  return;
@@ -474,6 +591,159 @@ export default function DocView({ navigate }: PageProps) {
474
591
  }
475
592
  }, [doc, navigate, renameValue]);
476
593
 
594
+ const handleStartMove = useCallback(() => {
595
+ if (!doc) {
596
+ return;
597
+ }
598
+ setMoveFolderPath(getParentPath(doc.relPath));
599
+ setMoveName(doc.relPath.split("/").pop() ?? doc.relPath);
600
+ setMoveError(null);
601
+ setMoveWarnings([]);
602
+ setMoveDialogOpen(true);
603
+ }, [doc]);
604
+
605
+ useEffect(() => {
606
+ if (!moveDialogOpen || !doc || !moveFolderPath.trim()) {
607
+ return;
608
+ }
609
+ void apiFetch<{ refactorWarnings?: { warnings: string[] } }>(
610
+ `/api/docs/${encodeURIComponent(doc.docid)}/refactor-plan`,
611
+ {
612
+ method: "POST",
613
+ body: JSON.stringify({
614
+ operation: "move",
615
+ folderPath: moveFolderPath,
616
+ name: moveName,
617
+ uri: doc.uri,
618
+ }),
619
+ }
620
+ ).then(({ data }) => {
621
+ setMoveWarnings(data?.refactorWarnings?.warnings ?? []);
622
+ });
623
+ }, [doc, moveDialogOpen, moveFolderPath, moveName]);
624
+
625
+ const handleMove = useCallback(async () => {
626
+ if (!doc) {
627
+ return;
628
+ }
629
+ setMoving(true);
630
+ setMoveError(null);
631
+ const { data, error: err } = await apiFetch<MoveDocResponse>(
632
+ `/api/docs/${encodeURIComponent(doc.docid)}/move`,
633
+ {
634
+ method: "POST",
635
+ body: JSON.stringify({
636
+ folderPath: moveFolderPath,
637
+ name: moveName,
638
+ uri: doc.uri,
639
+ }),
640
+ }
641
+ );
642
+ setMoving(false);
643
+
644
+ if (err) {
645
+ setMoveError(err);
646
+ return;
647
+ }
648
+
649
+ setMoveDialogOpen(false);
650
+ if (data?.uri) {
651
+ navigate(`/doc?uri=${encodeURIComponent(data.uri)}`);
652
+ }
653
+ }, [doc, moveFolderPath, moveName, navigate]);
654
+
655
+ const handleStartDuplicate = useCallback(() => {
656
+ if (!doc) {
657
+ return;
658
+ }
659
+ setDuplicateFolderPath(getParentPath(doc.relPath));
660
+ setDuplicateName(doc.relPath.split("/").pop() ?? doc.relPath);
661
+ setDuplicateError(null);
662
+ setDuplicateWarnings([]);
663
+ setDuplicateDialogOpen(true);
664
+ }, [doc]);
665
+
666
+ useEffect(() => {
667
+ if (!duplicateDialogOpen || !doc) {
668
+ return;
669
+ }
670
+ void apiFetch<{ refactorWarnings?: { warnings: string[] } }>(
671
+ `/api/docs/${encodeURIComponent(doc.docid)}/refactor-plan`,
672
+ {
673
+ method: "POST",
674
+ body: JSON.stringify({
675
+ operation: "duplicate",
676
+ folderPath: duplicateFolderPath || undefined,
677
+ name: duplicateName || undefined,
678
+ uri: doc.uri,
679
+ }),
680
+ }
681
+ ).then(({ data }) => {
682
+ setDuplicateWarnings(data?.refactorWarnings?.warnings ?? []);
683
+ });
684
+ }, [doc, duplicateDialogOpen, duplicateFolderPath, duplicateName]);
685
+
686
+ const handleDuplicate = useCallback(async () => {
687
+ if (!doc) {
688
+ return;
689
+ }
690
+ setDuplicating(true);
691
+ setDuplicateError(null);
692
+ const { data, error: err } = await apiFetch<DuplicateDocResponse>(
693
+ `/api/docs/${encodeURIComponent(doc.docid)}/duplicate`,
694
+ {
695
+ method: "POST",
696
+ body: JSON.stringify({
697
+ folderPath: duplicateFolderPath || undefined,
698
+ name: duplicateName || undefined,
699
+ uri: doc.uri,
700
+ }),
701
+ }
702
+ );
703
+ setDuplicating(false);
704
+
705
+ if (err) {
706
+ setDuplicateError(err);
707
+ return;
708
+ }
709
+
710
+ setDuplicateDialogOpen(false);
711
+ if (data?.uri) {
712
+ navigate(`/doc?uri=${encodeURIComponent(data.uri)}`);
713
+ }
714
+ }, [doc, duplicateFolderPath, duplicateName, navigate]);
715
+
716
+ useEffect(() => {
717
+ const unsubscribers = [
718
+ subscribeWorkspaceActionRequest("rename-current-note", () => {
719
+ if (doc?.capabilities.editable) {
720
+ handleStartRename();
721
+ }
722
+ }),
723
+ subscribeWorkspaceActionRequest("move-current-note", () => {
724
+ if (doc?.capabilities.editable) {
725
+ handleStartMove();
726
+ }
727
+ }),
728
+ subscribeWorkspaceActionRequest("duplicate-current-note", () => {
729
+ if (doc?.capabilities.editable) {
730
+ handleStartDuplicate();
731
+ }
732
+ }),
733
+ ];
734
+
735
+ return () => {
736
+ for (const unsubscribe of unsubscribers) {
737
+ unsubscribe();
738
+ }
739
+ };
740
+ }, [
741
+ doc?.capabilities.editable,
742
+ handleStartDuplicate,
743
+ handleStartMove,
744
+ handleStartRename,
745
+ ]);
746
+
477
747
  const handleReveal = useCallback(async () => {
478
748
  if (!doc) {
479
749
  return;
@@ -549,9 +819,8 @@ export default function DocView({ navigate }: PageProps) {
549
819
  setTimeout(() => setTagSaveSuccess(false), 2000);
550
820
  }, [doc, editedTags]);
551
821
 
552
- /** Slim left rail archival catalogue style, no Card chrome */
553
- const renderDocumentFactsRail = () => (
554
- <nav aria-label="Document facts" className="space-y-0">
822
+ const renderPropertiesPathRail = () => (
823
+ <nav aria-label="Document properties" className="space-y-0">
555
824
  {/* Section: Properties */}
556
825
  <div className="px-3 pb-3">
557
826
  <div className="mb-2.5 font-mono text-[10px] text-muted-foreground/50 uppercase tracking-[0.15em]">
@@ -630,11 +899,15 @@ export default function DocView({ navigate }: PageProps) {
630
899
  </button>
631
900
  </div>
632
901
  </div>
902
+ </nav>
903
+ );
633
904
 
634
- {/* Divider + Frontmatter/Tags */}
905
+ /** Left rail — metadata + outline */
906
+ const renderDocumentFactsRail = () => (
907
+ <nav aria-label="Document facts" className="space-y-0">
908
+ {/* Frontmatter + tags */}
635
909
  {(hasFrontmatter || showStandaloneTags) && (
636
910
  <>
637
- <div className="mx-3 border-border/20 border-t" />
638
911
  <div className="px-3 py-3">
639
912
  <div className="mb-2 flex items-center justify-between">
640
913
  <span className="font-mono text-[10px] text-muted-foreground/50 uppercase tracking-[0.15em]">
@@ -724,6 +997,71 @@ export default function DocView({ navigate }: PageProps) {
724
997
  </div>
725
998
  </>
726
999
  )}
1000
+
1001
+ {sections.length > 0 && (
1002
+ <>
1003
+ <div className="mx-3 border-border/20 border-t" />
1004
+ <div className="px-3 py-3">
1005
+ <div className="mb-2 font-mono text-[10px] text-muted-foreground/50 uppercase tracking-[0.15em]">
1006
+ Outline
1007
+ </div>
1008
+ <div className="space-y-0.5">
1009
+ {sections.map((section) => (
1010
+ <div
1011
+ className={`flex items-center gap-1 rounded px-1 py-0.5 ${
1012
+ activeSectionAnchor === section.anchor
1013
+ ? "bg-primary/10 text-primary"
1014
+ : "text-muted-foreground"
1015
+ }`}
1016
+ key={section.anchor}
1017
+ style={{ paddingLeft: `${section.level * 10}px` }}
1018
+ >
1019
+ <button
1020
+ className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-left text-xs transition-colors hover:bg-muted/20 hover:text-foreground"
1021
+ onClick={() => {
1022
+ setShowRawView(false);
1023
+ requestAnimationFrame(() => {
1024
+ document
1025
+ .getElementById(section.anchor)
1026
+ ?.scrollIntoView({
1027
+ behavior: "smooth",
1028
+ block: "start",
1029
+ });
1030
+ window.history.replaceState(
1031
+ {},
1032
+ "",
1033
+ `${buildDocDeepLink({
1034
+ uri: doc?.uri ?? "",
1035
+ view: "rendered",
1036
+ })}#${section.anchor}`
1037
+ );
1038
+ });
1039
+ }}
1040
+ type="button"
1041
+ >
1042
+ <ChevronRightIcon className="size-3 shrink-0" />
1043
+ <span className="truncate">{section.title}</span>
1044
+ </button>
1045
+ <button
1046
+ className="cursor-pointer rounded p-1 transition-colors hover:bg-muted/20 hover:text-foreground"
1047
+ onClick={() => {
1048
+ void navigator.clipboard.writeText(
1049
+ `${window.location.origin}${buildDocDeepLink({
1050
+ uri: doc?.uri ?? "",
1051
+ view: "rendered",
1052
+ })}#${section.anchor}`
1053
+ );
1054
+ }}
1055
+ type="button"
1056
+ >
1057
+ <CopyIcon className="size-3" />
1058
+ </button>
1059
+ </div>
1060
+ ))}
1061
+ </div>
1062
+ </div>
1063
+ </>
1064
+ )}
727
1065
  </nav>
728
1066
  );
729
1067
 
@@ -961,6 +1299,24 @@ export default function DocView({ navigate }: PageProps) {
961
1299
  <TextIcon className="size-4" />
962
1300
  Rename
963
1301
  </Button>
1302
+ <Button
1303
+ className="gap-1.5"
1304
+ onClick={handleStartMove}
1305
+ size="sm"
1306
+ variant="outline"
1307
+ >
1308
+ <FolderOpen className="size-4" />
1309
+ Move
1310
+ </Button>
1311
+ <Button
1312
+ className="gap-1.5"
1313
+ onClick={handleStartDuplicate}
1314
+ size="sm"
1315
+ variant="outline"
1316
+ >
1317
+ <CopyIcon className="size-4" />
1318
+ Duplicate
1319
+ </Button>
964
1320
  </>
965
1321
  ) : (
966
1322
  <>
@@ -1036,10 +1392,12 @@ export default function DocView({ navigate }: PageProps) {
1036
1392
  </header>
1037
1393
 
1038
1394
  <div className="mx-auto flex max-w-[1800px] gap-5 px-6 xl:px-8">
1039
- {/* Left rail — document facts, only on ultra-wide */}
1395
+ {/* Left rail — metadata + outline */}
1040
1396
  {doc && (
1041
1397
  <aside className="hidden w-[200px] shrink-0 border-border/15 border-r pr-2 py-6 lg:block">
1042
- <div className="sticky top-24">{renderDocumentFactsRail()}</div>
1398
+ <div className="sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto pr-1">
1399
+ {renderDocumentFactsRail()}
1400
+ </div>
1043
1401
  </aside>
1044
1402
  )}
1045
1403
 
@@ -1202,10 +1560,11 @@ export default function DocView({ navigate }: PageProps) {
1202
1560
  )}
1203
1561
  </main>
1204
1562
 
1205
- {/* Right rail — relationships */}
1563
+ {/* Right rail — properties/path + relationships */}
1206
1564
  {doc && (
1207
- <aside className="hidden w-[240px] min-w-0 shrink-0 overflow-hidden border-border/15 border-l pl-2 py-6 lg:block">
1208
- <div className="sticky top-24 min-w-0 space-y-1 overflow-hidden">
1565
+ <aside className="hidden w-[250px] min-w-0 shrink-0 overflow-hidden border-border/15 border-l pl-2 pt-2 pb-6 lg:block">
1566
+ <div className="sticky top-18 min-w-0 max-h-[calc(100vh-5.5rem)] space-y-1 overflow-y-auto overflow-x-hidden pr-1">
1567
+ {renderPropertiesPathRail()}
1209
1568
  <BacklinksPanel
1210
1569
  docId={doc.docid}
1211
1570
  onNavigate={(uri) =>
@@ -1322,6 +1681,13 @@ export default function DocView({ navigate }: PageProps) {
1322
1681
  {renameError}
1323
1682
  </div>
1324
1683
  )}
1684
+ {renameWarnings.length > 0 && (
1685
+ <div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-amber-500 text-sm">
1686
+ {renameWarnings.map((warning) => (
1687
+ <div key={warning}>{warning}</div>
1688
+ ))}
1689
+ </div>
1690
+ )}
1325
1691
  <DialogFooter className="gap-2 sm:gap-0">
1326
1692
  <Button
1327
1693
  onClick={() => setRenameDialogOpen(false)}
@@ -1338,6 +1704,102 @@ export default function DocView({ navigate }: PageProps) {
1338
1704
  </DialogFooter>
1339
1705
  </DialogContent>
1340
1706
  </Dialog>
1707
+
1708
+ <Dialog onOpenChange={setMoveDialogOpen} open={moveDialogOpen}>
1709
+ <DialogContent>
1710
+ <DialogHeader>
1711
+ <DialogTitle>Move document</DialogTitle>
1712
+ <DialogDescription>
1713
+ Move the current note to another folder inside this collection.
1714
+ </DialogDescription>
1715
+ </DialogHeader>
1716
+ <div className="space-y-3">
1717
+ <Input
1718
+ onChange={(event) => setMoveFolderPath(event.target.value)}
1719
+ placeholder="projects/research"
1720
+ value={moveFolderPath}
1721
+ />
1722
+ <Input
1723
+ onChange={(event) => setMoveName(event.target.value)}
1724
+ placeholder="note.md"
1725
+ value={moveName}
1726
+ />
1727
+ {moveError && (
1728
+ <div className="rounded-lg bg-destructive/10 p-3 text-destructive text-sm">
1729
+ {moveError}
1730
+ </div>
1731
+ )}
1732
+ {moveWarnings.length > 0 && (
1733
+ <div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-amber-500 text-sm">
1734
+ {moveWarnings.map((warning) => (
1735
+ <div key={warning}>{warning}</div>
1736
+ ))}
1737
+ </div>
1738
+ )}
1739
+ </div>
1740
+ <DialogFooter className="gap-2 sm:gap-0">
1741
+ <Button onClick={() => setMoveDialogOpen(false)} variant="outline">
1742
+ Cancel
1743
+ </Button>
1744
+ <Button disabled={moving} onClick={() => void handleMove()}>
1745
+ {moving && <Loader2Icon className="mr-1.5 size-4 animate-spin" />}
1746
+ Move
1747
+ </Button>
1748
+ </DialogFooter>
1749
+ </DialogContent>
1750
+ </Dialog>
1751
+
1752
+ <Dialog onOpenChange={setDuplicateDialogOpen} open={duplicateDialogOpen}>
1753
+ <DialogContent>
1754
+ <DialogHeader>
1755
+ <DialogTitle>Duplicate document</DialogTitle>
1756
+ <DialogDescription>
1757
+ Create a copy of this note in the current or another folder.
1758
+ </DialogDescription>
1759
+ </DialogHeader>
1760
+ <div className="space-y-3">
1761
+ <Input
1762
+ onChange={(event) => setDuplicateFolderPath(event.target.value)}
1763
+ placeholder="projects/research"
1764
+ value={duplicateFolderPath}
1765
+ />
1766
+ <Input
1767
+ onChange={(event) => setDuplicateName(event.target.value)}
1768
+ placeholder="note-copy.md"
1769
+ value={duplicateName}
1770
+ />
1771
+ {duplicateError && (
1772
+ <div className="rounded-lg bg-destructive/10 p-3 text-destructive text-sm">
1773
+ {duplicateError}
1774
+ </div>
1775
+ )}
1776
+ {duplicateWarnings.length > 0 && (
1777
+ <div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-amber-500 text-sm">
1778
+ {duplicateWarnings.map((warning) => (
1779
+ <div key={warning}>{warning}</div>
1780
+ ))}
1781
+ </div>
1782
+ )}
1783
+ </div>
1784
+ <DialogFooter className="gap-2 sm:gap-0">
1785
+ <Button
1786
+ onClick={() => setDuplicateDialogOpen(false)}
1787
+ variant="outline"
1788
+ >
1789
+ Cancel
1790
+ </Button>
1791
+ <Button
1792
+ disabled={duplicating}
1793
+ onClick={() => void handleDuplicate()}
1794
+ >
1795
+ {duplicating && (
1796
+ <Loader2Icon className="mr-1.5 size-4 animate-spin" />
1797
+ )}
1798
+ Duplicate
1799
+ </Button>
1800
+ </DialogFooter>
1801
+ </DialogContent>
1802
+ </Dialog>
1341
1803
  </div>
1342
1804
  );
1343
1805
  }