@gmickel/gno 0.25.2 → 0.27.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 (36) hide show
  1. package/README.md +5 -3
  2. package/assets/skill/SKILL.md +5 -0
  3. package/assets/skill/cli-reference.md +8 -6
  4. package/package.json +1 -1
  5. package/src/cli/commands/get.ts +21 -0
  6. package/src/cli/commands/skill/install.ts +2 -2
  7. package/src/cli/commands/skill/paths.ts +26 -4
  8. package/src/cli/commands/skill/uninstall.ts +2 -2
  9. package/src/cli/program.ts +18 -12
  10. package/src/core/document-capabilities.ts +113 -0
  11. package/src/mcp/tools/get.ts +10 -0
  12. package/src/mcp/tools/index.ts +434 -110
  13. package/src/sdk/documents.ts +12 -0
  14. package/src/serve/doc-events.ts +69 -0
  15. package/src/serve/public/app.tsx +81 -24
  16. package/src/serve/public/components/CaptureModal.tsx +138 -3
  17. package/src/serve/public/components/QuickSwitcher.tsx +248 -0
  18. package/src/serve/public/components/ShortcutHelpModal.tsx +1 -0
  19. package/src/serve/public/components/ai-elements/code-block.tsx +74 -26
  20. package/src/serve/public/components/editor/CodeMirrorEditor.tsx +51 -0
  21. package/src/serve/public/components/ui/command.tsx +2 -2
  22. package/src/serve/public/hooks/use-doc-events.ts +34 -0
  23. package/src/serve/public/hooks/useCaptureModal.tsx +12 -3
  24. package/src/serve/public/hooks/useKeyboardShortcuts.ts +2 -2
  25. package/src/serve/public/lib/deep-links.ts +68 -0
  26. package/src/serve/public/lib/document-availability.ts +22 -0
  27. package/src/serve/public/lib/local-history.ts +44 -0
  28. package/src/serve/public/lib/wiki-link.ts +36 -0
  29. package/src/serve/public/pages/Browse.tsx +11 -0
  30. package/src/serve/public/pages/Dashboard.tsx +2 -2
  31. package/src/serve/public/pages/DocView.tsx +241 -18
  32. package/src/serve/public/pages/DocumentEditor.tsx +399 -9
  33. package/src/serve/public/pages/Search.tsx +20 -1
  34. package/src/serve/routes/api.ts +359 -28
  35. package/src/serve/server.ts +48 -1
  36. package/src/serve/watch-service.ts +149 -0
