@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
@@ -21,6 +21,7 @@ import {
21
21
  LinkIcon,
22
22
  Loader2Icon,
23
23
  PenIcon,
24
+ SquareArrowOutUpRightIcon,
24
25
  UnlinkIcon,
25
26
  } from "lucide-react";
26
27
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -52,7 +53,19 @@ import {
52
53
  TooltipProvider,
53
54
  TooltipTrigger,
54
55
  } from "../components/ui/tooltip";
56
+ import {
57
+ type WikiLinkDoc,
58
+ WikiLinkAutocomplete,
59
+ } from "../components/WikiLinkAutocomplete";
55
60
  import { apiFetch } from "../hooks/use-api";
61
+ import { useDocEvents } from "../hooks/use-doc-events";
62
+ import { buildEditDeepLink, parseDocumentDeepLink } from "../lib/deep-links";
63
+ import { waitForDocumentAvailability } from "../lib/document-availability";
64
+ import {
65
+ appendLocalHistory,
66
+ loadLatestLocalHistory,
67
+ } from "../lib/local-history";
68
+ import { getActiveWikiLinkQuery } from "../lib/wiki-link";
56
69
 
57
70
  interface PageProps {
58
71
  navigate: (to: string | number) => void;
@@ -67,13 +80,47 @@ interface DocData {
67
80
  collection: string;
68
81
  relPath: string;
69
82
  source: {
83
+ absPath?: string;
70
84
  mime: string;
71
85
  ext: string;
72
86
  modifiedAt?: string;
73
87
  sizeBytes?: number;
88
+ sourceHash?: string;
89
+ };
90
+ capabilities: {
91
+ editable: boolean;
92
+ tagsEditable: boolean;
93
+ tagsWriteback: boolean;
94
+ canCreateEditableCopy: boolean;
95
+ mode: "editable" | "read_only";
96
+ reason?: string;
97
+ };
98
+ }
99
+
100
+ interface CreateEditableCopyResponse {
101
+ uri: string;
102
+ path: string;
103
+ jobId: string | null;
104
+ note?: string;
105
+ }
106
+
107
+ interface UpdateDocResponse {
108
+ success: boolean;
109
+ docId: string;
110
+ uri: string;
111
+ path: string;
112
+ jobId: string | null;
113
+ writeBack?: "applied" | "skipped_unsupported";
114
+ version: {
115
+ sourceHash: string;
116
+ modifiedAt?: string;
74
117
  };
75
118
  }
76
119
 
120
+ interface DocsAutocompleteResponse {
121
+ docs: WikiLinkDoc[];
122
+ }
123
+
77
124
  type SaveStatus = "saved" | "saving" | "unsaved" | "error";
78
125
 
79
126
  function useDebouncedCallback<T extends unknown[]>(
@@ -128,6 +175,10 @@ function formatTime(date: Date): string {
128
175
  }
129
176
 
130
177
  export default function DocumentEditor({ navigate }: PageProps) {
178
+ const currentTarget = useMemo(
179
+ () => parseDocumentDeepLink(window.location.search),
180
+ []
181
+ );
131
182
  const [doc, setDoc] = useState<DocData | null>(null);
132
183
  const [error, setError] = useState<string | null>(null);
133
184
  const [loading, setLoading] = useState(true);
@@ -137,10 +188,25 @@ export default function DocumentEditor({ navigate }: PageProps) {
137
188
  const [saveStatus, setSaveStatus] = useState<SaveStatus>("saved");
138
189
  const [lastSaved, setLastSaved] = useState<Date | null>(null);
139
190
  const [saveError, setSaveError] = useState<string | null>(null);
191
+ const [creatingCopy, setCreatingCopy] = useState(false);
192
+ const [copyError, setCopyError] = useState<string | null>(null);
193
+ const [externalChangeNotice, setExternalChangeNotice] = useState<
194
+ string | null
195
+ >(null);
196
+ const [hasLocalSnapshot, setHasLocalSnapshot] = useState(false);
140
197
 
141
198
  const [showPreview, setShowPreview] = useState(true);
142
199
  const [syncScroll, setSyncScroll] = useState(true);
143
200
  const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
201
+ const [wikiLinkDocs, setWikiLinkDocs] = useState<WikiLinkDoc[]>([]);
202
+ const [wikiLinkOpen, setWikiLinkOpen] = useState(false);
203
+ const [wikiLinkQuery, setWikiLinkQuery] = useState("");
204
+ const [wikiLinkRange, setWikiLinkRange] = useState<{
205
+ start: number;
206
+ end: number;
207
+ } | null>(null);
208
+ const [wikiLinkPosition, setWikiLinkPosition] = useState({ x: 48, y: 120 });
209
+ const [wikiLinkActiveIndex, setWikiLinkActiveIndex] = useState(-1);
144
210
  /** Where to navigate after dialog action (-1 for back, or URL string) */
145
211
  const [pendingNavigation, setPendingNavigation] = useState<
146
212
  string | number | null
@@ -150,6 +216,8 @@ export default function DocumentEditor({ navigate }: PageProps) {
150
216
  // Event-based suppression: ignore the echo event caused by programmatic scroll
151
217
  const ignoreNextEditorScroll = useRef(false);
152
218
  const ignoreNextPreviewScroll = useRef(false);
219
+ const ignoreDocEventsUntilRef = useRef(0);
220
+ const latestDocEvent = useDocEvents();
153
221
 
154
222
  const hasUnsavedChanges = content !== originalContent;
155
223
  const parsedContent = useMemo(() => parseFrontmatter(content), [content]);
@@ -228,11 +296,15 @@ export default function DocumentEditor({ navigate }: PageProps) {
228
296
  setSaveStatus("saving");
229
297
  setSaveError(null);
230
298
 
231
- const { error: err } = await apiFetch(
299
+ const { data, error: err } = await apiFetch<UpdateDocResponse>(
232
300
  `/api/docs/${encodeURIComponent(doc.docid)}`,
233
301
  {
234
302
  method: "PUT",
235
- body: JSON.stringify({ content: contentToSave }),
303
+ body: JSON.stringify({
304
+ content: contentToSave,
305
+ expectedSourceHash: doc.source.sourceHash,
306
+ expectedModifiedAt: doc.source.modifiedAt,
307
+ }),
236
308
  }
237
309
  );
238
310
 
@@ -240,14 +312,120 @@ export default function DocumentEditor({ navigate }: PageProps) {
240
312
  setSaveStatus("error");
241
313
  setSaveError(err);
242
314
  } else {
315
+ ignoreDocEventsUntilRef.current = Date.now() + 5_000;
316
+ if (originalContent !== contentToSave) {
317
+ appendLocalHistory(doc.docid, originalContent);
318
+ setHasLocalSnapshot(true);
319
+ }
243
320
  setSaveStatus("saved");
244
321
  setOriginalContent(contentToSave);
245
322
  setLastSaved(new Date());
323
+ if (data) {
324
+ setDoc((currentDoc) =>
325
+ currentDoc
326
+ ? {
327
+ ...currentDoc,
328
+ source: {
329
+ ...currentDoc.source,
330
+ sourceHash: data.version.sourceHash,
331
+ modifiedAt: data.version.modifiedAt,
332
+ },
333
+ }
334
+ : currentDoc
335
+ );
336
+ }
246
337
  }
247
338
  },
248
339
  [doc]
249
340
  );
250
341
 
342
+ const handleCreateEditableCopy = useCallback(async () => {
343
+ if (!doc?.capabilities.canCreateEditableCopy) return;
344
+
345
+ setCreatingCopy(true);
346
+ setCopyError(null);
347
+ const { data, error: err } = await apiFetch<CreateEditableCopyResponse>(
348
+ `/api/docs/${encodeURIComponent(doc.docid)}/editable-copy`,
349
+ { method: "POST" }
350
+ );
351
+ setCreatingCopy(false);
352
+
353
+ if (err) {
354
+ setCopyError(err);
355
+ return;
356
+ }
357
+
358
+ if (data) {
359
+ ignoreDocEventsUntilRef.current = Date.now() + 5_000;
360
+ const ready = await waitForDocumentAvailability(data.uri);
361
+ if (!ready) {
362
+ setCopyError(
363
+ "Created the markdown copy, but it is still indexing. Try again in a moment."
364
+ );
365
+ return;
366
+ }
367
+ navigate(`/edit?uri=${encodeURIComponent(data.uri)}`);
368
+ }
369
+ }, [doc, navigate]);
370
+
371
+ const insertWikiLink = useCallback(
372
+ (title: string) => {
373
+ if (!wikiLinkRange) return;
374
+ const didReplace = editorRef.current?.replaceRange(
375
+ wikiLinkRange.start,
376
+ wikiLinkRange.end,
377
+ `[[${title}]]`
378
+ );
379
+ if (didReplace) {
380
+ setWikiLinkOpen(false);
381
+ setWikiLinkActiveIndex(-1);
382
+ }
383
+ },
384
+ [wikiLinkRange]
385
+ );
386
+
387
+ const handleCreateLinkedNote = useCallback(
388
+ async (title: string) => {
389
+ if (!doc) return;
390
+ const filename = title
391
+ .toLowerCase()
392
+ .trim()
393
+ .replaceAll(/[^\w\s-]/g, "")
394
+ .replaceAll(/\s+/g, "-")
395
+ .replaceAll(/-+/g, "-")
396
+ .replace(/^-|-$/g, "");
397
+ const relPath = `${filename || "untitled"}.md`;
398
+ const { data, error: err } = await apiFetch<CreateEditableCopyResponse>(
399
+ "/api/docs",
400
+ {
401
+ method: "POST",
402
+ body: JSON.stringify({
403
+ collection: doc.collection,
404
+ relPath,
405
+ content: `# ${title}\n`,
406
+ }),
407
+ }
408
+ );
409
+ if (err) {
410
+ setCopyError(err);
411
+ return;
412
+ }
413
+ insertWikiLink(title);
414
+ if (data) {
415
+ setWikiLinkDocs((current) => [
416
+ ...current,
417
+ {
418
+ title,
419
+ uri: data.uri,
420
+ docid: data.uri,
421
+ collection: doc.collection,
422
+ },
423
+ ]);
424
+ }
425
+ },
426
+ [doc, insertWikiLink]
427
+ );
428
+
251
429
  // Debounced auto-save
252
430
  const { debouncedFn: debouncedSave } = useDebouncedCallback(
253
431
  saveDocument,
@@ -262,10 +440,47 @@ export default function DocumentEditor({ navigate }: PageProps) {
262
440
  setSaveStatus("unsaved");
263
441
  debouncedSave(newContent);
264
442
  }
443
+
444
+ const cursor = editorRef.current?.getCursorInfo();
445
+ if (!cursor) {
446
+ setWikiLinkOpen(false);
447
+ return;
448
+ }
449
+
450
+ const activeQuery = getActiveWikiLinkQuery(newContent, cursor.pos);
451
+ if (!activeQuery) {
452
+ setWikiLinkOpen(false);
453
+ setWikiLinkRange(null);
454
+ return;
455
+ }
456
+
457
+ setWikiLinkRange({ start: activeQuery.start, end: activeQuery.end });
458
+ setWikiLinkQuery(activeQuery.query);
459
+ setWikiLinkPosition({ x: cursor.x, y: cursor.y + 8 });
460
+ setWikiLinkOpen(true);
461
+ setWikiLinkActiveIndex(0);
265
462
  },
266
463
  [originalContent, debouncedSave]
267
464
  );
268
465
 
466
+ useEffect(() => {
467
+ if (!wikiLinkOpen) return;
468
+
469
+ const params = new URLSearchParams({
470
+ limit: "8",
471
+ query: wikiLinkQuery,
472
+ });
473
+ if (doc?.collection) {
474
+ params.set("collection", doc.collection);
475
+ }
476
+
477
+ void apiFetch<DocsAutocompleteResponse>(
478
+ `/api/docs/autocomplete?${params.toString()}`
479
+ ).then(({ data }) => {
480
+ setWikiLinkDocs(data?.docs ?? []);
481
+ });
482
+ }, [doc?.collection, wikiLinkOpen, wikiLinkQuery]);
483
+
269
484
  // Force save (Cmd+S) - saves and triggers embedding
270
485
  const handleForceSave = useCallback(async () => {
271
486
  if (!hasUnsavedChanges || !doc) return;
@@ -274,11 +489,15 @@ export default function DocumentEditor({ navigate }: PageProps) {
274
489
  setSaveError(null);
275
490
 
276
491
  // Save document
277
- const { error: err } = await apiFetch(
492
+ const { data, error: err } = await apiFetch<UpdateDocResponse>(
278
493
  `/api/docs/${encodeURIComponent(doc.docid)}`,
279
494
  {
280
495
  method: "PUT",
281
- body: JSON.stringify({ content }),
496
+ body: JSON.stringify({
497
+ content,
498
+ expectedSourceHash: doc.source.sourceHash,
499
+ expectedModifiedAt: doc.source.modifiedAt,
500
+ }),
282
501
  }
283
502
  );
284
503
 
@@ -288,18 +507,31 @@ export default function DocumentEditor({ navigate }: PageProps) {
288
507
  return;
289
508
  }
290
509
 
510
+ ignoreDocEventsUntilRef.current = Date.now() + 5_000;
511
+ if (originalContent !== content) {
512
+ appendLocalHistory(doc.docid, originalContent);
513
+ setHasLocalSnapshot(true);
514
+ }
291
515
  setSaveStatus("saved");
292
516
  setOriginalContent(content);
293
517
  setLastSaved(new Date());
518
+ if (data) {
519
+ setDoc({
520
+ ...doc,
521
+ source: {
522
+ ...doc.source,
523
+ sourceHash: data.version.sourceHash,
524
+ modifiedAt: data.version.modifiedAt,
525
+ },
526
+ });
527
+ }
294
528
 
295
529
  // Trigger embedding (fire and forget - don't block on result)
296
530
  void apiFetch("/api/embed", { method: "POST" });
297
531
  }, [hasUnsavedChanges, doc, content]);
298
532
 
299
- // Load document
300
- useEffect(() => {
301
- const params = new URLSearchParams(window.location.search);
302
- const uri = params.get("uri");
533
+ const loadDocument = useCallback(() => {
534
+ const uri = currentTarget.uri;
303
535
 
304
536
  if (!uri) {
305
537
  setError("No document URI provided");
@@ -317,14 +549,49 @@ export default function DocumentEditor({ navigate }: PageProps) {
317
549
  const docContent = data.content ?? "";
318
550
  setContent(docContent);
319
551
  setOriginalContent(docContent);
552
+ setHasLocalSnapshot(Boolean(loadLatestLocalHistory(data.docid)));
320
553
  // Ensure CodeMirror reflects content after async load
321
554
  requestAnimationFrame(() => {
322
555
  editorRef.current?.setValue(docContent);
556
+ if (currentTarget.lineStart) {
557
+ editorRef.current?.revealLine(currentTarget.lineStart);
558
+ }
323
559
  });
324
560
  }
325
561
  }
326
562
  );
327
- }, []);
563
+ }, [currentTarget.lineStart, currentTarget.uri]);
564
+
565
+ // Load document
566
+ useEffect(() => {
567
+ loadDocument();
568
+ }, [loadDocument]);
569
+
570
+ useEffect(() => {
571
+ if (!doc || latestDocEvent?.uri !== doc.uri) {
572
+ return;
573
+ }
574
+ if (Date.now() < ignoreDocEventsUntilRef.current) {
575
+ return;
576
+ }
577
+ setExternalChangeNotice(
578
+ "This document changed on disk. Reload before continuing."
579
+ );
580
+ }, [doc, latestDocEvent?.changedAt, latestDocEvent?.uri]);
581
+
582
+ const reloadDocument = useCallback(() => {
583
+ setExternalChangeNotice(null);
584
+ loadDocument();
585
+ }, [loadDocument]);
586
+
587
+ const restoreLatestSnapshot = useCallback(() => {
588
+ if (!doc) return;
589
+ const latest = loadLatestLocalHistory(doc.docid);
590
+ if (!latest) return;
591
+ setContent(latest.content);
592
+ editorRef.current?.setValue(latest.content);
593
+ setSaveStatus("unsaved");
594
+ }, [doc]);
328
595
 
329
596
  // Keyboard shortcuts
330
597
  useEffect(() => {
@@ -510,6 +777,70 @@ export default function DocumentEditor({ navigate }: PageProps) {
510
777
  );
511
778
  }
512
779
 
780
+ if (!doc.capabilities.editable) {
781
+ return (
782
+ <div className="flex min-h-screen items-center justify-center px-6">
783
+ <div className="w-full max-w-xl rounded-lg border border-border/50 bg-card p-6">
784
+ <div className="mb-4 flex items-center gap-3">
785
+ <AlertCircleIcon className="size-8 text-amber-500" />
786
+ <div>
787
+ <h2 className="font-semibold text-xl">Read-only document</h2>
788
+ <p className="text-muted-foreground text-sm">
789
+ {doc.capabilities.reason ??
790
+ "This document cannot be edited in place."}
791
+ </p>
792
+ </div>
793
+ </div>
794
+
795
+ {copyError && (
796
+ <div className="mb-4 rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-amber-500 text-sm">
797
+ {copyError}
798
+ </div>
799
+ )}
800
+
801
+ <div className="flex flex-wrap gap-3">
802
+ {doc.capabilities.canCreateEditableCopy && (
803
+ <Button
804
+ disabled={creatingCopy}
805
+ onClick={() => {
806
+ void handleCreateEditableCopy();
807
+ }}
808
+ >
809
+ {creatingCopy ? (
810
+ <Loader2Icon className="mr-2 size-4 animate-spin" />
811
+ ) : (
812
+ <PenIcon className="mr-2 size-4" />
813
+ )}
814
+ Create editable copy
815
+ </Button>
816
+ )}
817
+ <Button
818
+ onClick={() =>
819
+ navigate(`/doc?uri=${encodeURIComponent(doc.uri)}`)
820
+ }
821
+ variant="outline"
822
+ >
823
+ <BookOpenIcon className="mr-2 size-4" />
824
+ View document
825
+ </Button>
826
+ {doc.source.absPath && (
827
+ <Button asChild variant="outline">
828
+ <a
829
+ href={`file://${doc.source.absPath}`}
830
+ rel="noopener noreferrer"
831
+ target="_blank"
832
+ >
833
+ <SquareArrowOutUpRightIcon className="mr-2 size-4" />
834
+ Open original
835
+ </a>
836
+ </Button>
837
+ )}
838
+ </div>
839
+ </div>
840
+ </div>
841
+ );
842
+ }
843
+
513
844
  return (
514
845
  <div className="flex h-screen flex-col overflow-hidden">
515
846
  {/* Toolbar */}
@@ -564,6 +895,15 @@ export default function DocumentEditor({ navigate }: PageProps) {
564
895
  <div className="flex min-w-0 flex-1 items-center gap-2">
565
896
  <PenIcon className="size-4 shrink-0 text-muted-foreground" />
566
897
  <h1 className="truncate font-medium">{doc.title || doc.relPath}</h1>
898
+ {currentTarget.lineStart && (
899
+ <Badge className="shrink-0 font-mono" variant="outline">
900
+ L{currentTarget.lineStart}
901
+ {currentTarget.lineEnd &&
902
+ currentTarget.lineEnd !== currentTarget.lineStart
903
+ ? `-${currentTarget.lineEnd}`
904
+ : ""}
905
+ </Badge>
906
+ )}
567
907
  {hasUnsavedChanges && (
568
908
  <Badge
569
909
  className="shrink-0 bg-yellow-500/20 text-yellow-500"
@@ -579,6 +919,22 @@ export default function DocumentEditor({ navigate }: PageProps) {
579
919
 
580
920
  <Separator className="h-5" orientation="vertical" />
581
921
 
922
+ <Button
923
+ onClick={() => {
924
+ void navigator.clipboard.writeText(
925
+ `${window.location.origin}${buildEditDeepLink({
926
+ uri: doc.uri,
927
+ lineStart: currentTarget.lineStart,
928
+ lineEnd: currentTarget.lineEnd,
929
+ })}`
930
+ );
931
+ }}
932
+ size="sm"
933
+ variant="ghost"
934
+ >
935
+ <LinkIcon className="size-4" />
936
+ </Button>
937
+
582
938
  {/* Preview toggle */}
583
939
  <TooltipProvider>
584
940
  <Tooltip>
@@ -647,6 +1003,26 @@ export default function DocumentEditor({ navigate }: PageProps) {
647
1003
  </div>
648
1004
  </header>
649
1005
 
1006
+ {externalChangeNotice && (
1007
+ <div className="border-amber-500/30 border-b bg-amber-500/10 px-4 py-3">
1008
+ <div className="flex flex-wrap items-center justify-between gap-3">
1009
+ <p className="text-amber-500 text-sm">{externalChangeNotice}</p>
1010
+ <Button onClick={reloadDocument} size="sm" variant="outline">
1011
+ Reload
1012
+ </Button>
1013
+ {hasLocalSnapshot && (
1014
+ <Button
1015
+ onClick={restoreLatestSnapshot}
1016
+ size="sm"
1017
+ variant="outline"
1018
+ >
1019
+ Restore snapshot
1020
+ </Button>
1021
+ )}
1022
+ </div>
1023
+ </div>
1024
+ )}
1025
+
650
1026
  {/* Editor area */}
651
1027
  <div className="flex min-h-0 flex-1">
652
1028
  {/* Editor pane */}
@@ -684,6 +1060,20 @@ export default function DocumentEditor({ navigate }: PageProps) {
684
1060
  )}
685
1061
  </div>
686
1062
 
1063
+ <WikiLinkAutocomplete
1064
+ activeIndex={wikiLinkActiveIndex}
1065
+ docs={wikiLinkDocs}
1066
+ isOpen={wikiLinkOpen}
1067
+ onActiveIndexChange={setWikiLinkActiveIndex}
1068
+ onCreateNew={(title) => {
1069
+ void handleCreateLinkedNote(title);
1070
+ }}
1071
+ onDismiss={() => setWikiLinkOpen(false)}
1072
+ onSelect={(title) => insertWikiLink(title)}
1073
+ position={wikiLinkPosition}
1074
+ searchQuery={wikiLinkQuery}
1075
+ />
1076
+
687
1077
  {/* Unsaved changes dialog */}
688
1078
  <Dialog onOpenChange={setShowUnsavedDialog} open={showUnsavedDialog}>
689
1079
  <DialogContent>
@@ -35,7 +35,9 @@ import {
35
35
  } from "../components/ui/select";
36
36
  import { Textarea } from "../components/ui/textarea";
37
37
  import { apiFetch } from "../hooks/use-api";
38
+ import { useDocEvents } from "../hooks/use-doc-events";
38
39
  import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
40
+ import { buildDocDeepLink } from "../lib/deep-links";
39
41
  import {
40
42
  applyFiltersToUrl,
41
43
  parseFiltersFromSearch,
@@ -194,6 +196,7 @@ export default function Search({ navigate }: PageProps) {
194
196
  const [queryModeText, setQueryModeText] = useState("");
195
197
  const [queryModeError, setQueryModeError] = useState<string | null>(null);
196
198
  const [showMobileTags, setShowMobileTags] = useState(false);
199
+ const latestDocEvent = useDocEvents();
197
200
 
198
201
  const hybridAvailable = capabilities?.hybrid ?? false;
199
202
  const structuredQueryState = useMemo(
@@ -446,6 +449,15 @@ export default function Search({ navigate }: PageProps) {
446
449
  until,
447
450
  ]);
448
451
 
452
+ useEffect(() => {
453
+ if (!latestDocEvent?.changedAt) {
454
+ return;
455
+ }
456
+ if (searched && query.trim()) {
457
+ void handleSearch();
458
+ }
459
+ }, [handleSearch, latestDocEvent?.changedAt, query, searched]);
460
+
449
461
  const thoroughnessDesc =
450
462
  thoroughness === "fast"
451
463
  ? "Keyword search (BM25)"
@@ -1037,7 +1049,14 @@ export default function Search({ navigate }: PageProps) {
1037
1049
  className="group animate-fade-in cursor-pointer opacity-0 transition-all hover:border-primary/50 hover:bg-card/80"
1038
1050
  key={`${result.docid}-${i}`}
1039
1051
  onClick={() =>
1040
- navigate(`/doc?uri=${encodeURIComponent(result.uri)}`)
1052
+ navigate(
1053
+ buildDocDeepLink({
1054
+ uri: result.uri,
1055
+ view: result.snippetRange ? "source" : "rendered",
1056
+ lineStart: result.snippetRange?.startLine,
1057
+ lineEnd: result.snippetRange?.endLine,
1058
+ })
1059
+ )
1041
1060
  }
1042
1061
  style={{ animationDelay: `${i * 0.05}s` }}
1043
1062
  >