@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,248 @@
1
+ import {
2
+ FilePlusIcon,
3
+ FileTextIcon,
4
+ Loader2Icon,
5
+ SearchIcon,
6
+ } from "lucide-react";
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
+
9
+ import { apiFetch } from "../hooks/use-api";
10
+ import { buildDocDeepLink } from "../lib/deep-links";
11
+ import {
12
+ CommandDialog,
13
+ CommandEmpty,
14
+ CommandGroup,
15
+ CommandInput,
16
+ CommandItem,
17
+ CommandList,
18
+ CommandSeparator,
19
+ CommandShortcut,
20
+ } from "./ui/command";
21
+
22
+ interface SearchResult {
23
+ docid: string;
24
+ uri: string;
25
+ title?: string;
26
+ snippet: string;
27
+ score: number;
28
+ snippetRange?: {
29
+ startLine: number;
30
+ endLine: number;
31
+ };
32
+ }
33
+
34
+ interface SearchResponse {
35
+ results: SearchResult[];
36
+ }
37
+
38
+ interface RecentDoc {
39
+ uri: string;
40
+ href: string;
41
+ label: string;
42
+ }
43
+
44
+ export const RECENT_DOCS_STORAGE_KEY = "gno.recent-docs";
45
+
46
+ export function saveRecentDocument(doc: RecentDoc): void {
47
+ const current = loadRecentDocuments().filter(
48
+ (entry) => entry.href !== doc.href
49
+ );
50
+ const next = [doc, ...current].slice(0, 8);
51
+ localStorage.setItem(RECENT_DOCS_STORAGE_KEY, JSON.stringify(next));
52
+ }
53
+
54
+ export function loadRecentDocuments(): RecentDoc[] {
55
+ try {
56
+ const raw = localStorage.getItem(RECENT_DOCS_STORAGE_KEY);
57
+ if (!raw) return [];
58
+ const parsed = JSON.parse(raw) as unknown;
59
+ if (!Array.isArray(parsed)) return [];
60
+ return parsed.filter((entry): entry is RecentDoc => {
61
+ if (!entry || typeof entry !== "object") return false;
62
+ const candidate = entry as Record<string, unknown>;
63
+ return (
64
+ typeof candidate.uri === "string" &&
65
+ typeof candidate.href === "string" &&
66
+ typeof candidate.label === "string"
67
+ );
68
+ });
69
+ } catch {
70
+ return [];
71
+ }
72
+ }
73
+
74
+ export interface QuickSwitcherProps {
75
+ open: boolean;
76
+ onOpenChange: (open: boolean) => void;
77
+ navigate: (to: string) => void;
78
+ onCreateNote: (draftTitle?: string) => void;
79
+ }
80
+
81
+ export function QuickSwitcher({
82
+ open,
83
+ onOpenChange,
84
+ navigate,
85
+ onCreateNote,
86
+ }: QuickSwitcherProps) {
87
+ const [query, setQuery] = useState("");
88
+ const [results, setResults] = useState<SearchResult[]>([]);
89
+ const [recentDocs, setRecentDocs] = useState<RecentDoc[]>([]);
90
+ const [loading, setLoading] = useState(false);
91
+ const requestIdRef = useRef(0);
92
+
93
+ useEffect(() => {
94
+ if (!open) {
95
+ setQuery("");
96
+ setResults([]);
97
+ setLoading(false);
98
+ return;
99
+ }
100
+ setRecentDocs(loadRecentDocuments());
101
+ }, [open]);
102
+
103
+ useEffect(() => {
104
+ if (!open) return;
105
+ if (!query.trim()) {
106
+ setResults([]);
107
+ setLoading(false);
108
+ return;
109
+ }
110
+
111
+ const currentRequestId = ++requestIdRef.current;
112
+ setLoading(true);
113
+ const timer = setTimeout(() => {
114
+ void apiFetch<SearchResponse>("/api/search", {
115
+ method: "POST",
116
+ body: JSON.stringify({
117
+ query,
118
+ limit: 8,
119
+ }),
120
+ }).then(({ data }) => {
121
+ if (currentRequestId !== requestIdRef.current) {
122
+ return;
123
+ }
124
+ setResults(data?.results ?? []);
125
+ setLoading(false);
126
+ });
127
+ }, 120);
128
+
129
+ return () => clearTimeout(timer);
130
+ }, [open, query]);
131
+
132
+ const recentItems = useMemo(() => recentDocs.slice(0, 6), [recentDocs]);
133
+
134
+ const openTarget = useCallback(
135
+ (target: { uri: string; lineStart?: number; lineEnd?: number }) => {
136
+ navigate(
137
+ buildDocDeepLink({
138
+ uri: target.uri,
139
+ view: target.lineStart ? "source" : "rendered",
140
+ lineStart: target.lineStart,
141
+ lineEnd: target.lineEnd,
142
+ })
143
+ );
144
+ onOpenChange(false);
145
+ },
146
+ [navigate, onOpenChange]
147
+ );
148
+
149
+ const showCreateAction = query.trim().length > 0;
150
+
151
+ return (
152
+ <CommandDialog
153
+ description="Jump to notes, open recent documents, or create a new note."
154
+ onOpenChange={onOpenChange}
155
+ open={open}
156
+ title="Quick Switcher"
157
+ >
158
+ <CommandInput
159
+ autoFocus
160
+ onValueChange={setQuery}
161
+ placeholder="Search notes or create a new one..."
162
+ value={query}
163
+ />
164
+ <CommandList>
165
+ <CommandEmpty>
166
+ {loading ? "Searching..." : "No matching documents."}
167
+ </CommandEmpty>
168
+
169
+ {recentItems.length > 0 && !query.trim() && (
170
+ <CommandGroup heading="Recent">
171
+ {recentItems.map((item) => (
172
+ <CommandItem
173
+ key={item.href}
174
+ onSelect={() => {
175
+ navigate(item.href);
176
+ onOpenChange(false);
177
+ }}
178
+ value={item.label}
179
+ >
180
+ <FileTextIcon />
181
+ <span>{item.label}</span>
182
+ <CommandShortcut>Recent</CommandShortcut>
183
+ </CommandItem>
184
+ ))}
185
+ </CommandGroup>
186
+ )}
187
+
188
+ {showCreateAction && (
189
+ <>
190
+ <CommandGroup heading="Actions">
191
+ <CommandItem
192
+ onSelect={() => {
193
+ onOpenChange(false);
194
+ onCreateNote(query.trim());
195
+ }}
196
+ value={`create-${query}`}
197
+ >
198
+ <FilePlusIcon />
199
+ <span>Create new note</span>
200
+ <CommandShortcut>{query.trim()}</CommandShortcut>
201
+ </CommandItem>
202
+ </CommandGroup>
203
+ <CommandSeparator />
204
+ </>
205
+ )}
206
+
207
+ {query.trim() && (
208
+ <CommandGroup heading="Documents">
209
+ {results.map((result) => (
210
+ <CommandItem
211
+ key={result.docid}
212
+ onSelect={() =>
213
+ openTarget({
214
+ uri: result.uri,
215
+ lineStart: result.snippetRange?.startLine,
216
+ lineEnd: result.snippetRange?.endLine,
217
+ })
218
+ }
219
+ value={`${result.title ?? result.uri} ${result.uri}`}
220
+ >
221
+ {loading ? (
222
+ <Loader2Icon className="animate-spin" />
223
+ ) : (
224
+ <SearchIcon />
225
+ )}
226
+ <div className="min-w-0 flex-1">
227
+ <div className="truncate">{result.title || result.uri}</div>
228
+ <div className="truncate text-muted-foreground text-xs">
229
+ {result.uri}
230
+ </div>
231
+ </div>
232
+ {result.snippetRange && (
233
+ <CommandShortcut>
234
+ L{result.snippetRange.startLine}
235
+ {result.snippetRange.endLine !==
236
+ result.snippetRange.startLine
237
+ ? `-${result.snippetRange.endLine}`
238
+ : ""}
239
+ </CommandShortcut>
240
+ )}
241
+ </CommandItem>
242
+ ))}
243
+ </CommandGroup>
244
+ )}
245
+ </CommandList>
246
+ </CommandDialog>
247
+ );
248
+ }
@@ -34,6 +34,7 @@ const shortcutGroups: ShortcutGroup[] = [
34
34
  title: "Global",
35
35
  shortcuts: [
36
36
  { keys: "N", description: "New note" },
37
+ { keys: "Cmd+K", description: "Quick switcher" },
37
38
  { keys: "/", description: "Focus search" },
38
39
  { keys: "T", description: "Cycle search depth" },
39
40
  { keys: "?", description: "Show shortcuts" },
@@ -17,6 +17,8 @@ type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
17
17
  code: string;
18
18
  language: BundledLanguage;
19
19
  showLineNumbers?: boolean;
20
+ highlightedLines?: number[];
21
+ scrollToLine?: number;
20
22
  };
21
23
 
22
24
  interface CodeBlockContextType {
@@ -27,35 +29,63 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
27
29
  code: "",
28
30
  });
