@gmickel/gno 0.28.2 → 0.29.1
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 +203 -1
- 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 +541 -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,156 @@
|
|
|
1
|
+
export interface RecentDoc {
|
|
2
|
+
uri: string;
|
|
3
|
+
href: string;
|
|
4
|
+
label: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface FavoriteDoc {
|
|
8
|
+
uri: string;
|
|
9
|
+
href: string;
|
|
10
|
+
label: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FavoriteCollection {
|
|
14
|
+
name: string;
|
|
15
|
+
href: string;
|
|
16
|
+
label: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface NavigationStorageLike {
|
|
20
|
+
getItem(key: string): string | null;
|
|
21
|
+
setItem(key: string, value: string): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const RECENT_DOCS_STORAGE_KEY = "gno.recent-docs";
|
|
25
|
+
export const FAVORITE_DOCS_STORAGE_KEY = "gno.favorite-docs";
|
|
26
|
+
export const FAVORITE_COLLECTIONS_STORAGE_KEY = "gno.favorite-collections";
|
|
27
|
+
|
|
28
|
+
function getStorage(
|
|
29
|
+
storage?: NavigationStorageLike
|
|
30
|
+
): NavigationStorageLike | null {
|
|
31
|
+
if (storage) {
|
|
32
|
+
return storage;
|
|
33
|
+
}
|
|
34
|
+
if (typeof localStorage === "undefined") {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return localStorage;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadList<T>(
|
|
41
|
+
key: string,
|
|
42
|
+
isValid: (value: unknown) => value is T,
|
|
43
|
+
storage?: NavigationStorageLike
|
|
44
|
+
): T[] {
|
|
45
|
+
try {
|
|
46
|
+
const resolved = getStorage(storage);
|
|
47
|
+
if (!resolved) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
const raw = resolved.getItem(key);
|
|
51
|
+
if (!raw) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
55
|
+
if (!Array.isArray(parsed)) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
return parsed.filter(isValid);
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function saveList<T>(
|
|
65
|
+
key: string,
|
|
66
|
+
value: T[],
|
|
67
|
+
storage?: NavigationStorageLike
|
|
68
|
+
): void {
|
|
69
|
+
const resolved = getStorage(storage);
|
|
70
|
+
if (!resolved) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
resolved.setItem(key, JSON.stringify(value));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isRecentDoc(value: unknown): value is RecentDoc {
|
|
77
|
+
if (!value || typeof value !== "object") {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const candidate = value as Record<string, unknown>;
|
|
81
|
+
return (
|
|
82
|
+
typeof candidate.uri === "string" &&
|
|
83
|
+
typeof candidate.href === "string" &&
|
|
84
|
+
typeof candidate.label === "string"
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isFavoriteCollection(value: unknown): value is FavoriteCollection {
|
|
89
|
+
if (!value || typeof value !== "object") {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
const candidate = value as Record<string, unknown>;
|
|
93
|
+
return (
|
|
94
|
+
typeof candidate.name === "string" &&
|
|
95
|
+
typeof candidate.href === "string" &&
|
|
96
|
+
typeof candidate.label === "string"
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function loadRecentDocuments(
|
|
101
|
+
storage?: NavigationStorageLike
|
|
102
|
+
): RecentDoc[] {
|
|
103
|
+
return loadList(RECENT_DOCS_STORAGE_KEY, isRecentDoc, storage);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function saveRecentDocument(
|
|
107
|
+
doc: RecentDoc,
|
|
108
|
+
storage?: NavigationStorageLike
|
|
109
|
+
): void {
|
|
110
|
+
const current = loadRecentDocuments(storage).filter(
|
|
111
|
+
(entry) => entry.href !== doc.href
|
|
112
|
+
);
|
|
113
|
+
saveList(RECENT_DOCS_STORAGE_KEY, [doc, ...current].slice(0, 8), storage);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function loadFavoriteDocuments(
|
|
117
|
+
storage?: NavigationStorageLike
|
|
118
|
+
): FavoriteDoc[] {
|
|
119
|
+
return loadList(FAVORITE_DOCS_STORAGE_KEY, isRecentDoc, storage);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function toggleFavoriteDocument(
|
|
123
|
+
doc: FavoriteDoc,
|
|
124
|
+
storage?: NavigationStorageLike
|
|
125
|
+
): FavoriteDoc[] {
|
|
126
|
+
const current = loadFavoriteDocuments(storage);
|
|
127
|
+
const exists = current.some((entry) => entry.href === doc.href);
|
|
128
|
+
const next = exists
|
|
129
|
+
? current.filter((entry) => entry.href !== doc.href)
|
|
130
|
+
: [doc, ...current].slice(0, 12);
|
|
131
|
+
saveList(FAVORITE_DOCS_STORAGE_KEY, next, storage);
|
|
132
|
+
return next;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function loadFavoriteCollections(
|
|
136
|
+
storage?: NavigationStorageLike
|
|
137
|
+
): FavoriteCollection[] {
|
|
138
|
+
return loadList(
|
|
139
|
+
FAVORITE_COLLECTIONS_STORAGE_KEY,
|
|
140
|
+
isFavoriteCollection,
|
|
141
|
+
storage
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function toggleFavoriteCollection(
|
|
146
|
+
collection: FavoriteCollection,
|
|
147
|
+
storage?: NavigationStorageLike
|
|
148
|
+
): FavoriteCollection[] {
|
|
149
|
+
const current = loadFavoriteCollections(storage);
|
|
150
|
+
const exists = current.some((entry) => entry.name === collection.name);
|
|
151
|
+
const next = exists
|
|
152
|
+
? current.filter((entry) => entry.name !== collection.name)
|
|
153
|
+
: [collection, ...current].slice(0, 12);
|
|
154
|
+
saveList(FAVORITE_COLLECTIONS_STORAGE_KEY, next, storage);
|
|
155
|
+
return next;
|
|
156
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
export interface WorkspaceTab {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
location: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface WorkspaceState {
|
|
8
|
+
tabs: WorkspaceTab[];
|
|
9
|
+
activeTabId: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface WorkspaceStorageLike {
|
|
13
|
+
getItem(key: string): string | null;
|
|
14
|
+
setItem(key: string, value: string): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const WORKSPACE_TABS_STORAGE_KEY = "gno.workspace-tabs";
|
|
18
|
+
|
|
19
|
+
function getStorage(
|
|
20
|
+
storage?: WorkspaceStorageLike
|
|
21
|
+
): WorkspaceStorageLike | null {
|
|
22
|
+
if (storage) {
|
|
23
|
+
return storage;
|
|
24
|
+
}
|
|
25
|
+
if (typeof localStorage === "undefined") {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return localStorage;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createTabId(): string {
|
|
32
|
+
return `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getLocationLabel(location: string): string {
|
|
36
|
+
const [path, search = ""] = location.split("?");
|
|
37
|
+
const params = new URLSearchParams(search);
|
|
38
|
+
|
|
39
|
+
switch (path) {
|
|
40
|
+
case "/":
|
|
41
|
+
return "Home";
|
|
42
|
+
case "/search":
|
|
43
|
+
return "Search";
|
|
44
|
+
case "/browse": {
|
|
45
|
+
const collection = params.get("collection");
|
|
46
|
+
return collection ? `Browse: ${collection}` : "Browse";
|
|
47
|
+
}
|
|
48
|
+
case "/ask":
|
|
49
|
+
return "Ask";
|
|
50
|
+
case "/collections":
|
|
51
|
+
return "Collections";
|
|
52
|
+
case "/connectors":
|
|
53
|
+
return "Connectors";
|
|
54
|
+
case "/graph":
|
|
55
|
+
return "Graph";
|
|
56
|
+
case "/doc":
|
|
57
|
+
case "/edit": {
|
|
58
|
+
const uri = params.get("uri") ?? "";
|
|
59
|
+
const fallback = path === "/edit" ? "Editor" : "Document";
|
|
60
|
+
if (!uri) {
|
|
61
|
+
return fallback;
|
|
62
|
+
}
|
|
63
|
+
const label = decodeURIComponent(uri.split("/").pop() ?? fallback);
|
|
64
|
+
return path === "/edit" ? `Edit: ${label}` : label;
|
|
65
|
+
}
|
|
66
|
+
default:
|
|
67
|
+
return "Workspace";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isWorkspaceTab(value: unknown): value is WorkspaceTab {
|
|
72
|
+
if (!value || typeof value !== "object") {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const candidate = value as Record<string, unknown>;
|
|
76
|
+
return (
|
|
77
|
+
typeof candidate.id === "string" &&
|
|
78
|
+
typeof candidate.label === "string" &&
|
|
79
|
+
typeof candidate.location === "string"
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function loadWorkspaceState(
|
|
84
|
+
currentLocation: string,
|
|
85
|
+
storage?: WorkspaceStorageLike
|
|
86
|
+
): WorkspaceState {
|
|
87
|
+
const resolved = getStorage(storage);
|
|
88
|
+
const fallbackTab: WorkspaceTab = {
|
|
89
|
+
id: createTabId(),
|
|
90
|
+
label: getLocationLabel(currentLocation),
|
|
91
|
+
location: currentLocation,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (!resolved) {
|
|
95
|
+
return { tabs: [fallbackTab], activeTabId: fallbackTab.id };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const raw = resolved.getItem(WORKSPACE_TABS_STORAGE_KEY);
|
|
100
|
+
if (!raw) {
|
|
101
|
+
return { tabs: [fallbackTab], activeTabId: fallbackTab.id };
|
|
102
|
+
}
|
|
103
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
104
|
+
if (!parsed || typeof parsed !== "object") {
|
|
105
|
+
return { tabs: [fallbackTab], activeTabId: fallbackTab.id };
|
|
106
|
+
}
|
|
107
|
+
const candidate = parsed as Record<string, unknown>;
|
|
108
|
+
const tabs = Array.isArray(candidate.tabs)
|
|
109
|
+
? candidate.tabs.filter(isWorkspaceTab)
|
|
110
|
+
: [];
|
|
111
|
+
const activeTabId =
|
|
112
|
+
typeof candidate.activeTabId === "string" ? candidate.activeTabId : "";
|
|
113
|
+
if (tabs.length === 0) {
|
|
114
|
+
return { tabs: [fallbackTab], activeTabId: fallbackTab.id };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const explicitLocation = currentLocation !== "/";
|
|
118
|
+
if (!explicitLocation) {
|
|
119
|
+
return {
|
|
120
|
+
tabs,
|
|
121
|
+
activeTabId: tabs.some((tab) => tab.id === activeTabId)
|
|
122
|
+
? activeTabId
|
|
123
|
+
: tabs[0]!.id,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const existing = tabs.find((tab) => tab.location === currentLocation);
|
|
128
|
+
if (existing) {
|
|
129
|
+
return { tabs, activeTabId: existing.id };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const nextTabs = [
|
|
133
|
+
...tabs,
|
|
134
|
+
{
|
|
135
|
+
id: createTabId(),
|
|
136
|
+
label: getLocationLabel(currentLocation),
|
|
137
|
+
location: currentLocation,
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
return {
|
|
141
|
+
tabs: nextTabs,
|
|
142
|
+
activeTabId: nextTabs.at(-1)?.id ?? fallbackTab.id,
|
|
143
|
+
};
|
|
144
|
+
} catch {
|
|
145
|
+
return { tabs: [fallbackTab], activeTabId: fallbackTab.id };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function saveWorkspaceState(
|
|
150
|
+
state: WorkspaceState,
|
|
151
|
+
storage?: WorkspaceStorageLike
|
|
152
|
+
): void {
|
|
153
|
+
const resolved = getStorage(storage);
|
|
154
|
+
if (!resolved) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
resolved.setItem(WORKSPACE_TABS_STORAGE_KEY, JSON.stringify(state));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function updateActiveTabLocation(
|
|
161
|
+
state: WorkspaceState,
|
|
162
|
+
location: string
|
|
163
|
+
): WorkspaceState {
|
|
164
|
+
const nextTabs = state.tabs.map((tab) =>
|
|
165
|
+
tab.id === state.activeTabId
|
|
166
|
+
? { ...tab, location, label: getLocationLabel(location) }
|
|
167
|
+
: tab
|
|
168
|
+
);
|
|
169
|
+
return {
|
|
170
|
+
tabs: nextTabs,
|
|
171
|
+
activeTabId: state.activeTabId,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function createWorkspaceTab(
|
|
176
|
+
state: WorkspaceState,
|
|
177
|
+
location: string
|
|
178
|
+
): WorkspaceState {
|
|
179
|
+
const tab: WorkspaceTab = {
|
|
180
|
+
id: createTabId(),
|
|
181
|
+
label: getLocationLabel(location),
|
|
182
|
+
location,
|
|
183
|
+
};
|
|
184
|
+
return {
|
|
185
|
+
tabs: [...state.tabs, tab],
|
|
186
|
+
activeTabId: tab.id,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function activateWorkspaceTab(
|
|
191
|
+
state: WorkspaceState,
|
|
192
|
+
tabId: string
|
|
193
|
+
): WorkspaceState {
|
|
194
|
+
return {
|
|
195
|
+
tabs: state.tabs,
|
|
196
|
+
activeTabId: tabId,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function closeWorkspaceTab(
|
|
201
|
+
state: WorkspaceState,
|
|
202
|
+
tabId: string
|
|
203
|
+
): WorkspaceState {
|
|
204
|
+
const index = state.tabs.findIndex((tab) => tab.id === tabId);
|
|
205
|
+
if (index === -1) {
|
|
206
|
+
return state;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const nextTabs = state.tabs.filter((tab) => tab.id !== tabId);
|
|
210
|
+
if (nextTabs.length === 0) {
|
|
211
|
+
const fallback = {
|
|
212
|
+
id: createTabId(),
|
|
213
|
+
label: "Home",
|
|
214
|
+
location: "/",
|
|
215
|
+
};
|
|
216
|
+
return {
|
|
217
|
+
tabs: [fallback],
|
|
218
|
+
activeTabId: fallback.id,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (state.activeTabId !== tabId) {
|
|
223
|
+
return {
|
|
224
|
+
tabs: nextTabs,
|
|
225
|
+
activeTabId: state.activeTabId,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const nextActive =
|
|
230
|
+
nextTabs[index - 1] ?? nextTabs[index] ?? nextTabs[nextTabs.length - 1]!;
|
|
231
|
+
return {
|
|
232
|
+
tabs: nextTabs,
|
|
233
|
+
activeTabId: nextActive.id,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
ChevronDown,
|
|
5
5
|
CornerDownLeft,
|
|
6
6
|
FileText,
|
|
7
|
+
HomeIcon,
|
|
7
8
|
SlidersHorizontal,
|
|
8
9
|
Sparkles,
|
|
9
10
|
XIcon,
|
|
@@ -184,7 +185,7 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
184
185
|
const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
|
|
185
186
|
const [collections, setCollections] = useState<Collection[]>([]);
|
|
186
187
|
const [thoroughness, setThoroughness] = useState<Thoroughness>("balanced");
|
|
187
|
-
const [activePreset, setActivePreset] = useState("slim");
|
|
188
|
+
const [activePreset, setActivePreset] = useState("slim-tuned");
|
|
188
189
|
|
|
189
190
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
190
191
|
const [selectedCollection, setSelectedCollection] = useState("");
|
|
@@ -448,6 +449,15 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
448
449
|
<div className="flex h-dvh min-h-0 flex-col overflow-hidden">
|
|
449
450
|
<header className="glass sticky top-0 z-10 border-border/50 border-b">
|
|
450
451
|
<div className="flex items-center gap-4 px-8 py-4">
|
|
452
|
+
<Button
|
|
453
|
+
className="gap-2 text-primary"
|
|
454
|
+
onClick={() => navigate("/")}
|
|
455
|
+
size="sm"
|
|
456
|
+
variant="ghost"
|
|
457
|
+
>
|
|
458
|
+
<HomeIcon className="size-4" />
|
|
459
|
+
GNO
|
|
460
|
+
</Button>
|
|
451
461
|
<Button
|
|
452
462
|
className="gap-2"
|
|
453
463
|
onClick={() => navigate(-1)}
|
|
@@ -3,7 +3,9 @@ import {
|
|
|
3
3
|
ChevronRight,
|
|
4
4
|
FileText,
|
|
5
5
|
FolderOpen,
|
|
6
|
+
HomeIcon,
|
|
6
7
|
RefreshCw,
|
|
8
|
+
StarIcon,
|
|
7
9
|
} from "lucide-react";
|
|
8
10
|
import { Fragment, useEffect, useState } from "react";
|
|
9
11
|
|
|
@@ -28,6 +30,12 @@ import {
|
|
|
28
30
|
} from "../components/ui/table";
|
|
29
31
|
import { apiFetch } from "../hooks/use-api";
|
|
30
32
|
import { useDocEvents } from "../hooks/use-doc-events";
|
|
33
|
+
import {
|
|
34
|
+
loadFavoriteCollections,
|
|
35
|
+
loadFavoriteDocuments,
|
|
36
|
+
toggleFavoriteCollection,
|
|
37
|
+
toggleFavoriteDocument,
|
|
38
|
+
} from "../lib/navigation-state";
|
|
31
39
|
|
|
32
40
|
interface PageProps {
|
|
33
41
|
navigate: (to: string | number) => void;
|
|
@@ -78,6 +86,8 @@ export default function Browse({ navigate }: PageProps) {
|
|
|
78
86
|
const [syncTarget, setSyncTarget] = useState<SyncTarget>(null);
|
|
79
87
|
const [syncError, setSyncError] = useState<string | null>(null);
|
|
80
88
|
const [refreshToken, setRefreshToken] = useState(0);
|
|
89
|
+
const [favoriteDocHrefs, setFavoriteDocHrefs] = useState<string[]>([]);
|
|
90
|
+
const [favoriteCollections, setFavoriteCollections] = useState<string[]>([]);
|
|
81
91
|
const latestDocEvent = useDocEvents();
|
|
82
92
|
const limit = 25;
|
|
83
93
|
|
|
@@ -96,6 +106,10 @@ export default function Browse({ navigate }: PageProps) {
|
|
|
96
106
|
setCollections(data);
|
|
97
107
|
}
|
|
98
108
|
});
|
|
109
|
+
setFavoriteDocHrefs(loadFavoriteDocuments().map((entry) => entry.href));
|
|
110
|
+
setFavoriteCollections(
|
|
111
|
+
loadFavoriteCollections().map((entry) => entry.name)
|
|
112
|
+
);
|
|
99
113
|
}, []);
|
|
100
114
|
|
|
101
115
|
useEffect(() => {
|
|
@@ -237,6 +251,15 @@ export default function Browse({ navigate }: PageProps) {
|
|
|
237
251
|
<header className="glass sticky top-0 z-10 border-border/50 border-b">
|
|
238
252
|
<div className="flex flex-wrap items-center justify-between gap-4 px-8 py-4">
|
|
239
253
|
<div className="flex items-center gap-4">
|
|
254
|
+
<Button
|
|
255
|
+
className="gap-2 text-primary"
|
|
256
|
+
onClick={() => navigate("/")}
|
|
257
|
+
size="sm"
|
|
258
|
+
variant="ghost"
|
|
259
|
+
>
|
|
260
|
+
<HomeIcon className="size-4" />
|
|
261
|
+
GNO
|
|
262
|
+
</Button>
|
|
240
263
|
<Button
|
|
241
264
|
className="gap-2"
|
|
242
265
|
onClick={() => navigate(-1)}
|
|
@@ -300,6 +323,31 @@ export default function Browse({ navigate }: PageProps) {
|
|
|
300
323
|
<FolderOpen className="size-4" />
|
|
301
324
|
Collections
|
|
302
325
|
</Button>
|
|
326
|
+
{selected && (
|
|
327
|
+
<Button
|
|
328
|
+
className="gap-2"
|
|
329
|
+
onClick={() =>
|
|
330
|
+
setFavoriteCollections(
|
|
331
|
+
toggleFavoriteCollection({
|
|
332
|
+
name: selected,
|
|
333
|
+
href: `/browse?collection=${encodeURIComponent(selected)}`,
|
|
334
|
+
label: selected,
|
|
335
|
+
}).map((entry) => entry.name)
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
size="sm"
|
|
339
|
+
variant="outline"
|
|
340
|
+
>
|
|
341
|
+
<StarIcon
|
|
342
|
+
className={`size-4 ${
|
|
343
|
+
favoriteCollections.includes(selected)
|
|
344
|
+
? "fill-current text-secondary"
|
|
345
|
+
: ""
|
|
346
|
+
}`}
|
|
347
|
+
/>
|
|
348
|
+
{favoriteCollections.includes(selected) ? "Pinned" : "Pin"}
|
|
349
|
+
</Button>
|
|
350
|
+
)}
|
|
303
351
|
<Button
|
|
304
352
|
className="gap-2"
|
|
305
353
|
disabled={Boolean(syncJobId)}
|
|
@@ -423,6 +471,31 @@ export default function Browse({ navigate }: PageProps) {
|
|
|
423
471
|
{doc.relPath}
|
|
424
472
|
</div>
|
|
425
473
|
</div>
|
|
474
|
+
<Button
|
|
475
|
+
onClick={(event) => {
|
|
476
|
+
event.stopPropagation();
|
|
477
|
+
const next = toggleFavoriteDocument({
|
|
478
|
+
uri: doc.uri,
|
|
479
|
+
href: `/doc?uri=${encodeURIComponent(doc.uri)}`,
|
|
480
|
+
label: doc.title || doc.relPath,
|
|
481
|
+
});
|
|
482
|
+
setFavoriteDocHrefs(
|
|
483
|
+
next.map((entry) => entry.href)
|
|
484
|
+
);
|
|
485
|
+
}}
|
|
486
|
+
size="icon-sm"
|
|
487
|
+
variant="ghost"
|
|
488
|
+
>
|
|
489
|
+
<StarIcon
|
|
490
|
+
className={`size-4 ${
|
|
491
|
+
favoriteDocHrefs.includes(
|
|
492
|
+
`/doc?uri=${encodeURIComponent(doc.uri)}`
|
|
493
|
+
)
|
|
494
|
+
? "fill-current text-secondary"
|
|
495
|
+
: "text-muted-foreground"
|
|
496
|
+
}`}
|
|
497
|
+
/>
|
|
498
|
+
</Button>
|
|
426
499
|
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
|
427
500
|
</div>
|
|
428
501
|
</TableCell>
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
FolderIcon,
|
|
18
18
|
FolderMinusIcon,
|
|
19
19
|
FolderPlusIcon,
|
|
20
|
+
HomeIcon,
|
|
20
21
|
LayersIcon,
|
|
21
22
|
Loader2Icon,
|
|
22
23
|
MoreVerticalIcon,
|
|
@@ -24,8 +25,11 @@ import {
|
|
|
24
25
|
} from "lucide-react";
|
|
25
26
|
import { useCallback, useEffect, useState } from "react";
|
|
26
27
|
|
|
28
|
+
import type { AppStatusResponse } from "../../status-model";
|
|
29
|
+
|
|
27
30
|
import { AddCollectionDialog } from "../components/AddCollectionDialog";
|
|
28
31
|
import { Loader } from "../components/ai-elements/loader";
|
|
32
|
+
import { CollectionsEmptyState } from "../components/CollectionsEmptyState";
|
|
29
33
|
import { IndexingProgress } from "../components/IndexingProgress";
|
|
30
34
|
import { Badge } from "../components/ui/badge";
|
|
31
35
|
import { Button } from "../components/ui/button";
|
|
@@ -75,6 +79,7 @@ interface StatusResponse {
|
|
|
75
79
|
totalDocuments: number;
|
|
76
80
|
lastUpdated: string | null;
|
|
77
81
|
healthy: boolean;
|
|
82
|
+
onboarding: AppStatusResponse["onboarding"];
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
interface SyncResponse {
|
|
@@ -249,6 +254,9 @@ function CollectionCard({
|
|
|
249
254
|
|
|
250
255
|
export default function Collections({ navigate }: PageProps) {
|
|
251
256
|
const [collections, setCollections] = useState<CollectionStats[]>([]);
|
|
257
|
+
const [onboarding, setOnboarding] = useState<
|
|
258
|
+
AppStatusResponse["onboarding"] | null
|
|
259
|
+
>(null);
|
|
252
260
|
const [loading, setLoading] = useState(true);
|
|
253
261
|
const [error, setError] = useState<string | null>(null);
|
|
254
262
|
const [refreshing, setRefreshing] = useState(false);
|
|
@@ -260,6 +268,9 @@ export default function Collections({ navigate }: PageProps) {
|
|
|
260
268
|
);
|
|
261
269
|
const [removing, setRemoving] = useState(false);
|
|
262
270
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
|
271
|
+
const [initialCollectionPath, setInitialCollectionPath] = useState<
|
|
272
|
+
string | undefined
|
|
273
|
+
>(undefined);
|
|
263
274
|
|
|
264
275
|
const loadCollections = useCallback(async () => {
|
|
265
276
|
const { data, error: err } = await apiFetch<StatusResponse>("/api/status");
|
|
@@ -267,6 +278,7 @@ export default function Collections({ navigate }: PageProps) {
|
|
|
267
278
|
setError(err);
|
|
268
279
|
} else if (data) {
|
|
269
280
|
setCollections(data.collections);
|
|
281
|
+
setOnboarding(data.onboarding);
|
|
270
282
|
setError(null);
|
|
271
283
|
}
|
|
272
284
|
}, []);
|
|
@@ -340,6 +352,15 @@ export default function Collections({ navigate }: PageProps) {
|
|
|
340
352
|
<div className="flex flex-wrap items-start justify-between gap-4 px-8 py-4">
|
|
341
353
|
<div className="space-y-2">
|
|
342
354
|
<div className="flex flex-wrap items-center gap-3">
|
|
355
|
+
<Button
|
|
356
|
+
className="gap-2 text-primary"
|
|
357
|
+
onClick={() => navigate("/")}
|
|
358
|
+
size="sm"
|
|
359
|
+
variant="ghost"
|
|
360
|
+
>
|
|
361
|
+
<HomeIcon className="size-4" />
|
|
362
|
+
GNO
|
|
363
|
+
</Button>
|
|
343
364
|
<Button
|
|
344
365
|
className="gap-2"
|
|
345
366
|
onClick={() => navigate(-1)}
|
|
@@ -452,19 +473,13 @@ export default function Collections({ navigate }: PageProps) {
|
|
|
452
473
|
|
|
453
474
|
{/* Empty state */}
|
|
454
475
|
{!error && collections.length === 0 && (
|
|
455
|
-
<
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
</p>
|
|
463
|
-
<Button onClick={() => setAddDialogOpen(true)}>
|
|
464
|
-
<FolderPlusIcon className="mr-2 size-4" />
|
|
465
|
-
Add Collection
|
|
466
|
-
</Button>
|
|
467
|
-
</div>
|
|
476
|
+
<CollectionsEmptyState
|
|
477
|
+
onAddCollection={(path) => {
|
|
478
|
+
setInitialCollectionPath(path);
|
|
479
|
+
setAddDialogOpen(true);
|
|
480
|
+
}}
|
|
481
|
+
suggestedCollections={onboarding?.suggestedCollections ?? []}
|
|
482
|
+
/>
|
|
468
483
|
)}
|
|
469
484
|
|
|
470
485
|
{/* Collections grid */}
|
|
@@ -495,6 +510,7 @@ export default function Collections({ navigate }: PageProps) {
|
|
|
495
510
|
|
|
496
511
|
{/* Add collection dialog */}
|
|
497
512
|
<AddCollectionDialog
|
|
513
|
+
initialPath={initialCollectionPath}
|
|
498
514
|
onOpenChange={setAddDialogOpen}
|
|
499
515
|
onSuccess={() => void loadCollections()}
|
|
500
516
|
open={addDialogOpen}
|