@gmickel/gno 0.33.4 → 0.34.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 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**: Paginated document list, filter by collection
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
+ ![GNO Collections](./assets/screenshots/webui-collections.jpg)
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
  ![GNO Knowledge Graph](./assets/screenshots/webui-graph.jpg)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.33.4",
3
+ "version": "0.34.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -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
+ }
@@ -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
- const routes: Record<Route, React.ComponentType<{ navigate: Navigate }>> = {
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
- <CaptureModalProvider>
298
- <AppContent
299
- location={location}
300
- navigate={navigate}
301
- onActivateTab={activateTab}
302
- onCloseTab={closeTab}
303
- onNewTab={openNewTab}
304
- setShortcutHelpOpen={setShortcutHelpOpen}
305
- shortcutHelpOpen={shortcutHelpOpen}
306
- workspace={workspace}
307
- />
308
- </CaptureModalProvider>
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 - drawer handle aesthetic */}
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-[#4db8a8]/5"
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-[#d4a053]/50">
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 - brass label */}
274
- <span className="flex-1 text-left font-mono text-[11px] text-[#d4a053]/70 uppercase tracking-wider">
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 - teal for active, muted otherwise */}
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-[#4db8a8]/15 text-[#4db8a8]"
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 - teal spinner */}
294
+ {/* Loading indicator */}
295
295
  {loading && (
296
- <span className="size-3 animate-spin rounded-full border border-[#d4a053]/20 border-t-[#4db8a8]/60" />
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
+ }