29
31
 
30
- const lineNumberTransformer: ShikiTransformer = {
31
- name: "line-numbers",
32
- line(node, line) {
33
- node.children.unshift({
34
- type: "element",
35
- tagName: "span",
36
- properties: {
37
- className: [
38
- "inline-block",
39
- "min-w-10",
40
- "mr-4",
41
- "text-right",
42
- "select-none",
43
- "text-muted-foreground",
44
- ],
45
- },
46
- children: [{ type: "text", value: String(line) }],
47
- });
48
- },
49
- };
32
+ function createLineTransformer(
33
+ showLineNumbers: boolean,
34
+ highlightedLines: number[]
35
+ ): ShikiTransformer {
36
+ const highlighted = new Set(highlightedLines);
37
+
38
+ return {
39
+ name: "line-metadata",
40
+ line(node, line) {
41
+ const className = Array.isArray(node.properties.className)
42
+ ? [...node.properties.className]
43
+ : [];
44
+ className.push("gno-code-line");
45
+ if (highlighted.has(line)) {
46
+ className.push(
47
+ "bg-amber-500/12",
48
+ "ring-1",
49
+ "ring-inset",
50
+ "ring-amber-500/25"
51
+ );
52
+ }
53
+ node.properties.className = className;
54
+ node.properties["data-line-number"] = String(line);
55
+
56
+ if (!showLineNumbers) {
57
+ return;
58
+ }
59
+
60
+ node.children.unshift({
61
+ type: "element",
62
+ tagName: "span",
63
+ properties: {
64
+ className: [
65
+ "inline-block",
66
+ "min-w-10",
67
+ "mr-4",
68
+ "text-right",
69
+ "select-none",
70
+ "text-muted-foreground",
71
+ ],
72
+ },
73
+ children: [{ type: "text", value: String(line) }],
74
+ });
75
+ },
76
+ };
77
+ }
50
78
 