@@ -0,0 +1,44 @@
1
+ export interface LocalHistoryEntry {
2
+ savedAt: string;
3
+ content: string;
4
+ }
5
+
6
+ const HISTORY_PREFIX = "gno.doc-history.";
7
+ const MAX_ENTRIES = 10;
8
+
9
+ function getHistoryKey(docId: string): string {
10
+ return `${HISTORY_PREFIX}${docId}`;
11
+ }
12
+
13
+ export function loadLocalHistory(docId: string): LocalHistoryEntry[] {
14
+ try {
15
+ const raw = localStorage.getItem(getHistoryKey(docId));
16
+ if (!raw) return [];
17
+ const parsed = JSON.parse(raw) as unknown;
18
+ if (!Array.isArray(parsed)) return [];
19
+ return parsed.filter((entry): entry is LocalHistoryEntry => {
20
+ if (!entry || typeof entry !== "object") return false;
21
+ const candidate = entry as Record<string, unknown>;
22
+ return (
23
+ typeof candidate.savedAt === "string" &&
24
+ typeof candidate.content === "string"
25
+ );
26
+ });
27
+ } catch {
28
+ return [];
29
+ }
30
+ }
31
+
32
+ export function appendLocalHistory(docId: string, content: string): void {
33
+ const next = [
34
+ { savedAt: new Date().toISOString(), content },
35
+ ...loadLocalHistory(docId).filter((entry) => entry.content !== content),
36
+ ].slice(0, MAX_ENTRIES);
37
+ localStorage.setItem(getHistoryKey(docId), JSON.stringify(next));
38
+ }
39
+
40
+ export function loadLatestLocalHistory(
41
+ docId: string
42
+ ): LocalHistoryEntry | undefined {
43
+ return loadLocalHistory(docId)[0];
44
+ }
@@ -0,0 +1,36 @@
1
+ export interface WikiLinkQuery {
2
+ query: string;
3
+ start: number;
4
+ end: number;
5
+ }
6
+
7
+ export function getActiveWikiLinkQuery(
8
+ content: string,
9
+ cursorPos: number
10
+ ): WikiLinkQuery | null {
11
+ if (cursorPos < 2) {
12
+ return null;
13
+ }
14
+
15
+ const prefix = content.slice(0, cursorPos);
16
+ const start = prefix.lastIndexOf("[[");
17
+ if (start === -1) {
18
+ return null;
19
+ }
20
+
21
+ const closing = prefix.indexOf("]]", start);
22
+ if (closing !== -1) {
23
+ return null;
24
+ }
25
+
26
+ const query = prefix.slice(start + 2);
27
+ if (query.includes("\n")) {
28
+ return null;
29
+ }
30
+
31
+ return {
32
+ query,
33
+ start,
34
+ end: cursorPos,
35
+ };
36
+ }
@@ -27,6 +27,7 @@ import {
27
27
  TableRow,
28
28
  } from "../components/ui/table";
29
29
  import { apiFetch } from "../hooks/use-api";
30
+ import { useDocEvents } from "../hooks/use-doc-events";
30
31
 
