@gmickel/gno 0.33.4 → 0.34.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 +12 -1
- package/package.json +1 -1
- package/src/serve/browse-tree.ts +201 -0
- package/src/serve/public/app.tsx +38 -14
- package/src/serve/public/components/BacklinksPanel.tsx +10 -10
- package/src/serve/public/components/BrowseDetailPane.tsx +194 -0
- package/src/serve/public/components/BrowseOverview.tsx +81 -0
- package/src/serve/public/components/BrowseTreeSidebar.tsx +281 -0
- package/src/serve/public/components/BrowseWorkspaceCard.tsx +96 -0
- package/src/serve/public/components/FrontmatterDisplay.tsx +164 -65
- package/src/serve/public/components/OutgoingLinksPanel.tsx +8 -12
- package/src/serve/public/components/RelatedNotesSidebar.tsx +42 -76
- package/src/serve/public/components/editor/MarkdownPreview.tsx +61 -2
- package/src/serve/public/components/ui/button.tsx +1 -1
- package/src/serve/public/components/ui/dialog.tsx +1 -1
- package/src/serve/public/hooks/useWorkspace.tsx +38 -0
- package/src/serve/public/lib/browse.ts +110 -0
- package/src/serve/public/lib/workspace-tabs.ts +59 -1
- package/src/serve/public/pages/Browse.tsx +334 -344
- package/src/serve/public/pages/DocView.tsx +484 -296
- package/src/serve/public/pages/DocumentEditor.tsx +1 -11
- package/src/serve/public/pages/GraphView.tsx +5 -4
- package/src/serve/routes/api.ts +47 -0
- package/src/serve/server.ts +5 -0
- package/src/store/sqlite/adapter.ts +240 -241
package/README.md
CHANGED
|
@@ -425,7 +425,7 @@ gno serve --port 8080 # Custom port
|
|
|
425
425
|
Open `http://localhost:3000` to:
|
|
426
426
|
|
|
427
427
|
- **Search**: BM25, vector, or hybrid modes with visual results
|
|
428
|
-
- **Browse**:
|
|
428
|
+
- **Browse**: Cross-collection tree workspace with folder detail panes and per-tab browse context
|
|
429
429
|
- **Edit**: Create, edit, and delete documents with live preview
|
|
430
430
|
- **Ask**: AI-powered Q&A with citations
|
|
431
431
|
- **Manage Collections**: Add, remove, and re-index collections
|
|
@@ -459,6 +459,17 @@ Full-featured markdown editor with:
|
|
|
459
459
|
|
|
460
460
|
View documents with full context: outgoing links, backlinks, and AI-powered related notes sidebar.
|
|
461
461
|
|
|
462
|
+
### Browse Workspace
|
|
463
|
+
|
|
464
|
+

|
|
465
|
+
|
|
466
|
+
Navigate your notes like a real workspace, not just a flat list:
|
|
467
|
+
|
|
468
|
+
- Cross-collection tree sidebar
|
|
469
|
+
- Folder detail panes
|
|
470
|
+
- Pinned collections and per-tab browse state
|
|
471
|
+
- Direct jump from folder structure into notes
|
|
472
|
+
|
|
462
473
|
### Knowledge Graph
|
|
463
474
|
|
|
464
475
|