51
79
  export async function highlightCode(
52
80
  code: string,
53
81
  language: BundledLanguage,
54
- showLineNumbers = false
82
+ showLineNumbers = false,
83
+ highlightedLines: number[] = []
55
84
  ) {
56
- const transformers: ShikiTransformer[] = showLineNumbers
57
- ? [lineNumberTransformer]
58
- : [];
85
+ const transformers: ShikiTransformer[] =
86
+ showLineNumbers || highlightedLines.length > 0
87
+ ? [createLineTransformer(showLineNumbers, highlightedLines)]
88
+ : [];
59
89
 
60
90
  return await Promise.all([
61
91
  codeToHtml(code, {
@@ -75,6 +105,8 @@ export const CodeBlock = ({
75
105
  code,
76
106
  language,
77
107
  showLineNumbers = false,
108
+ highlightedLines = [],
109
+ scrollToLine,
78
110
  className,
79
111
  children,
80
112
  ...props
@@ -82,6 +114,7 @@ export const CodeBlock = ({
82
114
  const [html, setHtml] = useState<string>("");
83
115
  const [darkHtml, setDarkHtml] = useState<string>("");
84
116
  const requestIdRef = useRef(0);
117
+ const containerRef = useRef<HTMLDivElement>(null);
85
118
 
86
119
  useEffect(() => {
87
120
  let cancelled = false;
@@ -91,7 +124,8 @@ export const CodeBlock = ({
91
124
  const [light, dark] = await highlightCode(
92
125
  code,
93
126
  language,
94
- showLineNumbers
127
+ showLineNumbers,
128
+ highlightedLines
95
129
  );
96
130
  // Only apply if this is still the latest request AND not cancelled
97
131
  if (!cancelled && requestId === requestIdRef.current) {
@@ -104,7 +138,20 @@ export const CodeBlock = ({
104
138
  return () => {
105
139
  cancelled = true;
106
140
  };
107
- }, [code, language, showLineNumbers]);
141
+ }, [code, highlightedLines, language, showLineNumbers]);
142
+
143
+ useEffect(() => {
144
+ if (!scrollToLine) return;
145
+
146
+ const frame = requestAnimationFrame(() => {
147
+ const target = containerRef.current?.querySelector<HTMLElement>(
148
+ `[data-line-number="${scrollToLine}"]`
149
+ );
150
+ target?.scrollIntoView({ block: "center" });
151
+ });
152
+
153
+ return () => cancelAnimationFrame(frame);
154
+ }, [darkHtml, html, scrollToLine]);
108
155
 
109
156
  return (
110
157
  <CodeBlockContext.Provider value={{ code }}>
@@ -113,6 +160,7 @@ export const CodeBlock = ({
113
160
  "group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
114
161
  className
115
162
  )}
163
+ ref={containerRef}
116
164
  {...props}
117
165
  >
118
166
  <div className="relative">
@@ -36,6 +36,16 @@ export interface CodeMirrorEditorRef {
36
36
  insertAtCursor: (text: string) => void;
37
37
  /** Scroll to percentage position (0-1). Returns true if scroll actually changed. */
38
38
  scrollToPercent: (percent: number) => boolean;
39
+ /** Reveal a 1-based line number and select it. */
40
+ revealLine: (lineNumber: number) => boolean;
41
+ /** Current cursor position and screen coordinates. */
42
+ getCursorInfo: () => {
43
+ pos: number;
44
+ x: number;
45
+ y: number;
46
+ } | null;
47
+ /** Replace a text range and place the cursor at the end. */
48
+ replaceRange: (from: number, to: number, text: string) => boolean;
39
49
  }
40
50
 
41
51
  export function CodeMirrorEditor({
@@ -179,6 +189,47 @@ export function CodeMirrorEditor({
179
189
  }
180
190
  return false;
181
191
  },
192
+ revealLine: (lineNumber: number): boolean => {
193
+ const view = viewRef.current;
194
+ if (!view || !Number.isFinite(lineNumber)) return false;
195
+
196
+ const clampedLine = Math.max(
197
+ 1,
198
+ Math.min(Math.trunc(lineNumber), view.state.doc.lines)
199
+ );
200
+ const line = view.state.doc.line(clampedLine);
201
+ view.dispatch({
202
+ selection: { anchor: line.from, head: line.to },
203
+ scrollIntoView: true,
204
+ });
205
+ view.focus();
206
+ return true;
207
+ },
208
+ getCursorInfo: () => {
209
+ const view = viewRef.current;
210
+ if (!view) return null;
211
+
212
+ const pos = view.state.selection.main.head;
213
+ const coords = view.coordsAtPos(pos);
214
+ if (!coords) return null;
215
+
216
+ return {
217
+ pos,
218
+ x: coords.left,
219
+ y: coords.bottom,
220
+ };
221
+ },
222
+ replaceRange: (from: number, to: number, text: string): boolean => {
223
+ const view = viewRef.current;
224
+ if (!view) return false;
225
+
226
+ view.dispatch({
227
+ changes: { from, to, insert: text },
228
+ selection: { anchor: from + text.length },
229
+ });
230
+ view.focus();
231
+ return true;
232
+ },
182
233
  }));
183
234
 
184
235
  return <div ref={containerRef} className={className} />;
@@ -65,13 +65,13 @@ function CommandInput({
65
65
  }: React.ComponentProps<typeof CommandPrimitive.Input>) {
66
66
  return (
67
67
  <div
68
- className="flex h-9 items-center gap-2 border-b px-3"
68
+ className="flex h-11 items-center gap-2 rounded-t-md border-border/60 border-b bg-muted/10 px-3 focus-within:bg-background/80"
69
69
  data-slot="command-input-wrapper"
70
70
  >
71
71
  <SearchIcon className="size-4 shrink-0 opacity-50" />
72
72
  <CommandPrimitive.Input
73
73
  className={cn(
74
- "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
74
+ "flex h-10 w-full rounded-md border-none bg-transparent py-3 text-sm outline-none ring-0 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50",
75
75
  className
76
76
  )}
77
77
  data-slot="command-input"
@@ -0,0 +1,34 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export interface DocumentEvent {
4
+ type: "document-changed";
5
+ uri: string;
6
+ collection: string;
7
+ relPath: string;
8
+ origin: "watcher" | "save" | "create";
9
+ changedAt: string;
10
+ }
11
+
12
+ export function useDocEvents(): DocumentEvent | null {
13
+ const [event, setEvent] = useState<DocumentEvent | null>(null);
14
+
15
+ useEffect(() => {
16
+ const source = new EventSource("/api/events");
17
+ const handleEvent = (incoming: Event) => {
18
+ const message = incoming as MessageEvent<string>;
19
+ try {
20
+ setEvent(JSON.parse(message.data) as DocumentEvent);
21
+ } catch {
22
+ // Ignore malformed event payloads.
23
+ }
24
+ };
25
+
26
+ source.addEventListener("document-changed", handleEvent);
27
+ return () => {
28
+ source.removeEventListener("document-changed", handleEvent);
29
+ source.close();
30
+ };
31
+ }, []);
32
+
33
+ return event;
34
+ }
@@ -21,7 +21,7 @@ import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
21
21
 
22
22
  interface CaptureModalContextValue {
23
23
  /** Open the capture modal */
24
- openCapture: () => void;
24
+ openCapture: (draftTitle?: string) => void;
25
25
  /** Whether the modal is open */
26
26
  isOpen: boolean;
27
27
  }
@@ -41,8 +41,12 @@ export function CaptureModalProvider({
41
41
  onSuccess,
42
42
  }: CaptureModalProviderProps) {
43
43
  const [open, setOpen] = useState(false);
44
+ const [draftTitle, setDraftTitle] = useState("");
44
45
 
45
- const openCapture = useCallback(() => setOpen(true), []);
46
+ const openCapture = useCallback((nextDraftTitle?: string) => {
47
+ setDraftTitle(nextDraftTitle ?? "");
48
+ setOpen(true);
49
+ }, []);
46
50
 
47
51
  // 'n' global shortcut (single-key, skips when in text input)
48
52
  const shortcuts = useMemo(
@@ -68,7 +72,12 @@ export function CaptureModalProvider({
68
72
  return (
69
73
  <CaptureModalContext.Provider value={value}>
70
74
  {children}
71
- <CaptureModal onOpenChange={setOpen} onSuccess={onSuccess} open={open} />
75
+ <CaptureModal
76
+ draftTitle={draftTitle}
77
+ onOpenChange={setOpen}
78
+ onSuccess={onSuccess}
79
+ open={open}
80
+ />
72
81
  </CaptureModalContext.Provider>
73
82
  );
74
83
  }
@@ -72,8 +72,8 @@ export function useKeyboardShortcuts(shortcuts: Shortcut[]): void {
72
72
 
73
73
  // Modifier handling
74
74
  if (shortcut.meta) {
75
- // Require Ctrl for meta shortcuts
76
- if (!e.ctrlKey) continue;
75
+ // Allow Ctrl or Cmd for cross-platform meta shortcuts
76
+ if (!e.ctrlKey && !e.metaKey) continue;
77
77
  } else {
78
78
  // Single-key shortcuts: don't fire when any modifier held
79
79
  // Prevents hijacking Cmd+N on macOS, Ctrl+K on Windows, etc.
@@ -0,0 +1,68 @@
1
+ export interface DocumentDeepLinkTarget {
2
+ uri: string;
3
+ view?: "rendered" | "source";
4
+ lineStart?: number;
5
+ lineEnd?: number;
6
+ }
7
+
8
+ function parsePositiveInteger(value: string | null): number | undefined {
9
+ if (!value) return;
10
+ const parsed = Number.parseInt(value, 10);
11
+ if (!Number.isFinite(parsed) || parsed <= 0) {
12
+ return;
13
+ }
14
+ return parsed;
15
+ }
16
+
17
+ export function buildDocDeepLink(target: DocumentDeepLinkTarget): string {
18
+ const params = new URLSearchParams({ uri: target.uri });
19
+
20
+ if (target.view) {
21
+ params.set("view", target.view);
22
+ }
23
+ if (target.lineStart) {
24
+ params.set("lineStart", String(target.lineStart));
25
+ }
26
+ if (
27
+ target.lineStart !== undefined &&
28
+ target.lineEnd !== undefined &&
29
+ target.lineEnd >= target.lineStart
30
+ ) {
31
+ params.set("lineEnd", String(target.lineEnd));
32
+ }
33
+
34
+ return `/doc?${params.toString()}`;
35
+ }
36
+
37
+ export function buildEditDeepLink(target: DocumentDeepLinkTarget): string {
38
+ const params = new URLSearchParams({ uri: target.uri });
39
+
40
+ if (target.lineStart) {
41
+ params.set("lineStart", String(target.lineStart));
42
+ }
43
+ if (
44
+ target.lineStart !== undefined &&
45
+ target.lineEnd !== undefined &&
46
+ target.lineEnd >= target.lineStart
47
+ ) {
48
+ params.set("lineEnd", String(target.lineEnd));
49
+ }
50
+
51
+ return `/edit?${params.toString()}`;
52
+ }
53
+
54
+ export function parseDocumentDeepLink(search: string): DocumentDeepLinkTarget {
55
+ const params = new URLSearchParams(search);
56
+ const uri = params.get("uri") ?? "";
57
+ const viewParam = params.get("view");
58
+ const view = viewParam === "source" ? "source" : "rendered";
59
+ const lineStart = parsePositiveInteger(params.get("lineStart"));
60
+ const lineEnd = parsePositiveInteger(params.get("lineEnd"));
61
+
62
+ return {
63
+ uri,
64
+ view,
65
+ lineStart,
66
+ lineEnd,
67
+ };
68
+ }
@@ -0,0 +1,22 @@
1
+ import { apiFetch } from "../hooks/use-api";
2
+
3
+ interface DocAvailabilityResponse {
4
+ uri: string;
5
+ }
6
+
7
+ export async function waitForDocumentAvailability(
8
+ uri: string,
9
+ attempts = 20,
10
+ delayMs = 250
11
+ ): Promise<boolean> {
12
+ for (let i = 0; i < attempts; i += 1) {
13
+ const { data } = await apiFetch<DocAvailabilityResponse>(
14
+ `/api/doc?uri=${encodeURIComponent(uri)}`
15
+ );
16
+ if (data?.uri === uri) {
17
+ return true;
18
+ }
19
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
20
+ }
21
+ return false;
22
+ }