@gmickel/gno 0.28.2 → 0.29.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 (52) hide show
  1. package/README.md +10 -2
  2. package/package.json +1 -1
  3. package/src/app/constants.ts +4 -2
  4. package/src/cli/commands/mcp/install.ts +4 -4
  5. package/src/cli/commands/mcp/status.ts +7 -7
  6. package/src/cli/commands/skill/install.ts +5 -5
  7. package/src/cli/program.ts +2 -2
  8. package/src/collection/add.ts +10 -0
  9. package/src/collection/types.ts +1 -0
  10. package/src/config/types.ts +12 -2
  11. package/src/core/depth-policy.ts +1 -1
  12. package/src/core/file-ops.ts +38 -0
  13. package/src/llm/registry.ts +20 -4
  14. package/src/serve/AGENTS.md +16 -16
  15. package/src/serve/CLAUDE.md +16 -16
  16. package/src/serve/config-sync.ts +32 -1
  17. package/src/serve/connectors.ts +243 -0
  18. package/src/serve/context.ts +9 -0
  19. package/src/serve/doc-events.ts +31 -1
  20. package/src/serve/embed-scheduler.ts +12 -0
  21. package/src/serve/import-preview.ts +173 -0
  22. package/src/serve/public/app.tsx +101 -7
  23. package/src/serve/public/components/AIModelSelector.tsx +383 -145
  24. package/src/serve/public/components/AddCollectionDialog.tsx +123 -7
  25. package/src/serve/public/components/BootstrapStatus.tsx +133 -0
  26. package/src/serve/public/components/CaptureModal.tsx +5 -2
  27. package/src/serve/public/components/CollectionsEmptyState.tsx +63 -0
  28. package/src/serve/public/components/FirstRunWizard.tsx +622 -0
  29. package/src/serve/public/components/HealthCenter.tsx +128 -0
  30. package/src/serve/public/components/IndexingProgress.tsx +21 -2
  31. package/src/serve/public/components/QuickSwitcher.tsx +62 -36
  32. package/src/serve/public/components/TagInput.tsx +5 -1
  33. package/src/serve/public/components/WikiLinkAutocomplete.tsx +15 -6
  34. package/src/serve/public/components/WorkspaceTabs.tsx +60 -0
  35. package/src/serve/public/hooks/use-doc-events.ts +48 -4
  36. package/src/serve/public/lib/local-history.ts +40 -7
  37. package/src/serve/public/lib/navigation-state.ts +156 -0
  38. package/src/serve/public/lib/workspace-tabs.ts +235 -0
  39. package/src/serve/public/pages/Ask.tsx +11 -1
  40. package/src/serve/public/pages/Browse.tsx +73 -0
  41. package/src/serve/public/pages/Collections.tsx +29 -13
  42. package/src/serve/public/pages/Connectors.tsx +178 -0
  43. package/src/serve/public/pages/Dashboard.tsx +493 -67
  44. package/src/serve/public/pages/DocView.tsx +192 -34
  45. package/src/serve/public/pages/DocumentEditor.tsx +127 -5
  46. package/src/serve/public/pages/Search.tsx +12 -1
  47. package/src/serve/routes/api.ts +532 -62
  48. package/src/serve/server.ts +79 -2
  49. package/src/serve/status-model.ts +149 -0
  50. package/src/serve/status.ts +706 -0
  51. package/src/serve/watch-service.ts +73 -8
  52. package/src/types/electrobun-shell.d.ts +43 -0