|
package/package.json
CHANGED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { CollectionRow, DocumentRow } from "../store/types";
|
|
2
|
+
|
|
3
|
+
export interface BrowseTreeNode {
|
|
4
|
+
id: string;
|
|
5
|
+
kind: "collection" | "folder";
|
|
6
|
+
collection: string;
|
|
7
|
+
path: string;
|
|
8
|
+
name: string;
|
|
9
|
+
depth: number;
|
|
10
|
+
documentCount: number;
|
|
11
|
+
directDocumentCount: number;
|
|
12
|
+
children: BrowseTreeNode[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type BrowseCollectionLike = Pick<CollectionRow, "name">;
|
|
16
|
+
type BrowseDocumentLike = Pick<
|
|
17
|
+
DocumentRow,
|
|
18
|
+
"collection" | "relPath" | "active"
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
export function normalizeBrowsePath(path?: string | null): string {
|
|
22
|
+
return (path ?? "").replaceAll("\\", "/").replace(/^\/+|\/+$/g, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createBrowseNodeId(collection: string, path = ""): string {
|
|
26
|
+
const normalizedPath = normalizeBrowsePath(path);
|
|
27
|
+
return normalizedPath
|
|
28
|
+
? `folder:${collection}:${normalizedPath}`
|
|
29
|
+
: `collection:${collection}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createNode(options: {
|
|
33
|
+
kind: BrowseTreeNode["kind"];
|
|
34
|
+
collection: string;
|
|
35
|
+
path: string;
|
|
36
|
+
name: string;
|
|
37
|
+
depth: number;
|
|
38
|
+
}): BrowseTreeNode {
|
|
39
|
+
return {
|
|
40
|
+
id: createBrowseNodeId(options.collection, options.path),
|
|
41
|
+
kind: options.kind,
|
|
42
|
+
collection: options.collection,
|
|
43
|
+
path: options.path,
|
|
44
|
+
name: options.name,
|
|
45
|
+
depth: options.depth,
|
|
46
|
+
documentCount: 0,
|
|
47
|
+
directDocumentCount: 0,
|
|
48
|
+
children: [],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sortNodes(nodes: BrowseTreeNode[]): void {
|
|
53
|
+
nodes.sort((a, b) => a.name.localeCompare(b.name));
|
|
54
|
+
for (const node of nodes) {
|
|
55
|
+
sortNodes(node.children);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildBrowseTree(
|
|
60
|
+
collections: BrowseCollectionLike[],
|
|
61
|
+
documents: BrowseDocumentLike[]
|
|
62
|
+
): BrowseTreeNode[] {
|
|
63
|
+
const roots = new Map<string, BrowseTreeNode>();
|
|
64
|
+
|
|
65
|
+
for (const collection of collections) {
|
|
66
|
+
roots.set(
|
|
67
|
+
collection.name,
|
|
68
|
+
createNode({
|
|
69
|
+
kind: "collection",
|
|
70
|
+
collection: collection.name,
|
|
71
|
+
path: "",
|
|
72
|
+
name: collection.name,
|
|
73
|
+
depth: 0,
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const doc of documents) {
|
|
79
|
+
if (!doc.active) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let root = roots.get(doc.collection);
|
|
84
|
+
if (!root) {
|
|
85
|
+
root = createNode({
|
|
86
|
+
kind: "collection",
|
|
87
|
+
collection: doc.collection,
|
|
88
|
+
path: "",
|
|
89
|
+
name: doc.collection,
|
|
90
|
+
depth: 0,
|
|
91
|
+
});
|
|
92
|
+
roots.set(doc.collection, root);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
root.documentCount += 1;
|
|
96
|
+
|
|
97
|
+
const normalizedRelPath = normalizeBrowsePath(doc.relPath);
|
|
98
|
+
const parts = normalizedRelPath.split("/").filter(Boolean);
|
|
99
|
+
const folderParts = parts.slice(0, -1);
|
|
100
|
+
|
|
101
|
+
if (folderParts.length === 0) {
|
|
102
|
+
root.directDocumentCount += 1;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let current = root;
|
|
107
|
+
let currentPath = "";
|
|
108
|
+
for (const part of folderParts) {
|
|
109
|
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
110
|
+
let next = current.children.find((child) => child.path === currentPath);
|
|
111
|
+
if (!next) {
|
|
112
|
+
next = createNode({
|
|
113
|
+
kind: "folder",
|
|
114
|
+
collection: doc.collection,
|
|
115
|
+
path: currentPath,
|
|
116
|
+
name: part,
|
|
117
|
+
depth: current.depth + 1,
|
|
118
|
+
});
|
|
119
|
+
current.children.push(next);
|
|
120
|
+
}
|
|
121
|
+
next.documentCount += 1;
|
|
122
|
+
current = next;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
current.directDocumentCount += 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const sortedRoots = [...roots.values()].sort((a, b) =>
|
|
129
|
+
a.name.localeCompare(b.name)
|
|
130
|
+
);
|
|
131
|
+
for (const root of sortedRoots) {
|
|
132
|
+
sortNodes(root.children);
|
|
133
|
+
}
|
|
134
|
+
return sortedRoots;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function findBrowseNode(
|
|
138
|
+
roots: BrowseTreeNode[],
|
|
139
|
+
collection?: string | null,
|
|
140
|
+
path?: string | null
|
|
141
|
+
): BrowseTreeNode | null {
|
|
142
|
+
if (!collection) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const root = roots.find((node) => node.collection === collection);
|
|
147
|
+
if (!root) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const normalizedPath = normalizeBrowsePath(path);
|
|
152
|
+
if (!normalizedPath) {
|
|
153
|
+
return root;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const queue = [...root.children];
|
|
157
|
+
while (queue.length > 0) {
|
|
158
|
+
const node = queue.shift();
|
|
159
|
+
if (!node) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (node.path === normalizedPath) {
|
|
163
|
+
return node;
|
|
164
|
+
}
|
|
165
|
+
queue.push(...node.children);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function getBrowseAncestorIds(
|
|
172
|
+
collection?: string | null,
|
|
173
|
+
path?: string | null
|
|
174
|
+
): string[] {
|
|
175
|
+
if (!collection) {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const normalizedPath = normalizeBrowsePath(path);
|
|
180
|
+
const ids = [createBrowseNodeId(collection)];
|
|
181
|
+
if (!normalizedPath) {
|
|
182
|
+
return ids;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const parts = normalizedPath.split("/").filter(Boolean);
|
|
186
|
+
let currentPath = "";
|
|
187
|
+
for (const part of parts) {
|
|
188
|
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
189
|
+
ids.push(createBrowseNodeId(collection, currentPath));
|
|
190
|
+
}
|
|
191
|
+
return ids;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function getImmediateChildFolders(
|
|
195
|
+
roots: BrowseTreeNode[],
|
|
196
|
+
collection?: string | null,
|
|
197
|
+
path?: string | null
|
|
198
|
+
): BrowseTreeNode[] {
|
|
199
|
+
const node = findBrowseNode(roots, collection, path);
|
|
200
|
+
return node?.children ?? [];
|
|
201
|
+
}
|
package/src/serve/public/app.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { ShortcutHelpModal } from "./components/ShortcutHelpModal";
|
|
|
7
7
|
import { WorkspaceTabs } from "./components/WorkspaceTabs";
|
|
8
8
|
import { CaptureModalProvider, useCaptureModal } from "./hooks/useCaptureModal";
|
|
9
9
|
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
|
10
|
+
import { WorkspaceProvider } from "./hooks/useWorkspace";
|
|
10
11
|
import { parseDocumentDeepLink } from "./lib/deep-links";
|
|
11
12
|
import { saveRecentDocument } from "./lib/navigation-state";
|
|
12
13
|
import {
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
loadWorkspaceState,
|
|
17
18
|
saveWorkspaceState,
|
|
18
19
|
updateActiveTabLocation,
|
|
20
|
+
updateActiveTabBrowseState,
|
|
19
21
|
type WorkspaceState,
|
|
20
22
|
} from "./lib/workspace-tabs";
|
|
21
23
|
import Ask from "./pages/Ask";
|
|
@@ -40,7 +42,12 @@ type Route =
|
|
|
40
42
|
| "/connectors";
|
|
41
43
|
type Navigate = (to: string | number) => void;
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
interface RoutePageProps {
|
|
46
|
+
navigate: Navigate;
|
|
47
|
+
location?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const routes: Record<Route, React.ComponentType<RoutePageProps>> = {
|
|
44
51
|
"/": Dashboard,
|
|
45
52
|
"/search": Search,
|
|
46
53
|
"/browse": Browse,
|
|
@@ -130,6 +137,7 @@ function AppContent({
|
|
|
130
137
|
|
|
131
138
|
const basePath = location.split("?")[0] as Route;
|
|
132
139
|
const Page = routes[basePath] || Dashboard;
|
|
140
|
+
const pageKey = basePath === "/browse" ? basePath : location;
|
|
133
141
|
|
|
134
142
|
return (
|
|
135
143
|
<>
|
|
@@ -142,7 +150,7 @@ function AppContent({
|
|
|
142
150
|
tabs={workspace.tabs}
|
|
143
151
|
/>
|
|
144
152
|
<div className="flex-1">
|
|
145
|
-
<Page key={location} navigate={navigate} />
|
|
153
|
+
<Page key={pageKey} location={location} navigate={navigate} />
|
|
146
154
|
</div>
|
|
147
155
|
<footer className="border-t border-border/30 bg-background/60 py-6 text-center text-sm backdrop-blur-sm">
|
|
148
156
|
<div className="ornament mx-auto mb-4 max-w-[8rem] text-muted-foreground/20">
|
|
@@ -293,19 +301,35 @@ function App() {
|
|
|
293
301
|
});
|
|
294
302
|
}, []);
|
|
295
303
|
|
|
304
|
+
const updateActiveBrowseState = useCallback(
|
|
305
|
+
(nextBrowseState: Parameters<typeof updateActiveTabBrowseState>[1]) => {
|
|
306
|
+
setWorkspace((current) =>
|
|
307
|
+
updateActiveTabBrowseState(current, nextBrowseState)
|
|
308
|
+
);
|
|
309
|
+
},
|
|
310
|
+
[]
|
|
311
|
+
);
|
|
312
|
+
|
|
296
313
|
return (
|
|
297
|
-
<
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
314
|
+
<WorkspaceProvider
|
|
315
|
+
value={{
|
|
316
|
+
activeTab: activeTab ?? null,
|
|
317
|
+
updateActiveTabBrowseState: updateActiveBrowseState,
|
|
318
|
+
}}
|
|
319
|
+
>
|
|
320
|
+
<CaptureModalProvider>
|
|
321
|
+
<AppContent
|
|
322
|
+
location={location}
|
|
323
|
+
navigate={navigate}
|
|
324
|
+
onActivateTab={activateTab}
|
|
325
|
+
onCloseTab={closeTab}
|
|
326
|
+
onNewTab={openNewTab}
|
|
327
|
+
setShortcutHelpOpen={setShortcutHelpOpen}
|
|
328
|
+
shortcutHelpOpen={shortcutHelpOpen}
|
|
329
|
+
workspace={workspace}
|
|
330
|
+
/>
|
|
331
|
+
</CaptureModalProvider>
|
|
332
|
+
</WorkspaceProvider>
|
|
309
333
|
);
|
|
310
334
|
}
|
|
311
335
|
|
|
@@ -247,22 +247,22 @@ export function BacklinksPanel({
|
|
|
247
247
|
|
|
248
248
|
return (
|
|
249
249
|
<Collapsible
|
|
250
|
-
className={cn("", className)}
|
|
250
|
+
className={cn("px-1", className)}
|
|
251
251
|
onOpenChange={setIsOpen}
|
|
252
252
|
open={isOpen}
|
|
253
253
|
>
|
|
254
|
-
{/* Panel header
|
|
254
|
+
{/* Panel header */}
|
|
255
255
|
<CollapsibleTrigger
|
|
256
256
|
aria-label={`${isOpen ? "Collapse" : "Expand"} backlinks panel`}
|
|
257
257
|
className={cn(
|
|
258
258
|
"group flex w-full items-center gap-2",
|
|
259
259
|
"rounded-sm px-2 py-1.5",
|
|
260
260
|
"transition-colors duration-150",
|
|
261
|
-
"hover:bg-
|
|
261
|
+
"hover:bg-muted/20"
|
|
262
262
|
)}
|
|
263
263
|
>
|
|
264
264
|
{/* Chevron */}
|
|
265
|
-
<span className="flex size-4 shrink-0 items-center justify-center text-
|
|
265
|
+
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground/50">
|
|
266
266
|
{isOpen ? (
|
|
267
267
|
<ChevronDownIcon className="size-3.5 transition-transform duration-200" />
|
|
268
268
|
) : (
|
|
@@ -270,12 +270,12 @@ export function BacklinksPanel({
|
|
|
270
270
|
)}
|
|
271
271
|
</span>
|
|
272
272
|
|
|
273
|
-
{/* Title
|
|
274
|
-
<span className="flex-1 text-left font-mono text-[
|
|
273
|
+
{/* Title */}
|
|
274
|
+
<span className="flex-1 text-left font-mono text-[10px] text-muted-foreground/60 uppercase tracking-[0.15em]">
|
|
275
275
|
Backlinks
|
|
276
276
|
</span>
|
|
277
277
|
|
|
278
|
-
{/* Count badge
|
|
278
|
+
{/* Count badge */}
|
|
279
279
|
{!loading && (
|
|
280
280
|
<span
|
|
281
281
|
className={cn(
|
|
@@ -283,7 +283,7 @@ export function BacklinksPanel({
|
|
|
283
283
|
"font-mono text-[10px] tabular-nums",
|
|
284
284
|
"transition-colors duration-150",
|
|
285
285
|
totalCount > 0
|
|
286
|
-
? "bg-
|
|
286
|
+
? "bg-primary/12 text-primary"
|
|
287
287
|
: "bg-muted/20 text-muted-foreground/60"
|
|
288
288
|
)}
|
|
289
289
|
>
|
|
@@ -291,9 +291,9 @@ export function BacklinksPanel({
|
|
|
291
291
|
</span>
|
|
292
292
|
)}
|
|
293
293
|
|
|
294
|
-
{/* Loading indicator
|
|
294
|
+
{/* Loading indicator */}
|
|
295
295
|
{loading && (
|
|
296
|
-
<span className="size-3 animate-spin rounded-full border border-
|
|
296
|
+
<span className="size-3 animate-spin rounded-full border border-muted/30 border-t-primary/60" />
|
|
297
297
|
)}
|
|
298
298
|
</CollapsibleTrigger>
|
|
299
299
|
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { ChevronRight, FileText, FolderOpen, StarIcon } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import type { BrowseTreeNode } from "../../browse-tree";
|
|
4
|
+
import type { BrowseDocument } from "../lib/browse";
|
|
5
|
+
|
|
6
|
+
import { getExtBadgeVariant } from "../lib/browse";
|
|
7
|
+
import { Badge } from "./ui/badge";
|
|
8
|
+
import { Button } from "./ui/button";
|
|
9
|
+
import { Card, CardContent } from "./ui/card";
|
|
10
|
+
import {
|
|
11
|
+
Table,
|
|
12
|
+
TableBody,
|
|
13
|
+
TableCell,
|
|
14
|
+
TableHead,
|
|
15
|
+
TableHeader,
|
|
16
|
+
TableRow,
|
|
17
|
+
} from "./ui/table";
|
|
18
|
+
|
|
19
|
+
export function BrowseDetailPane({
|
|
20
|
+
childFolders,
|
|
21
|
+
docs,
|
|
22
|
+
docsLoading,
|
|
23
|
+
favoriteDocHrefs,
|
|
24
|
+
onLoadMore,
|
|
25
|
+
onOpenDoc,
|
|
26
|
+
onSelectCollection,
|
|
27
|
+
onSelectFolder,
|
|
28
|
+
onToggleFavoriteDocument,
|
|
29
|
+
selectedNode,
|
|
30
|
+
selectedPath,
|
|
31
|
+
total,
|
|
32
|
+
}: {
|
|
33
|
+
childFolders: BrowseTreeNode[];
|
|
34
|
+
docs: BrowseDocument[];
|
|
35
|
+
docsLoading: boolean;
|
|
36
|
+
favoriteDocHrefs: string[];
|
|
37
|
+
onLoadMore: () => void;
|
|
38
|
+
onOpenDoc: (uri: string) => void;
|
|
39
|
+
onSelectCollection: (collection: string) => void;
|
|
40
|
+
onSelectFolder: (collection: string, path?: string) => void;
|
|
41
|
+
onToggleFavoriteDocument: (doc: BrowseDocument) => void;
|
|
42
|
+
selectedNode: BrowseTreeNode | null;
|
|
43
|
+
selectedPath: string;
|
|
44
|
+
total: number;
|
|
45
|
+
}) {
|
|
46
|
+
if (docsLoading && docs.length === 0) {
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex flex-col items-center justify-center gap-4 py-20">
|
|
49
|
+
<div className="text-muted-foreground">Loading folder contents...</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (docs.length === 0 && childFolders.length === 0) {
|
|
55
|
+
return (
|
|
56
|
+
<div className="py-20 text-center">
|
|
57
|
+
<FileText className="mx-auto mb-4 size-12 text-muted-foreground" />
|
|
58
|
+
<h3 className="mb-2 font-medium text-lg">No documents found</h3>
|
|
59
|
+
<p className="text-muted-foreground">
|
|
60
|
+
{selectedPath
|
|
61
|
+
? "This folder is empty"
|
|
62
|
+
: "This collection root has no documents yet"}
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="space-y-6">
|
|
70
|
+
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
71
|
+
{childFolders.map((folder) => (
|
|
72
|
+
<Card key={folder.id}>
|
|
73
|
+
<CardContent className="space-y-3 py-4">
|
|
74
|
+
<div className="flex items-start justify-between gap-3">
|
|
75
|
+
<div className="min-w-0">
|
|
76
|
+
<div className="truncate font-medium">{folder.name}</div>
|
|
77
|
+
<div className="text-muted-foreground text-sm">
|
|
78
|
+
{folder.documentCount} docs
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
<FolderOpen className="size-4 text-muted-foreground" />
|
|
82
|
+
</div>
|
|
83
|
+
<Button
|
|
84
|
+
className="w-full justify-between"
|
|
85
|
+
onClick={() => onSelectFolder(folder.collection, folder.path)}
|
|
86
|
+
variant="outline"
|
|
87
|
+
>
|
|
88
|
+
Open folder
|
|
89
|
+
<ChevronRight className="size-4" />
|
|
90
|
+
</Button>
|
|
91
|
+
</CardContent>
|
|
92
|
+
</Card>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div className="space-y-4">
|
|
97
|
+
<div className="flex items-center justify-between">
|
|
98
|
+
<div className="text-muted-foreground text-sm">
|
|
99
|
+
{selectedNode?.directDocumentCount ?? docs.length} direct docs
|
|
100
|
+
</div>
|
|
101
|
+
<div className="text-muted-foreground text-sm">
|
|
102
|
+
{selectedNode?.documentCount ?? total} total docs in scope
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{docs.length > 0 && (
|
|
107
|
+
<Table className="table-fixed">
|
|
108
|
+
<TableHeader>
|
|
109
|
+
<TableRow>
|
|
110
|
+
<TableHead className="w-[68%]">Document</TableHead>
|
|
111
|
+
<TableHead className="w-[220px]">Collection</TableHead>
|
|
112
|
+
<TableHead className="w-[72px] text-right">Type</TableHead>
|
|
113
|
+
</TableRow>
|
|
114
|
+
</TableHeader>
|
|
115
|
+
<TableBody>
|
|
116
|
+
{docs.map((doc) => (
|
|
117
|
+
<TableRow
|
|
118
|
+
className="group cursor-pointer"
|
|
119
|
+
key={doc.docid}
|
|
120
|
+
onClick={() => onOpenDoc(doc.uri)}
|
|
121
|
+
>
|
|
122
|
+
<TableCell className="align-top whitespace-normal">
|
|
123
|
+
<div className="flex items-center gap-2">
|
|
124
|
+
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
|
125
|
+
<div className="min-w-0">
|
|
126
|
+
<div className="break-words font-medium leading-tight transition-colors group-hover:text-primary">
|
|
127
|
+
{doc.title || doc.relPath}
|
|
128
|
+
</div>
|
|
129
|
+
<div className="break-all font-mono text-muted-foreground text-xs leading-relaxed">
|
|
130
|
+
{doc.relPath}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
<Button
|
|
134
|
+
onClick={(event) => {
|
|
135
|
+
event.stopPropagation();
|
|
136
|
+
onToggleFavoriteDocument(doc);
|
|
137
|
+
}}
|
|
138
|
+
size="icon-sm"
|
|
139
|
+
variant="ghost"
|
|
140
|
+
>
|
|
141
|
+
<StarIcon
|
|
142
|
+
className={`size-4 ${
|
|
143
|
+
favoriteDocHrefs.includes(
|
|
144
|
+
`/doc?uri=${encodeURIComponent(doc.uri)}`
|
|
145
|
+
)
|
|
146
|
+
? "fill-current text-secondary"
|
|
147
|
+
: "text-muted-foreground"
|
|
148
|
+
}`}
|
|
149
|
+
/>
|
|
150
|
+
</Button>
|
|
151
|
+
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
|
152
|
+
</div>
|
|
153
|
+
</TableCell>
|
|
154
|
+
<TableCell className="align-top whitespace-normal">
|
|
155
|
+
<Badge
|
|
156
|
+
className="inline-flex min-h-[2.5rem] max-w-[180px] cursor-pointer items-center px-3 py-1 text-center whitespace-normal break-words font-mono text-xs leading-tight transition-colors hover:border-primary hover:text-primary"
|
|
157
|
+
onClick={(event) => {
|
|
158
|
+
event.stopPropagation();
|
|
159
|
+
onSelectCollection(doc.collection);
|
|
160
|
+
}}
|
|
161
|
+
variant="outline"
|
|
162
|
+
>
|
|
163
|
+
{doc.collection}
|
|
164
|
+
</Badge>
|
|
165
|
+
</TableCell>
|
|
166
|
+
<TableCell className="align-top text-right">
|
|
167
|
+
<Badge
|
|
168
|
+
className="font-mono text-xs"
|
|
169
|
+
variant={getExtBadgeVariant(doc.sourceExt)}
|
|
170
|
+
>
|
|
171
|
+
{doc.sourceExt}
|
|
172
|
+
</Badge>
|
|
173
|
+
</TableCell>
|
|
174
|
+
</TableRow>
|
|
175
|
+
))}
|
|
176
|
+
</TableBody>
|
|
177
|
+
</Table>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{docs.length < total && (
|
|
181
|
+
<div className="flex justify-center pt-4">
|
|
182
|
+
<Button
|
|
183
|
+
disabled={docsLoading}
|
|
184
|
+
onClick={onLoadMore}
|
|
185
|
+
variant="outline"
|
|
186
|
+
>
|
|
187
|
+
{docsLoading ? "Loading..." : "Load More"}
|
|
188
|
+
</Button>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ChevronRight, FolderOpen, StarIcon } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import type { BrowseTreeNode } from "../../browse-tree";
|
|
4
|
+
|
|
5
|
+
import { Badge } from "./ui/badge";
|
|
6
|
+
import { Button } from "./ui/button";
|
|
7
|
+
import { Card, CardContent } from "./ui/card";
|
|
8
|
+
|
|
9
|
+
export function BrowseOverview({
|
|
10
|
+
collections,
|
|
11
|
+
favoriteCollections,
|
|
12
|
+
onSelectCollection,
|
|
13
|
+
onToggleFavoriteCollection,
|
|
14
|
+
}: {
|
|
15
|
+
collections: BrowseTreeNode[];
|
|
16
|
+
favoriteCollections: string[];
|
|
17
|
+
onSelectCollection: (collection: string) => void;
|
|
18
|
+
onToggleFavoriteCollection: (collection: string) => void;
|
|
19
|
+
}) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="space-y-4">
|
|
22
|
+
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
23
|
+
{collections.map((collection) => (
|
|
24
|
+
<Card key={collection.id}>
|
|
25
|
+
<CardContent className="space-y-4 py-5">
|
|
26
|
+
<div className="flex items-start justify-between gap-3">
|
|
27
|
+
<div>
|
|
28
|
+
<h2 className="font-semibold text-lg">{collection.name}</h2>
|
|
29
|
+
<p className="text-muted-foreground text-sm">
|
|
30
|
+
{collection.documentCount} indexed docs
|
|
31
|
+
</p>
|
|
32
|
+
</div>
|
|
33
|
+
<Button
|
|
34
|
+
onClick={() =>
|
|
35
|
+
onToggleFavoriteCollection(collection.collection)
|
|
36
|
+
}
|
|
37
|
+
size="icon-sm"
|
|
38
|
+
variant="ghost"
|
|
39
|
+
>
|
|
40
|
+
<StarIcon
|
|
41
|
+
className={`size-4 ${
|
|
42
|
+
favoriteCollections.includes(collection.collection)
|
|
43
|
+
? "fill-current text-secondary"
|
|
44
|
+
: "text-muted-foreground"
|
|
45
|
+
}`}
|
|
46
|
+
/>
|
|
47
|
+
</Button>
|
|
48
|
+
</div>
|
|
49
|
+
<div className="flex flex-wrap gap-2">
|
|
50
|
+
<Badge variant="outline">
|
|
51
|
+
{collection.children.length} folders
|
|
52
|
+
</Badge>
|
|
53
|
+
<Badge variant="outline">
|
|
54
|
+
{collection.directDocumentCount} root docs
|
|
55
|
+
</Badge>
|
|
56
|
+
</div>
|
|
57
|
+
<Button
|
|
58
|
+
className="w-full justify-between"
|
|
59
|
+
onClick={() => onSelectCollection(collection.collection)}
|
|
60
|
+
variant="outline"
|
|
61
|
+
>
|
|
62
|
+
Open collection
|
|
63
|
+
<ChevronRight className="size-4" />
|
|
64
|
+
</Button>
|
|
65
|
+
</CardContent>
|
|
66
|
+
</Card>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
{collections.length === 0 && (
|
|
71
|
+
<div className="py-20 text-center">
|
|
72
|
+
<FolderOpen className="mx-auto mb-4 size-12 text-muted-foreground" />
|
|
73
|
+
<h3 className="mb-2 font-medium text-lg">No collections yet</h3>
|
|
74
|
+
<p className="text-muted-foreground">
|
|
75
|
+
Add a collection to populate the workspace tree.
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|