@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.
@@ -1,18 +1,31 @@
1
1
  import {
2
2
  ArrowLeft,
3
- ChevronRight,
4
- FileText,
5
3
  FolderOpen,
6
4
  HomeIcon,
7
5
  RefreshCw,
8
6
  StarIcon,
9
7
  } from "lucide-react";
10
- import { Fragment, useEffect, useState } from "react";
8
+ import { useEffect, useMemo, useState } from "react";
11
9
 
10
+ import {
11
+ createBrowseNodeId,
12
+ findBrowseNode,
13
+ getBrowseAncestorIds,
14
+ getImmediateChildFolders,
15
+ } from "../../browse-tree";
12
16
  import { Loader } from "../components/ai-elements/loader";
13
- import { IndexingProgress } from "../components/IndexingProgress";
17
+ import { BrowseDetailPane } from "../components/BrowseDetailPane";
18
+ import { BrowseOverview } from "../components/BrowseOverview";
19
+ import { BrowseTreeSidebar } from "../components/BrowseTreeSidebar";
20
+ import { BrowseWorkspaceCard } from "../components/BrowseWorkspaceCard";
14
21
  import { Badge } from "../components/ui/badge";
15
22
  import { Button } from "../components/ui/button";
23
+ import {
24
+ Dialog,
25
+ DialogContent,
26
+ DialogHeader,
27
+ DialogTitle,
28
+ } from "../components/ui/dialog";
16
29
  import {
17
30
  Select,
18
31
  SelectContent,
@@ -20,16 +33,18 @@ import {
20
33
  SelectTrigger,
21
34
  SelectValue,
22
35
  } from "../components/ui/select";
23
- import {
24
- Table,
25
- TableBody,
26
- TableCell,
27
- TableHead,
28
- TableHeader,
29
- TableRow,
30
- } from "../components/ui/table";
31
36
  import { apiFetch } from "../hooks/use-api";
32
37
  import { useDocEvents } from "../hooks/use-doc-events";
38
+ import { useWorkspace } from "../hooks/useWorkspace";
39
+ import {
40
+ buildBrowseCrumbs,
41
+ buildBrowseLocation,
42
+ formatDateFieldLabel,
43
+ parseBrowseLocation,
44
+ type BrowseDocument,
45
+ type BrowseTreeResponse,
46
+ type DocsResponse,
47
+ } from "../lib/browse";
33
48
  import {
34
49
  loadFavoriteCollections,
35
50
  loadFavoriteDocuments,
@@ -39,30 +54,7 @@ import {
39
54
 
40
55
  interface PageProps {
41
56
  navigate: (to: string | number) => void;
42
- }
43
-
44
- interface Collection {
45
- name: string;
46
- path: string;
47
- }
48
-
49
- interface Document {
50
- docid: string;
51
- uri: string;
52
- title: string | null;
53
- collection: string;
54
- relPath: string;
55
- sourceExt: string;
56
- }
57
-
58
- interface DocsResponse {
59
- documents: Document[];
60
- total: number;
61
- limit: number;
62
- offset: number;
63
- availableDateFields: string[];
64
- sortField: string;
65
- sortOrder: "asc" | "desc";
57
+ location?: string;
66
58
  }
67
59
 
68
60
  interface SyncResponse {
@@ -71,14 +63,26 @@ interface SyncResponse {
71
63
 
72
64
  type SyncTarget = { kind: "all" } | { kind: "collection"; name: string } | null;
73
65
 
74
- export default function Browse({ navigate }: PageProps) {
75
- const [collections, setCollections] = useState<Collection[]>([]);
76
- const [selected, setSelected] = useState<string>("");
77
- const [docs, setDocs] = useState<Document[]>([]);
66
+ export default function Browse({ navigate, location }: PageProps) {
67
+ const { activeTab, updateActiveTabBrowseState } = useWorkspace();
68
+ const latestDocEvent = useDocEvents();
69
+ const resolvedLocation =
70
+ location ?? `${window.location.pathname}${window.location.search}`;
71
+ const selection = useMemo(
72
+ () =>
73
+ parseBrowseLocation(
74
+ resolvedLocation.includes("?")
75
+ ? `?${resolvedLocation.split("?")[1] ?? ""}`
76
+ : ""
77
+ ),
78
+ [resolvedLocation]
79
+ );
80
+ const [tree, setTree] = useState<BrowseTreeResponse | null>(null);
81
+ const [docs, setDocs] = useState<BrowseDocument[]>([]);
78
82
  const [total, setTotal] = useState(0);
79
83
  const [offset, setOffset] = useState(0);
80
- const [loading, setLoading] = useState(false);
81
- const [initialLoad, setInitialLoad] = useState(true);
84
+ const [treeLoading, setTreeLoading] = useState(true);
85
+ const [docsLoading, setDocsLoading] = useState(false);
82
86
  const [availableDateFields, setAvailableDateFields] = useState<string[]>([]);
83
87
  const [sortField, setSortField] = useState("modified");
84
88
  const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
@@ -88,24 +92,58 @@ export default function Browse({ navigate }: PageProps) {
88
92
  const [refreshToken, setRefreshToken] = useState(0);
89
93
  const [favoriteDocHrefs, setFavoriteDocHrefs] = useState<string[]>([]);
90
94
  const [favoriteCollections, setFavoriteCollections] = useState<string[]>([]);
91
- const latestDocEvent = useDocEvents();
95
+ const [mobileTreeOpen, setMobileTreeOpen] = useState(false);
92
96
  const limit = 25;
93
97
 
94
- // Parse collection from URL on mount
95
- useEffect(() => {
96
- const params = new URLSearchParams(window.location.search);
97
- const collection = params.get("collection");
98
- if (collection) {
99
- setSelected(collection);
98
+ const selectedCollection = selection.collection;
99
+ const selectedPath = selection.path;
100
+ const selectedNodeId = selectedCollection
101
+ ? createBrowseNodeId(selectedCollection, selectedPath)
102
+ : null;
103
+ const selectedNode = tree
104
+ ? findBrowseNode(tree.collections, selectedCollection, selectedPath)
105
+ : null;
106
+
107
+ const expandedNodeIds = useMemo(() => {
108
+ const persisted = activeTab?.browseState?.expandedNodeIds ?? [];
109
+ const ancestors = selectedPath
110
+ ? getBrowseAncestorIds(selectedCollection, selectedPath)
111
+ : [];
112
+ return [...new Set([...persisted, ...ancestors])];
113
+ }, [
114
+ activeTab?.browseState?.expandedNodeIds,
115
+ selectedCollection,
116
+ selectedPath,
117
+ ]);
118
+
119
+ const childFolders = useMemo(() => {
120
+ if (!tree) {
121
+ return [];
100
122
  }
101
- }, []);
123
+ if (!selectedCollection) {
124
+ return tree.collections;
125
+ }
126
+ return getImmediateChildFolders(
127
+ tree.collections,
128
+ selectedCollection,
129
+ selectedPath
130
+ );
131
+ }, [selectedCollection, selectedPath, tree]);
132
+
133
+ const crumbs = useMemo(
134
+ () =>
135
+ selectedCollection
136
+ ? buildBrowseCrumbs(selectedCollection, selectedPath)
137
+ : [],
138
+ [selectedCollection, selectedPath]
139
+ );
140
+
141
+ useEffect(() => {
142
+ setOffset(0);
143
+ setDocs([]);
144
+ }, [selectedCollection, selectedPath]);
102
145
 
103
146
  useEffect(() => {
104
- void apiFetch<Collection[]>("/api/collections").then(({ data }) => {
105
- if (data) {
106
- setCollections(data);
107
- }
108
- });
109
147
  setFavoriteDocHrefs(loadFavoriteDocuments().map((entry) => entry.href));
110
148
  setFavoriteCollections(
111
149
  loadFavoriteCollections().map((entry) => entry.name)
@@ -113,22 +151,68 @@ export default function Browse({ navigate }: PageProps) {
113
151
  }, []);
114
152
 
115
153
  useEffect(() => {
116
- setLoading(true);
154
+ void apiFetch<BrowseTreeResponse>("/api/browse/tree").then(({ data }) => {
155
+ setTree(
156
+ data ?? { collections: [], totalCollections: 0, totalDocuments: 0 }
157
+ );
158
+ setTreeLoading(false);
159
+ });
160
+ }, [refreshToken]);
161
+
162
+ useEffect(() => {
163
+ if (!tree) {
164
+ return;
165
+ }
166
+
167
+ if (!selectedCollection) {
168
+ return;
169
+ }
170
+
171
+ const node = findBrowseNode(
172
+ tree.collections,
173
+ selectedCollection,
174
+ selectedPath
175
+ );
176
+ if (node) {
177
+ return;
178
+ }
179
+
180
+ const collectionRoot = findBrowseNode(tree.collections, selectedCollection);
181
+ if (collectionRoot) {
182
+ navigate(buildBrowseLocation(selectedCollection));
183
+ return;
184
+ }
185
+
186
+ navigate("/browse");
187
+ }, [navigate, selectedCollection, selectedPath, tree]);
188
+
189
+ useEffect(() => {
190
+ if (!selectedCollection) {
191
+ setDocs([]);
192
+ setTotal(0);
193
+ setOffset(0);
194
+ return;
195
+ }
196
+
197
+ setDocsLoading(true);
117
198
  const params = new URLSearchParams({
199
+ collection: selectedCollection,
118
200
  limit: String(limit),
119
201
  offset: String(offset),
120
202
  sortField,
121
203
  sortOrder,
204
+ directChildrenOnly: "true",
122
205
  });
123
- if (selected) {
124
- params.set("collection", selected);
206
+ if (selectedPath) {
207
+ params.set("pathPrefix", selectedPath);
125
208
  }
126
- const url = `/api/docs?${params.toString()}`;
127
209
 
128
- void apiFetch<DocsResponse>(url).then(({ data }) => {
129
- setLoading(false);
130
- setInitialLoad(false);
131
- if (data) {
210
+ void apiFetch<DocsResponse>(`/api/docs?${params.toString()}`).then(
211
+ ({ data }) => {
212
+ setDocsLoading(false);
213
+ if (!data) {
214
+ return;
215
+ }
132
216
  setAvailableDateFields(data.availableDateFields ?? []);
133
217
  setSortField(data.sortField);
134
218
  setSortOrder(data.sortOrder);
@@ -137,42 +221,77 @@ export default function Browse({ navigate }: PageProps) {
137
221
  );
138
222
  setTotal(data.total);
139
223
  }
140
- });
141
- }, [selected, offset, refreshToken, sortField, sortOrder]);
224
+ );
225
+ }, [
226
+ limit,
227
+ offset,
228
+ refreshToken,
229
+ selectedCollection,
230
+ selectedPath,
231
+ sortField,
232
+ sortOrder,
233
+ ]);
142
234
 
143
235
  useEffect(() => {
144
- if (sortField === "modified" || availableDateFields.includes(sortField)) {
236
+ if (!latestDocEvent?.changedAt) {
145
237
  return;
146
238
  }
147
- setSortField("modified");
148
- setSortOrder("desc");
149
239
  setOffset(0);
150
240
  setDocs([]);
151
- }, [availableDateFields, sortField]);
241
+ setRefreshToken((current) => current + 1);
242
+ }, [latestDocEvent?.changedAt]);
152
243
 
153
244
  useEffect(() => {
154
- if (!latestDocEvent?.changedAt) {
245
+ const persisted = activeTab?.browseState?.expandedNodeIds ?? [];
246
+ const ancestors = selectedPath
247
+ ? getBrowseAncestorIds(selectedCollection, selectedPath)
248
+ : [];
249
+ const merged = [...new Set([...persisted, ...ancestors])];
250
+ if (merged.length === persisted.length) {
155
251
  return;
156
252
  }
157
- setOffset(0);
158
- setDocs([]);
159
- setRefreshToken((current) => current + 1);
160
- }, [latestDocEvent?.changedAt]);
253
+ updateActiveTabBrowseState({
254
+ expandedNodeIds: merged,
255
+ });
256
+ }, [
257
+ activeTab?.browseState?.expandedNodeIds,
258
+ selectedCollection,
259
+ selectedPath,
260
+ updateActiveTabBrowseState,
261
+ ]);
161
262
 
162
- const handleCollectionChange = (value: string) => {
163
- const newSelected = value === "all" ? "" : value;
164
- setSelected(newSelected);
165
- setOffset(0);
263
+ const handleLoadMore = () => {
264
+ setOffset((current) => current + limit);
265
+ };
266
+
267
+ const handleSelectNode = (collection: string, path?: string) => {
166
268
  setDocs([]);
167
- // Update URL for shareable deep-links
168
- const url = newSelected
169
- ? `/browse?collection=${encodeURIComponent(newSelected)}`
170
- : "/browse";
171
- window.history.pushState({}, "", url);
269
+ setOffset(0);
270
+ setMobileTreeOpen(false);
271
+ navigate(buildBrowseLocation(collection, path));
172
272
  };
173
273
 
174
- const handleLoadMore = () => {
175
- setOffset((prev) => prev + limit);
274
+ const handleToggleNode = (nodeId: string) => {
275
+ updateActiveTabBrowseState((current) => {
276
+ const next = new Set(current.expandedNodeIds);
277
+ if (next.has(nodeId)) {
278
+ next.delete(nodeId);
279
+ } else {
280
+ next.add(nodeId);
281
+ }
282
+ return {
283
+ expandedNodeIds: [...next],
284
+ };
285
+ });
286
+ };
287
+
288
+ const handleToggleFavoriteCollection = (collection: string) => {
289
+ const next = toggleFavoriteCollection({
290
+ name: collection,
291
+ href: buildBrowseLocation(collection),
292
+ label: collection,
293
+ });
294
+ setFavoriteCollections(next.map((entry) => entry.name));
176
295
  };
177
296
 
178
297
  const handleSortChange = (value: string) => {
@@ -186,25 +305,9 @@ export default function Browse({ navigate }: PageProps) {
186
305
  setDocs([]);
187
306
  };
188
307
 
189
- const navigateToCollection = (collection: string) => {
190
- const nextValue = collection.trim();
191
- if (!nextValue) {
192
- return;
193
- }
194
- setSelected(nextValue);
195
- setOffset(0);
196
- setDocs([]);
197
- window.history.pushState(
198
- {},
199
- "",
200
- `/browse?collection=${encodeURIComponent(nextValue)}`
201
- );
202
- };
203
-
204
308
  const handleReindex = async () => {
205
309
  setSyncError(null);
206
-
207
- const body = selected ? { collection: selected } : {};
310
+ const body = selectedCollection ? { collection: selectedCollection } : {};
208
311
  const { data, error } = await apiFetch<SyncResponse>("/api/sync", {
209
312
  method: "POST",
210
313
  body: JSON.stringify(body),
@@ -218,36 +321,27 @@ export default function Browse({ navigate }: PageProps) {
218
321
  if (data?.jobId) {
219
322
  setSyncJobId(data.jobId);
220
323
  setSyncTarget(
221
- selected ? { kind: "collection", name: selected } : { kind: "all" }
324
+ selectedCollection
325
+ ? { kind: "collection", name: selectedCollection }
326
+ : { kind: "all" }
222
327
  );
223
328
  }
224
329
  };
225
330
 
226
- const formatDateFieldLabel = (field: string) =>
227
- field
228
- .split("_")
229
- .filter((token) => token.length > 0)
230
- .map((token) => token.charAt(0).toUpperCase() + token.slice(1))
231
- .join(" ");
232
-
233
- const getExtBadgeVariant = (ext: string) => {
234
- switch (ext.toLowerCase()) {
235
- case ".md":
236
- case ".markdown":
237
- return "default";
238
- case ".pdf":
239
- return "destructive";
240
- case ".docx":
241
- case ".doc":
242
- return "secondary";
243
- default:
244
- return "outline";
245
- }
246
- };
331
+ const renderSidebar = () => (
332
+ <BrowseTreeSidebar
333
+ collections={tree?.collections ?? []}
334
+ expandedNodeIds={expandedNodeIds}
335
+ favoriteCollections={favoriteCollections}
336
+ onSelect={handleSelectNode}
337
+ onToggle={handleToggleNode}
338
+ onToggleFavoriteCollection={handleToggleFavoriteCollection}
339
+ selectedNodeId={selectedNodeId}
340
+ />
341
+ );
247
342
 
248
343
  return (
249
344
  <div className="min-h-screen">
250
- {/* Header */}
251
345
  <header className="glass sticky top-0 z-10 border-border/50 border-b">
252
346
  <div className="flex flex-wrap items-center justify-between gap-4 px-8 py-4">
253
347
  <div className="flex items-center gap-4">
@@ -272,23 +366,15 @@ export default function Browse({ navigate }: PageProps) {
272
366
  <h1 className="font-semibold text-xl">Browse</h1>
273
367
  </div>
274
368
  <div className="flex flex-wrap items-center justify-end gap-3">
275
- <Select
276
- onValueChange={handleCollectionChange}
277
- value={selected || "all"}
369
+ <Button
370
+ className="gap-2 lg:hidden"
371
+ onClick={() => setMobileTreeOpen(true)}
372
+ size="sm"
373
+ variant="outline"
278
374
  >
279
- <SelectTrigger className="w-[200px]">
280
- <FolderOpen className="mr-2 size-4 text-muted-foreground" />
281
- <SelectValue placeholder="All Collections" />
282
- </SelectTrigger>
283
- <SelectContent>
284
- <SelectItem value="all">All Collections</SelectItem>
285
- {collections.map((c) => (
286
- <SelectItem key={c.name} value={c.name}>
287
- {c.name}
288
- </SelectItem>
289
- ))}
290
- </SelectContent>
291
- </Select>
375
+ <FolderOpen className="size-4" />
376
+ Tree
377
+ </Button>
292
378
  <Select
293
379
  onValueChange={handleSortChange}
294
380
  value={`${sortField}:${sortOrder}`}
@@ -300,19 +386,22 @@ export default function Browse({ navigate }: PageProps) {
300
386
  <SelectItem value="modified:desc">Newest Modified</SelectItem>
301
387
  <SelectItem value="modified:asc">Oldest Modified</SelectItem>
302
388
  {availableDateFields.map((field) => (
303
- <Fragment key={field}>
304
- <SelectItem value={`${field}:desc`}>
305
- {`Newest by ${formatDateFieldLabel(field)}`}
306
- </SelectItem>
307
- <SelectItem value={`${field}:asc`}>
308
- {`Oldest by ${formatDateFieldLabel(field)}`}
309
- </SelectItem>
310
- </Fragment>
389
+ <SelectItem key={`${field}:desc`} value={`${field}:desc`}>
390
+ {`Newest by ${formatDateFieldLabel(field)}`}
391
+ </SelectItem>
392
+ ))}
393
+ {availableDateFields.map((field) => (
394
+ <SelectItem key={`${field}:asc`} value={`${field}:asc`}>
395
+ {`Oldest by ${formatDateFieldLabel(field)}`}
396
+ </SelectItem>
311
397
  ))}
312
398
  </SelectContent>
313
399
  </Select>
314
400
  <Badge className="font-mono" variant="outline">
315
- {total.toLocaleString()} docs
401
+ {selectedNode?.documentCount ??
402
+ tree?.totalDocuments.toLocaleString() ??
403
+ 0}{" "}
404
+ docs
316
405
  </Badge>
317
406
  <Button
318
407
  className="gap-2"
@@ -323,29 +412,25 @@ export default function Browse({ navigate }: PageProps) {
323
412
  <FolderOpen className="size-4" />
324
413
  Collections
325
414
  </Button>
326
- {selected && (
415
+ {selectedCollection && (
327
416
  <Button
328
417
  className="gap-2"
329
418
  onClick={() =>
330
- setFavoriteCollections(
331
- toggleFavoriteCollection({
332
- name: selected,
333
- href: `/browse?collection=${encodeURIComponent(selected)}`,
334
- label: selected,
335
- }).map((entry) => entry.name)
336
- )
419
+ handleToggleFavoriteCollection(selectedCollection)
337
420
  }
338
421
  size="sm"
339
422
  variant="outline"
340
423
  >
341
424
  <StarIcon
342
425
  className={`size-4 ${
343
- favoriteCollections.includes(selected)
426
+ favoriteCollections.includes(selectedCollection)
344
427
  ? "fill-current text-secondary"
345
428
  : ""
346
429
  }`}
347
430
  />
348
- {favoriteCollections.includes(selected) ? "Pinned" : "Pin"}
431
+ {favoriteCollections.includes(selectedCollection)
432
+ ? "Pinned"
433
+ : "Pin"}
349
434
  </Button>
350
435
  )}
351
436
  <Button
@@ -355,203 +440,108 @@ export default function Browse({ navigate }: PageProps) {
355
440
  size="sm"
356
441
  >
357
442
  <RefreshCw className="size-4" />
358
- {selected ? "Re-index This Collection" : "Re-index All"}
443
+ {selectedCollection ? "Re-index This Collection" : "Re-index All"}
359
444
  </Button>
360
445
  </div>
361
446
  </div>
362
447
  </header>
363
448
 
364
- <main className="mx-auto max-w-6xl p-8">
365
- <div className="mb-6 rounded-2xl border border-border/60 bg-card/70 p-4 shadow-sm">
366
- <div className="flex flex-wrap items-start justify-between gap-4">
367
- <div className="space-y-1">
368
- <p className="font-medium text-sm tracking-[0.18em] uppercase">
369
- Collection Controls
370
- </p>
371
- <p className="max-w-2xl text-muted-foreground text-sm">
372
- Add folders, remove sources, and re-index after external edits
373
- from the collections view.
374
- </p>
375
- {selected && (
376
- <div className="flex items-center gap-2">
377
- <span className="text-muted-foreground text-sm">
378
- Current collection:
379
- </span>
380
- <Badge className="font-mono text-xs" variant="secondary">
381
- {selected}
382
- </Badge>
383
- </div>
384
- )}
449
+ <div className="flex min-h-[calc(100vh-73px)]">
450
+ <aside className="hidden w-[320px] shrink-0 border-border/40 border-r bg-card/30 lg:block">
451
+ {treeLoading ? (
452
+ <div className="flex h-full items-center justify-center">
453
+ <Loader className="text-primary" size={24} />
385
454
  </div>
455
+ ) : (
456
+ renderSidebar()
457
+ )}
458
+ </aside>
386
459
 
387
- <div className="flex min-h-9 items-center">
388
- {syncJobId ? (
389
- <IndexingProgress
390
- className="justify-end"
391
- compact
392
- jobId={syncJobId}
393
- onComplete={() => {
394
- setSyncError(null);
395
- setSyncJobId(null);
396
- setSyncTarget(null);
397
- setRefreshToken((current) => current + 1);
398
- }}
399
- onError={(error) => {
400
- setSyncError(error);
401
- setSyncJobId(null);
402
- setSyncTarget(null);
403
- }}
404
- />
405
- ) : syncError ? (
406
- <p className="text-destructive text-sm">{syncError}</p>
407
- ) : syncTarget ? (
408
- <p className="text-muted-foreground text-sm">
409
- Re-index queued for{" "}
410
- <span className="font-medium text-foreground">
411
- {syncTarget.kind === "all"
412
- ? "all collections"
413
- : syncTarget.name}
414
- </span>
415
- .
460
+ <main className="min-w-0 flex-1 p-8">
461
+ <div className="mx-auto max-w-6xl space-y-6">
462
+ <BrowseWorkspaceCard
463
+ crumbs={crumbs}
464
+ navigate={navigate}
465
+ onSyncComplete={() => {
466
+ setSyncError(null);
467
+ setSyncJobId(null);
468
+ setSyncTarget(null);
469
+ setRefreshToken((current) => current + 1);
470
+ }}
471
+ onSyncError={(error) => {
472
+ setSyncError(error);
473
+ setSyncJobId(null);
474
+ setSyncTarget(null);
475
+ }}
476
+ selectedCollection={selectedCollection}
477
+ syncError={syncError}
478
+ syncJobId={syncJobId}
479
+ syncTarget={syncTarget}
480
+ />
481
+
482
+ {treeLoading ? (
483
+ <div className="flex flex-col items-center justify-center gap-4 py-20">
484
+ <Loader className="text-primary" size={32} />
485
+ <p className="text-muted-foreground">
486
+ Loading workspace tree...
416
487
  </p>
417
- ) : null}
418
- </div>
488
+ </div>
489
+ ) : !selectedCollection ? (
490
+ <BrowseOverview
491
+ collections={tree?.collections ?? []}
492
+ favoriteCollections={favoriteCollections}
493
+ onSelectCollection={(collection) =>
494
+ handleSelectNode(collection)
495
+ }
496
+ onToggleFavoriteCollection={handleToggleFavoriteCollection}
497
+ />
498
+ ) : (
499
+ <BrowseDetailPane
500
+ childFolders={childFolders}
501
+ docs={docs}
502
+ docsLoading={docsLoading}
503
+ favoriteDocHrefs={favoriteDocHrefs}
504
+ onLoadMore={handleLoadMore}
505
+ onOpenDoc={(uri) =>
506
+ navigate(`/doc?uri=${encodeURIComponent(uri)}`)
507
+ }
508
+ onSelectCollection={(collection) =>
509
+ handleSelectNode(collection)
510
+ }
511
+ onSelectFolder={handleSelectNode}
512
+ onToggleFavoriteDocument={(doc) => {
513
+ const next = toggleFavoriteDocument({
514
+ uri: doc.uri,
515
+ href: `/doc?uri=${encodeURIComponent(doc.uri)}`,
516
+ label: doc.title || doc.relPath,
517
+ });
518
+ setFavoriteDocHrefs(next.map((entry) => entry.href));
519
+ }}
520
+ selectedNode={selectedNode}
521
+ selectedPath={selectedPath}
522
+ total={total}
523
+ />
524
+ )}
419
525
  </div>
420
- </div>
526
+ </main>
527
+ </div>
421
528
 
422
- {/* Initial loading */}
423
- {initialLoad && loading && (
424
- <div className="flex flex-col items-center justify-center gap-4 py-20">
425
- <Loader className="text-primary" size={32} />
426
- <p className="text-muted-foreground">Loading documents...</p>
427
- </div>
428
- )}
429
-
430
- {/* Empty state */}
431
- {!loading && docs.length === 0 && (
432
- <div className="py-20 text-center">
433
- <FileText className="mx-auto mb-4 size-12 text-muted-foreground" />
434
- <h3 className="mb-2 font-medium text-lg">No documents found</h3>
435
- <p className="text-muted-foreground">
436
- {selected
437
- ? "This collection is empty"
438
- : "Index some documents to get started"}
439
- </p>
440
- </div>
441
- )}
442
-
443
- {/* Document Table */}
444
- {docs.length > 0 && (
445
- <div className="animate-fade-in opacity-0">
446
- <Table className="table-fixed">
447
- <TableHeader>
448
- <TableRow>
449
- <TableHead className="w-[68%]">Document</TableHead>
450
- <TableHead className="w-[220px]">Collection</TableHead>
451
- <TableHead className="w-[72px] text-right">Type</TableHead>
452
- </TableRow>
453
- </TableHeader>
454
- <TableBody>
455
- {docs.map((doc, _i) => (
456
- <TableRow
457
- className="group cursor-pointer"
458
- key={doc.docid}
459
- onClick={() =>
460
- navigate(`/doc?uri=${encodeURIComponent(doc.uri)}`)
461
- }
462
- >
463
- <TableCell className="align-top whitespace-normal">
464
- <div className="flex items-center gap-2">
465
- <FileText className="size-4 shrink-0 text-muted-foreground" />
466
- <div className="min-w-0">
467
- <div className="break-words font-medium leading-tight transition-colors group-hover:text-primary">
468
- {doc.title || doc.relPath}
469
- </div>
470
- <div className="break-all font-mono text-muted-foreground text-xs leading-relaxed">
471
- {doc.relPath}
472
- </div>
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>
499
- <ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
500
- </div>
501
- </TableCell>
502
- <TableCell className="align-top whitespace-normal">
503
- <Badge
504
- 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"
505
- onClick={(event) => {
506
- event.stopPropagation();
507
- navigateToCollection(doc.collection);
508
- }}
509
- variant="outline"
510
- >
511
- {doc.collection}
512
- </Badge>
513
- </TableCell>
514
- <TableCell className="align-top text-right">
515
- <Badge
516
- className="font-mono text-xs"
517
- variant={getExtBadgeVariant(doc.sourceExt)}
518
- >
519
- {doc.sourceExt}
520
- </Badge>
521
- </TableCell>
522
- </TableRow>
523
- ))}
524
- </TableBody>
525
- </Table>
526
-
527
- {/* Load More */}
528
- {offset + limit < total && (
529
- <div className="mt-8 text-center">
530
- <Button
531
- className="gap-2"
532
- disabled={loading}
533
- onClick={handleLoadMore}
534
- variant="outline"
535
- >
536
- {loading ? (
537
- <>
538
- <Loader size={16} />
539
- Loading...
540
- </>
541
- ) : (
542
- <>
543
- Load More
544
- <Badge className="ml-1" variant="secondary">
545
- {Math.min(limit, total - docs.length)} remaining
546
- </Badge>
547
- </>
548
- )}
549
- </Button>
529
+ <Dialog onOpenChange={setMobileTreeOpen} open={mobileTreeOpen}>
530
+ <DialogContent className="max-w-xl p-0">
531
+ <DialogHeader className="px-6 pt-6">
532
+ <DialogTitle>Workspace Tree</DialogTitle>
533
+ </DialogHeader>
534
+ <div className="h-[70vh] border-border/40 border-t">
535
+ {treeLoading ? (
536
+ <div className="flex h-full items-center justify-center">
537
+ <Loader className="text-primary" size={24} />
550
538
  </div>
539
+ ) : (
540
+ renderSidebar()
551
541
  )}
552
542
  </div>
553
- )}
554
- </main>
543
+ </DialogContent>
544
+ </Dialog>
555
545
  </div>
556
546
  );
557
547
  }