@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.
- package/README.md +10 -2
- package/package.json +1 -1
- package/src/app/constants.ts +4 -2
- package/src/cli/commands/mcp/install.ts +4 -4
- package/src/cli/commands/mcp/status.ts +7 -7
- package/src/cli/commands/skill/install.ts +5 -5
- package/src/cli/program.ts +2 -2
- package/src/collection/add.ts +10 -0
- package/src/collection/types.ts +1 -0
- package/src/config/types.ts +12 -2
- package/src/core/depth-policy.ts +1 -1
- package/src/core/file-ops.ts +38 -0
- package/src/llm/registry.ts +20 -4
- package/src/serve/AGENTS.md +16 -16
- package/src/serve/CLAUDE.md +16 -16
- package/src/serve/config-sync.ts +32 -1
- package/src/serve/connectors.ts +243 -0
- package/src/serve/context.ts +9 -0
- package/src/serve/doc-events.ts +31 -1
- package/src/serve/embed-scheduler.ts +12 -0
- package/src/serve/import-preview.ts +173 -0
- package/src/serve/public/app.tsx +101 -7
- package/src/serve/public/components/AIModelSelector.tsx +383 -145
- package/src/serve/public/components/AddCollectionDialog.tsx +123 -7
- package/src/serve/public/components/BootstrapStatus.tsx +133 -0
- package/src/serve/public/components/CaptureModal.tsx +5 -2
- package/src/serve/public/components/CollectionsEmptyState.tsx +63 -0
- package/src/serve/public/components/FirstRunWizard.tsx +622 -0
- package/src/serve/public/components/HealthCenter.tsx +128 -0
- package/src/serve/public/components/IndexingProgress.tsx +21 -2
- package/src/serve/public/components/QuickSwitcher.tsx +62 -36
- package/src/serve/public/components/TagInput.tsx +5 -1
- package/src/serve/public/components/WikiLinkAutocomplete.tsx +15 -6
- package/src/serve/public/components/WorkspaceTabs.tsx +60 -0
- package/src/serve/public/hooks/use-doc-events.ts +48 -4
- package/src/serve/public/lib/local-history.ts +40 -7
- package/src/serve/public/lib/navigation-state.ts +156 -0
- package/src/serve/public/lib/workspace-tabs.ts +235 -0
- package/src/serve/public/pages/Ask.tsx +11 -1
- package/src/serve/public/pages/Browse.tsx +73 -0
- package/src/serve/public/pages/Collections.tsx +29 -13
- package/src/serve/public/pages/Connectors.tsx +178 -0
- package/src/serve/public/pages/Dashboard.tsx +493 -67
- package/src/serve/public/pages/DocView.tsx +192 -34
- package/src/serve/public/pages/DocumentEditor.tsx +127 -5
- package/src/serve/public/pages/Search.tsx +12 -1
- package/src/serve/routes/api.ts +532 -62
- package/src/serve/server.ts +79 -2
- package/src/serve/status-model.ts +149 -0
- package/src/serve/status.ts +706 -0
- package/src/serve/watch-service.ts +73 -8
- 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
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
175
|
+
currentRun = currentChar;
|
|
173
176
|
isHighlighted = charIsHighlighted;
|
|
174
177
|
} else {
|
|
175
|
-
currentRun +=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
65
|
+
...loadLocalHistory(docId, storage).filter(
|
|
66
|
+
(entry) => entry.content !== content
|
|
67
|
+
),
|
|
36
68
|
].slice(0, MAX_ENTRIES);
|
|
37
|
-
|
|
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
|
}
|