31
32
  interface PageProps {
32
33
  navigate: (to: string | number) => void;
@@ -77,6 +78,7 @@ export default function Browse({ navigate }: PageProps) {
77
78
  const [syncTarget, setSyncTarget] = useState<SyncTarget>(null);
78
79
  const [syncError, setSyncError] = useState<string | null>(null);
79
80
  const [refreshToken, setRefreshToken] = useState(0);
81
+ const latestDocEvent = useDocEvents();
80
82
  const limit = 25;
81
83
 
82
84
  // Parse collection from URL on mount
@@ -134,6 +136,15 @@ export default function Browse({ navigate }: PageProps) {
134
136
  setDocs([]);
135
137
  }, [availableDateFields, sortField]);
136
138
 
139
+ useEffect(() => {
140
+ if (!latestDocEvent?.changedAt) {
141
+ return;
142
+ }
143
+ setOffset(0);
144
+ setDocs([]);
145
+ setRefreshToken((current) => current + 1);
146
+ }, [latestDocEvent?.changedAt]);
147
+
137
148
  const handleCollectionChange = (value: string) => {
138
149
  const newSelected = value === "all" ? "" : value;
139
150
  setSelected(newSelected);
@@ -271,7 +271,7 @@ export default function Dashboard({ navigate }: PageProps) {
271
271
  {/* Quick Capture Card */}
272
272
  <Card
273
273
  className="group stagger-3 animate-fade-in cursor-pointer opacity-0 transition-all duration-200 hover:-translate-y-0.5 hover:border-secondary/50 hover:bg-secondary/5 hover:shadow-lg"
274
- onClick={openCapture}
274
+ onClick={() => openCapture()}
275
275
  >
276
276
  <CardHeader className="pb-2">
277
277
  <CardDescription className="flex items-center gap-2">
@@ -350,7 +350,7 @@ export default function Dashboard({ navigate }: PageProps) {
350
350
  </main>
351
351
 
352
352
  {/* Floating Action Button */}
353
- <CaptureButton onClick={openCapture} />
353
+ <CaptureButton onClick={() => openCapture()} />
354
354
  </div>
355
355
  );
356
356
  }
@@ -9,8 +9,10 @@ import {
9
9
  FolderOpen,
10
10
  HardDrive,
11
11
  HomeIcon,
12
+ LinkIcon,
12
13
  Loader2Icon,
13
14
  PencilIcon,
15
+ SquareArrowOutUpRightIcon,
14
16
  TagIcon,
15
17
  TextIcon,
16
18
  TrashIcon,
@@ -49,6 +51,13 @@ import {
49
51
  } from "../components/ui/dialog";
50
52
  import { Separator } from "../components/ui/separator";
51
53
  import { apiFetch } from "../hooks/use-api";
54
+ import { useDocEvents } from "../hooks/use-doc-events";
55
+ import {
56
+ buildDocDeepLink,
57
+ buildEditDeepLink,
58
+ parseDocumentDeepLink,
59
+ } from "../lib/deep-links";
60
+ import { waitForDocumentAvailability } from "../lib/document-availability";
52
61
 
53
62
  interface PageProps {
54
63
  navigate: (to: string | number) => void;
@@ -64,10 +73,40 @@ interface DocData {
64
73
  relPath: string;
65
74
  tags: string[];
66
75
  source: {
76
+ absPath?: string;
67
77
  mime: string;
68
78
  ext: string;
69
79
  modifiedAt?: string;
70
80
  sizeBytes?: number;
81
+ sourceHash?: string;
82
+ };
83
+ capabilities: {
84
+ editable: boolean;
85
+ tagsEditable: boolean;
86
+ tagsWriteback: boolean;
87
+ canCreateEditableCopy: boolean;
88
+ mode: "editable" | "read_only";
89
+ reason?: string;
90
+ };
91
+ }
92
+
93
+ interface CreateEditableCopyResponse {
94
+ uri: string;
95
+ path: string;
96
+ jobId: string | null;
97
+ note?: string;
98
+ }
99
+
100
+ interface UpdateDocResponse {
101
+ success: boolean;
102
+ docId: string;
103
+ uri: string;
104
+ path: string;
105
+ jobId: string | null;
106
+ writeBack?: "applied" | "skipped_unsupported";
107
+ version: {
108
+ sourceHash: string;
109
+ modifiedAt?: string;
71
110
  };
72
111
  }
73
112
 
@@ -181,6 +220,11 @@ export default function DocView({ navigate }: PageProps) {
181
220
  const [deleting, setDeleting] = useState(false);
182
221
  const [deleteError, setDeleteError] = useState<string | null>(null);
183
222
  const [showRawView, setShowRawView] = useState(false);
223
+ const [creatingCopy, setCreatingCopy] = useState(false);
224
+ const [copyError, setCopyError] = useState<string | null>(null);
225
+ const [externalChangeNotice, setExternalChangeNotice] = useState<
226
+ string | null
227
+ >(null);
184
228
 
185
229
  // Tag editing state
186
230
  const [editingTags, setEditingTags] = useState(false);
@@ -191,25 +235,32 @@ export default function DocView({ navigate }: PageProps) {
191
235
 
192
236
  // Request sequencing - ignore stale responses on rapid navigation
193
237
  const requestIdRef = useRef(0);
238
+ const latestDocEvent = useDocEvents();
194
239
 
195
240
  // App remounts page on route/query changes, so URI is stable per render.
196
- const currentUri = useMemo(() => {
197
- const params = new URLSearchParams(window.location.search);
198
- return params.get("uri") ?? "";
199
- }, []);
241
+ const currentTarget = useMemo(
242
+ () => parseDocumentDeepLink(window.location.search),
243
+ []
244
+ );
245
+ const currentUri = currentTarget.uri;
246
+ const highlightedLines = useMemo(() => {
247
+ if (!currentTarget.lineStart) return [];
248
+ const end = currentTarget.lineEnd ?? currentTarget.lineStart;
249
+ const lines: number[] = [];
250
+ for (let line = currentTarget.lineStart; line <= end; line += 1) {
251
+ lines.push(line);
252
+ }
253
+ return lines;
254
+ }, [currentTarget.lineEnd, currentTarget.lineStart]);
200
255
 
201
- // Fetch document when URI changes
202
- useEffect(() => {
256
+ const loadDocument = useCallback(() => {
203
257
  if (!currentUri) {
204
258
  setError("No document URI provided");
205
259
  setLoading(false);
206
260
  return;
207
261
  }
208
262
 
209
- // Increment request ID to ignore stale responses
210
263
  const currentRequestId = ++requestIdRef.current;
211
-
212
- // Reset state for new document
213
264
  setLoading(true);
214
265
  setError(null);
215
266
  setDoc(null);
@@ -232,6 +283,25 @@ export default function DocView({ navigate }: PageProps) {
232
283
  });
233
284
  }, [currentUri]);
234
285
 
286
+ // Fetch document when URI changes
287
+ useEffect(() => {
288
+ loadDocument();
289
+ }, [loadDocument]);
290
+
291
+ useEffect(() => {
292
+ if (latestDocEvent?.uri !== currentUri) {
293
+ return;
294
+ }
295
+ setExternalChangeNotice(
296
+ "This document changed on disk. Reload to see the latest content."
297
+ );
298
+ }, [currentUri, latestDocEvent?.changedAt, latestDocEvent?.uri]);
299
+
300
+ const reloadDocument = useCallback(() => {
301
+ setExternalChangeNotice(null);
302
+ loadDocument();
303
+ }, [loadDocument]);
304
+
235
305
  const isMarkdown =
236
306
  doc?.source.ext &&
237
307
  [".md", ".markdown"].includes(doc.source.ext.toLowerCase());
@@ -267,14 +337,54 @@ export default function DocView({ navigate }: PageProps) {
267
337
 
268
338
  const hasFrontmatter = Object.keys(parsedContent.data).length > 0;
269
339
 
340
+ useEffect(() => {
341
+ if (currentTarget.view === "source" || currentTarget.lineStart) {
342
+ setShowRawView(true);
343
+ }
344
+ }, [currentTarget.lineStart, currentTarget.view]);
345
+
270
346
  const breadcrumbs = doc ? parseBreadcrumbs(doc.collection, doc.relPath) : [];
271
347
 
272
348
  const handleEdit = () => {
273
- if (doc) {
274
- navigate(`/edit?uri=${encodeURIComponent(doc.uri)}`);
349
+ if (doc?.capabilities.editable) {
350
+ navigate(
351
+ buildEditDeepLink({
352
+ uri: doc.uri,
353
+ lineStart: currentTarget.lineStart,
354
+ lineEnd: currentTarget.lineEnd,
355
+ })
356
+ );
275
357
  }
276
358
  };
277
359
 
360
+ const handleCreateEditableCopy = useCallback(async () => {
361
+ if (!doc?.capabilities.canCreateEditableCopy) return;
362
+
363
+ setCreatingCopy(true);
364
+ setCopyError(null);
365
+ const { data, error: err } = await apiFetch<CreateEditableCopyResponse>(
366
+ `/api/docs/${encodeURIComponent(doc.docid)}/editable-copy`,
367
+ { method: "POST" }
368
+ );
369
+ setCreatingCopy(false);
370
+
371
+ if (err) {
372
+ setCopyError(err);
373
+ return;
374
+ }
375
+
376
+ if (data) {
377
+ const ready = await waitForDocumentAvailability(data.uri);
378
+ if (!ready) {
379
+ setCopyError(
380
+ "Created the markdown copy, but it is still indexing. Try again in a moment."
381
+ );
382
+ return;
383
+ }
384
+ navigate(`/edit?uri=${encodeURIComponent(data.uri)}`);
385
+ }
386
+ }, [doc, navigate]);
387
+
278
388
  const handleDelete = async () => {
279
389
  if (!doc) return;
280
390
 
@@ -322,11 +432,15 @@ export default function DocView({ navigate }: PageProps) {
322
432
  setTagSaveError(null);
323
433
  setTagSaveSuccess(false);
324
434
 
325
- const { error: err } = await apiFetch(
435
+ const { data, error: err } = await apiFetch<UpdateDocResponse>(
326
436
  `/api/docs/${encodeURIComponent(doc.docid)}`,
327
437
  {
328
438
  method: "PUT",
329
- body: JSON.stringify({ tags: editedTags }),
439
+ body: JSON.stringify({
440
+ tags: editedTags,
441
+ expectedSourceHash: doc.source.sourceHash,
442
+ expectedModifiedAt: doc.source.modifiedAt,
443
+ }),
330
444
  }
331
445
  );
332
446
 
@@ -338,7 +452,15 @@ export default function DocView({ navigate }: PageProps) {
338
452
  }
339
453
 
340
454
  // Update doc with new tags
341
- setDoc({ ...doc, tags: editedTags });
455
+ setDoc({
456
+ ...doc,
457
+ tags: editedTags,
458
+ source: {
459
+ ...doc.source,
460
+ sourceHash: data?.version.sourceHash ?? doc.source.sourceHash,
461
+ modifiedAt: data?.version.modifiedAt ?? doc.source.modifiedAt,
462
+ },
463
+ });
342
464
  setEditingTags(false);
343
465
  setTagSaveSuccess(true);
344
466
 
@@ -377,6 +499,9 @@ export default function DocView({ navigate }: PageProps) {
377
499
  {doc?.title || "Document"}
378
500
  </h1>
379
501
  </div>
502
+ {doc?.capabilities.mode === "read_only" && (
503
+ <Badge variant="secondary">Read-only</Badge>
504
+ )}
380
505
  {doc?.source.ext && (
381
506
  <Badge className="shrink-0 font-mono" variant="outline">
382
507
  {doc.source.ext}
@@ -386,10 +511,44 @@ export default function DocView({ navigate }: PageProps) {
386
511
  <>
387
512
  <Separator className="h-6" orientation="vertical" />
388
513
  <div className="flex items-center gap-2">
389
- <Button className="gap-1.5" onClick={handleEdit} size="sm">
390
- <PencilIcon className="size-4" />
391
- Edit
392
- </Button>
514
+ {doc.capabilities.editable ? (
515
+ <Button className="gap-1.5" onClick={handleEdit} size="sm">
516
+ <PencilIcon className="size-4" />
517
+ Edit
518
+ </Button>
519
+ ) : (
520
+ <>
521
+ {doc.capabilities.canCreateEditableCopy && (
522
+ <Button
523
+ className="gap-1.5"
524
+ disabled={creatingCopy}
525
+ onClick={() => {
526
+ void handleCreateEditableCopy();
527
+ }}
528
+ size="sm"
529
+ >
530
+ {creatingCopy ? (
531
+ <Loader2Icon className="size-4 animate-spin" />
532
+ ) : (
533
+ <PencilIcon className="size-4" />
534
+ )}
535
+ Create editable copy
536
+ </Button>
537
+ )}
538
+ {doc.source.absPath && (
539
+ <Button asChild size="sm" variant="outline">
540
+ <a
541
+ href={`file://${doc.source.absPath}`}
542
+ rel="noopener noreferrer"
543
+ target="_blank"
544
+ >
545
+ <SquareArrowOutUpRightIcon className="mr-1.5 size-4" />
546
+ Open original
547
+ </a>
548
+ </Button>
549
+ )}
550
+ </>
551
+ )}
393
552
  <Button
394
553
  className="gap-1.5 text-muted-foreground hover:text-destructive"
395
554
  onClick={() => setDeleteDialogOpen(true)}
@@ -431,6 +590,29 @@ export default function DocView({ navigate }: PageProps) {
431
590
  {/* Document */}
432
591
  {doc && (
433
592
  <div className="animate-fade-in space-y-6 opacity-0">
593
+ {externalChangeNotice && (
594
+ <Card className="border-amber-500/40 bg-amber-500/10">
595
+ <CardContent className="flex flex-wrap items-center justify-between gap-3 py-3">
596
+ <p className="text-amber-500 text-sm">
597
+ {externalChangeNotice}
598
+ </p>
599
+ <Button
600
+ onClick={reloadDocument}
601
+ size="sm"
602
+ variant="outline"
603
+ >
604
+ Reload
605
+ </Button>
606
+ </CardContent>
607
+ </Card>
608
+ )}
609
+ {copyError && (
610
+ <Card className="border-amber-500/40 bg-amber-500/10">
611
+ <CardContent className="py-3 text-amber-500 text-sm">
612
+ {copyError}
613
+ </CardContent>
614
+ </Card>
615
+ )}
434
616
  {/* Breadcrumbs */}
435
617
  {breadcrumbs.length > 0 && (
436
618
  <nav className="flex items-center gap-1 text-sm">
@@ -507,6 +689,43 @@ export default function DocView({ navigate }: PageProps) {
507
689
  <code className="break-all font-mono text-muted-foreground text-sm">
508
690
  {doc.uri}
509
691
  </code>
692
+ {doc.capabilities.reason && (
693
+ <p className="mt-2 text-amber-500 text-xs">
694
+ {doc.capabilities.reason}
695
+ </p>
696
+ )}
697
+ <div className="mt-2 flex flex-wrap gap-2">
698
+ {currentTarget.lineStart && (
699
+ <Badge className="font-mono" variant="outline">
700
+ L{currentTarget.lineStart}
701
+ {currentTarget.lineEnd &&
702
+ currentTarget.lineEnd !== currentTarget.lineStart
703
+ ? `-${currentTarget.lineEnd}`
704
+ : ""}
705
+ </Badge>
706
+ )}
707
+ <Button
708
+ onClick={() => {
709
+ void navigator.clipboard.writeText(
710
+ `${window.location.origin}${buildDocDeepLink({
711
+ uri: doc.uri,
712
+ view:
713
+ currentTarget.view === "source" ||
714
+ currentTarget.lineStart
715
+ ? "source"
716
+ : "rendered",
717
+ lineStart: currentTarget.lineStart,
718
+ lineEnd: currentTarget.lineEnd,
719
+ })}`
720
+ );
721
+ }}
722
+ size="sm"
723
+ variant="outline"
724
+ >
725
+ <LinkIcon className="mr-1.5 size-4" />
726
+ Copy link
727
+ </Button>
728
+ </div>
510
729
  </div>
511
730
  </CardContent>
512
731
  </Card>
@@ -659,7 +878,9 @@ export default function DocView({ navigate }: PageProps) {
659
878
  {doc.contentAvailable && isMarkdown && showRawView && (
660
879
  <CodeBlock
661
880
  code={doc.content ?? ""}
881
+ highlightedLines={highlightedLines}
662
882
  language={"markdown" as BundledLanguage}
883
+ scrollToLine={currentTarget.lineStart}
663
884
  showLineNumbers
664
885
  >
665
886
  <CodeBlockCopyButton />
@@ -668,9 +889,11 @@ export default function DocView({ navigate }: PageProps) {
668
889
  {doc.contentAvailable && isCodeFile && !isMarkdown && (
669
890
  <CodeBlock
670
891
  code={doc.content ?? ""}
892
+ highlightedLines={highlightedLines}
671
893
  language={
672
894
  getLanguageFromExt(doc.source.ext) as BundledLanguage
673
895
  }
896
+ scrollToLine={currentTarget.lineStart}
674
897
  showLineNumbers
675
898
  >
676
899
  <CodeBlockCopyButton />