@@ -0,0 +1,128 @@
1
+ import {
2
+ AlertCircleIcon,
3
+ CheckCircle2Icon,
4
+ HardDriveIcon,
5
+ Loader2Icon,
6
+ SparklesIcon,
7
+ } from "lucide-react";
8
+
9
+ import type {
10
+ AppStatusResponse,
11
+ HealthActionKind,
12
+ HealthCheck,
13
+ } from "../../status-model";
14
+
15
+ import { Badge } from "./ui/badge";
16
+ import { Button } from "./ui/button";
17
+ import {
18
+ Card,
19
+ CardContent,
20
+ CardDescription,
21
+ CardHeader,
22
+ CardTitle,
23
+ } from "./ui/card";
24
+
25
+ interface HealthCenterProps {
26
+ health: AppStatusResponse["health"];
27
+ onAction: (action: HealthActionKind) => void;
28
+ busyAction?: HealthActionKind | null;
29
+ }
30
+
31
+ function getStatusIcon(status: HealthCheck["status"]) {
32
+ switch (status) {
33
+ case "ok":
34
+ return <CheckCircle2Icon className="size-4 text-green-500" />;
35
+ case "warn":
36
+ return <AlertCircleIcon className="size-4 text-amber-500" />;
37
+ case "error":
38
+ return <AlertCircleIcon className="size-4 text-destructive" />;
39
+ }
40
+ }
41
+
42
+ function getStatusLabel(status: HealthCheck["status"]): string {
43
+ switch (status) {
44
+ case "ok":
45
+ return "Ready";
46
+ case "warn":
47
+ return "Needs attention";
48
+ case "error":
49
+ return "Blocked";
50
+ }
51
+ }
52
+
53
+ export function HealthCenter({
54
+ health,
55
+ onAction,
56
+ busyAction,
57
+ }: HealthCenterProps) {
58
+ return (
59
+ <section className="space-y-4">
60
+ <div className="flex items-start justify-between gap-4">
61
+ <div>
62
+ <div className="mb-2 flex items-center gap-2">
63
+ <HardDriveIcon className="size-4 text-primary" />
64
+ <h2 className="font-semibold text-2xl">Health Center</h2>
65
+ </div>
66
+ <p className="max-w-3xl text-muted-foreground">{health.summary}</p>
67
+ </div>
68
+ <Badge
69
+ className="border-primary/20 bg-primary/10 text-primary"
70
+ variant="outline"
71
+ >
72
+ {health.state === "healthy"
73
+ ? "Ready"
74
+ : health.state === "setup-required"
75
+ ? "First run"
76
+ : "Needs attention"}
77
+ </Badge>
78
+ </div>
79
+
80
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
81
+ {health.checks.map((check) => {
82
+ const isBusy = busyAction === check.actionKind;
83
+
84
+ return (
85
+ <Card
86
+ className="border-border/60 bg-card/70 backdrop-blur-sm"
87
+ key={check.id}
88
+ >
89
+ <CardHeader className="space-y-3 pb-3">
90
+ <div className="flex items-center justify-between gap-3">
91
+ <div className="flex items-center gap-2">
92
+ {getStatusIcon(check.status)}
93
+ <CardTitle className="text-base">{check.title}</CardTitle>
94
+ </div>
95
+ <Badge variant="secondary">
96
+ {getStatusLabel(check.status)}
97
+ </Badge>
98
+ </div>
99
+ <CardDescription className="text-sm text-foreground/85">
100
+ {check.summary}
101
+ </CardDescription>
102
+ </CardHeader>
103
+ <CardContent className="space-y-4">
104
+ <p className="text-muted-foreground text-sm">{check.detail}</p>
105
+ {check.actionKind && check.actionLabel && (
106
+ <Button
107
+ className="w-full"
108
+ disabled={isBusy}
109
+ onClick={() => onAction(check.actionKind!)}
110
+ size="sm"
111
+ variant={check.status === "ok" ? "outline" : "default"}
112
+ >
113
+ {isBusy ? (
114
+ <Loader2Icon className="mr-2 size-4 animate-spin" />
115
+ ) : check.status === "ok" ? (
116
+ <SparklesIcon className="mr-2 size-4" />
117
+ ) : null}
118
+ {check.actionLabel}
119
+ </Button>
120
+ )}
121
+ </CardContent>
122
+ </Card>
123
+ );
124
+ })}
125
+ </div>
126
+ </section>
127
+ );
128
+ }
@@ -50,6 +50,19 @@ interface CollectionResult {
50
50
  durationMs: number;
51
51
  }
52
52
 
53
+ function isNoopSyncResult(result: SyncResult | undefined): boolean {
54
+ if (!result) {
55
+ return false;
56
+ }
57
+
58
+ return (
59
+ result.totalFilesProcessed === 0 &&
60
+ result.totalFilesAdded === 0 &&
61
+ result.totalFilesUpdated === 0 &&
62
+ result.totalFilesErrored === 0
63
+ );
64
+ }
65
+
53
66
  export interface IndexingProgressProps {
54
67
  /** Job ID to poll */
55
68
  jobId: string;
@@ -211,8 +224,9 @@ export function IndexingProgress({
211
224
  >
212
225
  <CheckCircle2Icon className="size-4 text-green-500" />
213
226
  <span className="text-muted-foreground">
214
- {result?.totalFilesProcessed ?? 0} files in{" "}
215
- {formatDuration(result?.totalDurationMs ?? 0)}
227
+ {isNoopSyncResult(result)
228
+ ? "Up to date"
229
+ : `${result?.totalFilesProcessed ?? 0} files in ${formatDuration(result?.totalDurationMs ?? 0)}`}
216
230
  </span>
217
231
  </div>
218
232
  );
@@ -281,6 +295,11 @@ export function IndexingProgress({
281
295
  {/* Success summary */}
282
296
  {status?.status === "completed" && status.result && (
283
297
  <div className="space-y-2">
298
+ {isNoopSyncResult(status.result) && (
299
+ <div className="rounded-lg border border-green-500/30 bg-green-500/10 p-3 text-green-500 text-sm">
300
+ No file changes found. Workspace is already up to date.
301
+ </div>
302
+ )}
284
303
  <div className="flex flex-wrap gap-2">
285
304
  <Badge className="gap-1" variant="outline">
286
305
  <FileTextIcon className="size-3" />
@@ -1,13 +1,23 @@
1
1
  import {
2
2
  FilePlusIcon,
3
3
  FileTextIcon,
4
+ FolderIcon,
4
5
  Loader2Icon,
5
6
  SearchIcon,
7
+ StarIcon,
6
8
  } from "lucide-react";
7
9
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
10
 
9
11
  import { apiFetch } from "../hooks/use-api";
10
12
  import { buildDocDeepLink } from "../lib/deep-links";
13
+ import {
14
+ loadFavoriteCollections,
15
+ loadFavoriteDocuments,
16
+ loadRecentDocuments,
17
+ type FavoriteCollection,
18
+ type FavoriteDoc,
19
+ type RecentDoc,
20
+ } from "../lib/navigation-state";
11
21
  import {
12
22
  CommandDialog,
13
23
  CommandEmpty,
@@ -35,42 +45,6 @@ interface SearchResponse {
35
45
  results: SearchResult[];
36
46
  }
37
47
 
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
48
  export interface QuickSwitcherProps {
75
49
  open: boolean;
76
50
  onOpenChange: (open: boolean) => void;
@@ -87,6 +61,10 @@ export function QuickSwitcher({
87
61
  const [query, setQuery] = useState("");
88
62
  const [results, setResults] = useState<SearchResult[]>([]);
89
63
  const [recentDocs, setRecentDocs] = useState<RecentDoc[]>([]);
64
+ const [favoriteDocs, setFavoriteDocs] = useState<FavoriteDoc[]>([]);
65
+ const [favoriteCollections, setFavoriteCollections] = useState<
66
+ FavoriteCollection[]
67
+ >([]);
90
68
  const [loading, setLoading] = useState(false);
91
69
  const requestIdRef = useRef(0);
92
70
 
@@ -98,6 +76,8 @@ export function QuickSwitcher({
98
76
  return;
99
77
  }
100
78
  setRecentDocs(loadRecentDocuments());
79
+ setFavoriteDocs(loadFavoriteDocuments());
80
+ setFavoriteCollections(loadFavoriteCollections());
101
81
  }, [open]);
102
82
 
103
83
  useEffect(() => {
@@ -130,6 +110,14 @@ export function QuickSwitcher({
130
110
  }, [open, query]);
131
111
 
132
112
  const recentItems = useMemo(() => recentDocs.slice(0, 6), [recentDocs]);
113
+ const favoriteDocItems = useMemo(
114
+ () => favoriteDocs.slice(0, 6),
115
+ [favoriteDocs]
116
+ );
117
+ const favoriteCollectionItems = useMemo(
118
+ () => favoriteCollections.slice(0, 6),
119
+ [favoriteCollections]
120
+ );
133
121
 
134
122
  const openTarget = useCallback(
135
123
  (target: { uri: string; lineStart?: number; lineEnd?: number }) => {
@@ -185,6 +173,44 @@ export function QuickSwitcher({
185
173
  </CommandGroup>
186
174
  )}
187
175
 
176
+ {!query.trim() &&
177
+ (favoriteDocItems.length > 0 ||
178
+ favoriteCollectionItems.length > 0) && (
179
+ <>
180
+ <CommandSeparator />
181
+ <CommandGroup heading="Favorites">
182
+ {favoriteDocItems.map((item) => (
183
+ <CommandItem
184
+ key={item.href}
185
+ onSelect={() => {
186
+ navigate(item.href);
187
+ onOpenChange(false);
188
+ }}
189
+ value={`favorite-doc-${item.label}`}
190
+ >
191
+ <StarIcon />
192
+ <span>{item.label}</span>
193
+ <CommandShortcut>Doc</CommandShortcut>
194
+ </CommandItem>
195
+ ))}
196
+ {favoriteCollectionItems.map((item) => (
197
+ <CommandItem
198
+ key={item.href}
199
+ onSelect={() => {
200
+ navigate(item.href);
201
+ onOpenChange(false);
202
+ }}
203
+ value={`favorite-collection-${item.label}`}
204
+ >
205
+ <FolderIcon />
206
+ <span>{item.label}</span>
207
+ <CommandShortcut>Collection</CommandShortcut>
208
+ </CommandItem>
209
+ ))}
210
+ </CommandGroup>
211
+ </>
212
+ )}
213
+
188
214
  {showCreateAction && (
189
215
  <>
190
216
  <CommandGroup heading="Actions">
@@ -97,6 +97,7 @@ function flattenSuggestions(suggestions: TagSuggestion[]): FlatOption[] {
97
97
  const groupTags = groups.get(prefix)!;
98
98
  for (let i = 0; i < groupTags.length; i++) {
99
99
  const s = groupTags[i];
100
+ if (!s) continue;
100
101
  flat.push({
101
102
  tag: s.tag,
102
103
  count: s.count,
@@ -327,7 +328,10 @@ export function TagInput({
327
328
  case "Backspace":
328
329
  if (inputValue === "" && value.length > 0) {
329
330
  e.preventDefault();
330
- removeTag(value[value.length - 1]);
331
+ const lastTag = value.at(-1);
332
+ if (lastTag) {
333
+ removeTag(lastTag);
334
+ }
331
335
  }
332
336
  break;
333
337
  }
@@ -69,7 +69,8 @@ function fuzzyScore(text: string, query: string): number {
69
69
  const substringIdx = lowerText.indexOf(lowerQuery);
70
70
  if (substringIdx !== -1) {
71
71
  // Bonus for word boundary
72
- if (substringIdx === 0 || /\W/.test(text[substringIdx - 1])) {
72
+ const previousChar = substringIdx > 0 ? text.charAt(substringIdx - 1) : "";
73
+ if (substringIdx === 0 || /\W/.test(previousChar)) {
73
74
  return 800 + (query.length / text.length) * 50;
74
75
  }
75
76
  return 700 + (query.length / text.length) * 50;
@@ -92,7 +93,8 @@ function fuzzyScore(text: string, query: string): number {
92
93
  }
93
94
 
94
95
  // Word boundary bonus
95
- if (foundIdx === 0 || /\W/.test(text[foundIdx - 1])) {
96
+ const previousChar = foundIdx > 0 ? text.charAt(foundIdx - 1) : "";
97
+ if (foundIdx === 0 || /\W/.test(previousChar)) {
96
98
  score += 20;
97
99
  }
98
100
 
@@ -151,6 +153,7 @@ function HighlightedText({
151
153
  let isHighlighted = false;
152
154
 
153
155
  for (let i = 0; i < text.length; i++) {
156
+ const currentChar = text.charAt(i);
154
157
  const charIsHighlighted = indexSet.has(i);
155
158
 
156
159
  if (charIsHighlighted !== isHighlighted) {
@@ -169,10 +172,10 @@ function HighlightedText({
169
172
  )
170
173
  );
171
174
  }
172
- currentRun = text[i];
175
+ currentRun = currentChar;
173
176
  isHighlighted = charIsHighlighted;
174
177
  } else {
175
- currentRun += text[i];
178
+ currentRun += currentChar;
176
179
  }
177
180
  }
178
181
 
@@ -273,12 +276,18 @@ export function WikiLinkAutocomplete({
273
276
  e.preventDefault();
274
277
  e.stopPropagation();
275
278
  if (activeIndex >= 0 && activeIndex < filteredDocs.length) {
276
- onSelect(filteredDocs[activeIndex].doc.title);
279
+ const activeDoc = filteredDocs.at(activeIndex);
280
+ if (activeDoc) {
281
+ onSelect(activeDoc.doc.title);
282
+ }
277
283
  } else if (activeIndex === createOptionIndex && showCreateOption) {
278
284
  onCreateNew?.(searchQuery.trim());
279
285
  } else if (filteredDocs.length > 0) {
280
286
  // Default to first result if nothing selected
281
- onSelect(filteredDocs[0].doc.title);
287
+ const firstDoc = filteredDocs.at(0);
288
+ if (firstDoc) {
289
+ onSelect(firstDoc.doc.title);
290
+ }
282
291
  } else if (showCreateOption) {
283
292
  onCreateNew?.(searchQuery.trim());
284
293
  }
@@ -0,0 +1,60 @@
1
+ import { PlusIcon, XIcon } from "lucide-react";
2
+
3
+ import type { WorkspaceTab } from "../lib/workspace-tabs";
4
+
5
+ import { Button } from "./ui/button";
6
+
7
+ interface WorkspaceTabsProps {
8
+ tabs: WorkspaceTab[];
9
+ activeTabId: string;
10
+ onActivate: (tabId: string) => void;
11
+ onClose: (tabId: string) => void;
12
+ onNewTab: () => void;
13
+ }
14
+
15
+ export function WorkspaceTabs({
16
+ tabs,
17
+ activeTabId,
18
+ onActivate,
19
+ onClose,
20
+ onNewTab,
21
+ }: WorkspaceTabsProps) {
22
+ return (
23
+ <div className="border-border/50 border-b bg-background/90">
24
+ <div className="mx-auto flex max-w-7xl items-center gap-2 overflow-x-auto px-4 py-2">
25
+ {tabs.map((tab) => {
26
+ const active = tab.id === activeTabId;
27
+ return (
28
+ <div
29
+ className={`flex items-center rounded-lg border px-2 py-1 ${
30
+ active
31
+ ? "border-primary/40 bg-primary/10 text-primary"
32
+ : "border-border/60 bg-card/70"
33
+ }`}
34
+ key={tab.id}
35
+ >
36
+ <button
37
+ className="max-w-[220px] truncate px-2 py-1 text-left text-sm"
38
+ onClick={() => onActivate(tab.id)}
39
+ type="button"
40
+ >
41
+ {tab.label}
42
+ </button>
43
+ <Button
44
+ onClick={() => onClose(tab.id)}
45
+ size="icon-sm"
46
+ variant="ghost"
47
+ >
48
+ <XIcon className="size-3.5" />
49
+ </Button>
50
+ </div>
51
+ );
52
+ })}
53
+ <Button onClick={onNewTab} size="sm" variant="outline">
54
+ <PlusIcon className="mr-2 size-4" />
55
+ New Tab
56
+ </Button>
57
+ </div>
58
+ </div>
59
+ );
60
+ }
@@ -9,24 +9,68 @@ export interface DocumentEvent {
9
9
  changedAt: string;
10
10
  }
11
11
 
12
+ export function getEventStreamRetryDelay(attempt: number): number {
13
+ return Math.min(1_000 * 2 ** attempt, 10_000);
14
+ }
15
+
12
16
  export function useDocEvents(): DocumentEvent | null {
13
17
  const [event, setEvent] = useState<DocumentEvent | null>(null);
14
18
 
15
19
  useEffect(() => {
16
- const source = new EventSource("/api/events");
20
+ let source: EventSource | null = null;
21
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
22
+ let stopped = false;
23
+ let reconnectAttempt = 0;
24
+
25
+ const cleanupSource = () => {
26
+ if (!source) {
27
+ return;
28
+ }
29
+ source.removeEventListener("document-changed", handleEvent);
30
+ source.onerror = null;
31
+ source.close();
32
+ source = null;
33
+ };
34
+
35
+ const scheduleReconnect = () => {
36
+ if (stopped || reconnectTimer) {
37
+ return;
38
+ }
39
+ const delay = getEventStreamRetryDelay(reconnectAttempt);
40
+ reconnectAttempt += 1;
41
+ reconnectTimer = setTimeout(() => {
42
+ reconnectTimer = null;
43
+ connect();
44
+ }, delay);
45
+ };
46
+
17
47
  const handleEvent = (incoming: Event) => {
18
48
  const message = incoming as MessageEvent<string>;
19
49
  try {
20
50
  setEvent(JSON.parse(message.data) as DocumentEvent);
51
+ reconnectAttempt = 0;
21
52
  } catch {
22
53
  // Ignore malformed event payloads.
23
54
  }
24
55
  };
25
56
 
26
- source.addEventListener("document-changed", handleEvent);
57
+ const connect = () => {
58
+ cleanupSource();
59
+ source = new EventSource("/api/events");
60
+ source.addEventListener("document-changed", handleEvent);
61
+ source.onerror = () => {
62
+ cleanupSource();
63
+ scheduleReconnect();
64
+ };
65
+ };
66
+
67
+ connect();
27
68
  return () => {
28
- source.removeEventListener("document-changed", handleEvent);
29
- source.close();
69
+ stopped = true;
70
+ if (reconnectTimer) {
71
+ clearTimeout(reconnectTimer);
72
+ }
73
+ cleanupSource();
30
74
  };
31
75
  }, []);
32
76
 
@@ -3,6 +3,11 @@ export interface LocalHistoryEntry {
3
3
  content: string;
4
4
  }
5
5
 
6
+ export interface LocalHistoryStorageLike {
7
+ getItem(key: string): string | null;
8
+ setItem(key: string, value: string): void;
9
+ }
10
+
6
11
  const HISTORY_PREFIX = "gno.doc-history.";
7
12
  const MAX_ENTRIES = 10;
8
13
 
@@ -10,9 +15,26 @@ function getHistoryKey(docId: string): string {
10
15
  return `${HISTORY_PREFIX}${docId}`;
11
16
  }
12
17
 
13
- export function loadLocalHistory(docId: string): LocalHistoryEntry[] {
18
+ function getStorage(
19
+ storage?: LocalHistoryStorageLike
20
+ ): LocalHistoryStorageLike | null {
21
+ if (storage) {
22
+ return storage;
23
+ }
24
+ if (typeof localStorage === "undefined") {
25
+ return null;
26
+ }
27
+ return localStorage;
28
+ }
29
+
30
+ export function loadLocalHistory(
31
+ docId: string,
32
+ storage?: LocalHistoryStorageLike
33
+ ): LocalHistoryEntry[] {
14
34
  try {
15
- const raw = localStorage.getItem(getHistoryKey(docId));
35
+ const resolved = getStorage(storage);
36
+ if (!resolved) return [];
37
+ const raw = resolved.getItem(getHistoryKey(docId));
16
38
  if (!raw) return [];
17
39
  const parsed = JSON.parse(raw) as unknown;
18
40
  if (!Array.isArray(parsed)) return [];
@@ -29,16 +51,27 @@ export function loadLocalHistory(docId: string): LocalHistoryEntry[] {
29
51
  }
30
52
  }
31
53
 
32
- export function appendLocalHistory(docId: string, content: string): void {
54
+ export function appendLocalHistory(
55
+ docId: string,
56
+ content: string,
57
+ storage?: LocalHistoryStorageLike
58
+ ): void {
59
+ const resolved = getStorage(storage);
60
+ if (!resolved) {
61
+ return;
62
+ }
33
63
  const next = [
34
64
  { savedAt: new Date().toISOString(), content },
35
- ...loadLocalHistory(docId).filter((entry) => entry.content !== content),
65
+ ...loadLocalHistory(docId, storage).filter(
66
+ (entry) => entry.content !== content
67
+ ),
36
68
  ].slice(0, MAX_ENTRIES);
37
- localStorage.setItem(getHistoryKey(docId), JSON.stringify(next));
69
+ resolved.setItem(getHistoryKey(docId), JSON.stringify(next));
38
70
  }
39
71
 
40
72
  export function loadLatestLocalHistory(
41
- docId: string
73
+ docId: string,
74
+ storage?: LocalHistoryStorageLike
42
75
  ): LocalHistoryEntry | undefined {
43
- return loadLocalHistory(docId)[0];
76
+ return loadLocalHistory(docId, storage)[0];
44
77
  }