@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
|
@@ -250,41 +250,37 @@ export function OutgoingLinksPanel({
|
|
|
250
250
|
|
|
251
251
|
return (
|
|
252
252
|
<Collapsible
|
|
253
|
-
className={cn(
|
|
254
|
-
// Container styling - dark manuscript edge
|
|
255
|
-
"border-border/40 border-l",
|
|
256
|
-
"bg-gradient-to-b from-[#050505] to-[#0a0a0a]",
|
|
257
|
-
className
|
|
258
|
-
)}
|
|
253
|
+
className={cn("px-1", className)}
|
|
259
254
|
onOpenChange={setIsOpen}
|
|
260
255
|
open={isOpen}
|
|
261
256
|
>
|
|
262
257
|
{/* Header trigger */}
|
|
263
258
|
<CollapsibleTrigger
|
|
264
259
|
className={cn(
|
|
265
|
-
"group flex w-full items-center gap-2
|
|
260
|
+
"group flex w-full items-center gap-2",
|
|
261
|
+
"rounded-sm px-2 py-1.5",
|
|
266
262
|
"transition-colors duration-150",
|
|
267
|
-
"hover:bg-
|
|
263
|
+
"hover:bg-muted/20"
|
|
268
264
|
)}
|
|
269
265
|
>
|
|
270
266
|
{/* Chevron */}
|
|
271
267
|
<ChevronDownIcon
|
|
272
268
|
className={cn(
|
|
273
|
-
"size-
|
|
269
|
+
"size-3.5 shrink-0 text-muted-foreground/50",
|
|
274
270
|
"transition-transform duration-200",
|
|
275
271
|
!isOpen && "-rotate-90"
|
|
276
272
|
)}
|
|
277
273
|
/>
|
|
278
274
|
|
|
279
275
|
{/* Title */}
|
|
280
|
-
<span className="flex-1 text-left font-mono text-[
|
|
276
|
+
<span className="flex-1 text-left font-mono text-[10px] text-muted-foreground/60 uppercase tracking-[0.15em]">
|
|
281
277
|
Outgoing Links
|
|
282
278
|
</span>
|
|
283
279
|
|
|
284
280
|
{/* Count badges */}
|
|
285
281
|
{!loading && links.length > 0 && (
|
|
286
282
|
<div className="flex items-center gap-1.5">
|
|
287
|
-
<span className="rounded bg-
|
|
283
|
+
<span className="rounded bg-primary/12 px-1.5 py-0.5 font-mono text-[10px] tabular-nums text-primary">
|
|
288
284
|
{links.length}
|
|
289
285
|
</span>
|
|
290
286
|
{brokenCount > 0 && (
|
|
@@ -295,7 +291,7 @@ export function OutgoingLinksPanel({
|
|
|
295
291
|
</div>
|
|
296
292
|
)}
|
|
297
293
|
|
|
298
|
-
{/* Loading indicator
|
|
294
|
+
{/* Loading indicator */}
|
|
299
295
|
{loading && (
|
|
300
296
|
<Loader2Icon className="size-3.5 animate-spin text-muted-foreground/50" />
|
|
301
297
|
)}
|
|
@@ -190,34 +190,17 @@ function SimilarityBar({ score }: { score: number }) {
|
|
|
190
190
|
const percentage = Math.round(score * 100);
|
|
191
191
|
|
|
192
192
|
return (
|
|
193
|
-
<div className="
|
|
194
|
-
{/*
|
|
195
|
-
<div className="
|
|
196
|
-
<span className="font-mono text-[9px] text-muted-foreground/60 uppercase tracking-wider">
|
|
197
|
-
Similarity
|
|
198
|
-
</span>
|
|
199
|
-
<span className="font-mono text-[10px] text-primary/80 tabular-nums">
|
|
200
|
-
{percentage}%
|
|
201
|
-
</span>
|
|
202
|
-
</div>
|
|
203
|
-
|
|
204
|
-
{/* Bar track - glass effect */}
|
|
205
|
-
<div className="relative h-1.5 overflow-hidden rounded-full bg-muted/30 shadow-[inset_0_1px_2px_rgba(0,0,0,0.2)]">
|
|
206
|
-
{/* Fill - teal glow */}
|
|
193
|
+
<div className="mt-1 flex items-center gap-2">
|
|
194
|
+
{/* Bar track */}
|
|
195
|
+
<div className="relative h-1 flex-1 overflow-hidden rounded-full bg-muted/30">
|
|
207
196
|
<div
|
|
208
|
-
className=
|
|
209
|
-
"absolute inset-y-0 left-0 rounded-full",
|
|
210
|
-
"bg-gradient-to-r from-primary/70 via-primary to-primary/80",
|
|
211
|
-
"transition-all duration-500 ease-out",
|
|
212
|
-
// Subtle glow on high scores
|
|
213
|
-
score > 0.7 && "shadow-[0_0_8px_hsl(var(--primary)/0.5)]"
|
|
214
|
-
)}
|
|
197
|
+
className="absolute inset-y-0 left-0 rounded-full bg-primary/60"
|
|
215
198
|
style={{ width: `${percentage}%` }}
|
|
216
|
-
|
|
217
|
-
{/* Shimmer effect */}
|
|
218
|
-
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent opacity-0 transition-opacity duration-300 group-hover/bar:opacity-100" />
|
|
219
|
-
</div>
|
|
199
|
+
/>
|
|
220
200
|
</div>
|
|
201
|
+
<span className="shrink-0 font-mono text-[9px] text-muted-foreground/50 tabular-nums">
|
|
202
|
+
{percentage}%
|
|
203
|
+
</span>
|
|
221
204
|
</div>
|
|
222
205
|
);
|
|
223
206
|
}
|
|
@@ -238,16 +221,13 @@ function RelatedNoteItem({
|
|
|
238
221
|
return (
|
|
239
222
|
<button
|
|
240
223
|
className={cn(
|
|
241
|
-
|
|
242
|
-
"
|
|
243
|
-
"
|
|
244
|
-
"
|
|
245
|
-
|
|
246
|
-
"
|
|
247
|
-
"
|
|
248
|
-
// Focus state
|
|
249
|
-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
|
|
250
|
-
// Stagger animation
|
|
224
|
+
"group relative flex min-w-0 w-full items-start gap-2",
|
|
225
|
+
"rounded px-2 py-1.5 text-left",
|
|
226
|
+
"font-mono text-xs",
|
|
227
|
+
"transition-all duration-150",
|
|
228
|
+
"text-primary/90 hover:bg-muted/20",
|
|
229
|
+
"cursor-pointer hover:translate-x-0.5",
|
|
230
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50",
|
|
251
231
|
"animate-fade-in opacity-0"
|
|
252
232
|
)}
|
|
253
233
|
onClick={onNavigate}
|
|
@@ -257,43 +237,29 @@ function RelatedNoteItem({
|
|
|
257
237
|
}}
|
|
258
238
|
type="button"
|
|
259
239
|
>
|
|
260
|
-
|
|
240
|
+
<span
|
|
241
|
+
className={cn(
|
|
242
|
+
"mt-0.5 flex size-5 shrink-0 items-center justify-center rounded",
|
|
243
|
+
"bg-primary/15 transition-colors duration-150",
|
|
244
|
+
"group-hover:bg-primary/25"
|
|
245
|
+
)}
|
|
246
|
+
>
|
|
247
|
+
<SparklesIcon className="size-3" />
|
|
248
|
+
</span>
|
|
249
|
+
|
|
261
250
|
<Tooltip>
|
|
262
251
|
<TooltipTrigger asChild>
|
|
263
|
-
<
|
|
264
|
-
|
|
265
|
-
|
|
252
|
+
<div className="min-w-0 flex-1">
|
|
253
|
+
<span className="block break-words font-medium leading-tight whitespace-normal text-foreground/90 group-hover:text-foreground">
|
|
254
|
+
{doc.title || "Untitled"}
|
|
255
|
+
</span>
|
|
256
|
+
<SimilarityBar score={doc.score} />
|
|
257
|
+
</div>
|
|
266
258
|
</TooltipTrigger>
|
|
267
259
|
<TooltipContent side="left" className="max-w-[300px]">
|
|
268
260
|
<p className="break-words">{doc.title || "Untitled"}</p>
|
|
269
261
|
</TooltipContent>
|
|
270
262
|
</Tooltip>
|
|
271
|
-
|
|
272
|
-
{/* Collection badge - brass plate style */}
|
|
273
|
-
<div className="mt-1 flex items-center gap-1.5">
|
|
274
|
-
<span
|
|
275
|
-
className={cn(
|
|
276
|
-
"inline-flex items-center gap-1 rounded px-1.5 py-0.5",
|
|
277
|
-
"bg-secondary/10 font-mono text-[10px] text-secondary/80",
|
|
278
|
-
"transition-colors group-hover:bg-secondary/15 group-hover:text-secondary"
|
|
279
|
-
)}
|
|
280
|
-
>
|
|
281
|
-
{doc.collection}
|
|
282
|
-
</span>
|
|
283
|
-
</div>
|
|
284
|
-
|
|
285
|
-
{/* Similarity bar */}
|
|
286
|
-
<SimilarityBar score={doc.score} />
|
|
287
|
-
|
|
288
|
-
{/* Hover indicator - brass accent */}
|
|
289
|
-
<div
|
|
290
|
-
className={cn(
|
|
291
|
-
"absolute right-2 top-2 opacity-0 transition-opacity",
|
|
292
|
-
"group-hover:opacity-100"
|
|
293
|
-
)}
|
|
294
|
-
>
|
|
295
|
-
<SparklesIcon className="size-3.5 text-secondary/60" />
|
|
296
|
-
</div>
|
|
297
263
|
</button>
|
|
298
264
|
);
|
|
299
265
|
}
|
|
@@ -406,19 +372,19 @@ export function RelatedNotesSidebar({
|
|
|
406
372
|
}
|
|
407
373
|
|
|
408
374
|
return (
|
|
409
|
-
<div className={cn("
|
|
375
|
+
<div className={cn("min-w-0 overflow-hidden px-1", className)}>
|
|
410
376
|
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
|
411
377
|
{/* Header */}
|
|
412
|
-
<div className="flex items-center gap-1
|
|
378
|
+
<div className="flex items-center gap-1">
|
|
413
379
|
<CollapsibleTrigger
|
|
414
380
|
className={cn(
|
|
415
|
-
"flex flex-1 items-center gap-
|
|
381
|
+
"flex flex-1 items-center gap-2 rounded-sm px-2 py-1.5",
|
|
416
382
|
"transition-colors duration-150",
|
|
417
|
-
"hover:bg-muted/
|
|
383
|
+
"hover:bg-muted/20"
|
|
418
384
|
)}
|
|
419
385
|
>
|
|
420
386
|
{/* Chevron */}
|
|
421
|
-
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground/
|
|
387
|
+
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground/50 transition-transform duration-200">
|
|
422
388
|
{isOpen ? (
|
|
423
389
|
<ChevronDownIcon className="size-3.5" />
|
|
424
390
|
) : (
|
|
@@ -427,13 +393,13 @@ export function RelatedNotesSidebar({
|
|
|
427
393
|
</span>
|
|
428
394
|
|
|
429
395
|
{/* Title */}
|
|
430
|
-
<span className="flex-1 text-left font-mono text-[
|
|
396
|
+
<span className="flex-1 text-left font-mono text-[10px] text-muted-foreground/60 uppercase tracking-[0.15em]">
|
|
431
397
|
Related Notes
|
|
432
398
|
</span>
|
|
433
399
|
|
|
434
400
|
{/* Count badge */}
|
|
435
401
|
{similar.length > 0 && (
|
|
436
|
-
<span className="rounded bg-primary/
|
|
402
|
+
<span className="rounded bg-primary/12 px-1.5 py-0.5 font-mono text-[10px] text-primary tabular-nums">
|
|
437
403
|
{similar.length}
|
|
438
404
|
</span>
|
|
439
405
|
)}
|
|
@@ -442,9 +408,9 @@ export function RelatedNotesSidebar({
|
|
|
442
408
|
{/* Hide button */}
|
|
443
409
|
<button
|
|
444
410
|
className={cn(
|
|
445
|
-
"flex size-
|
|
446
|
-
"text-muted-foreground/
|
|
447
|
-
"hover:bg-muted/
|
|
411
|
+
"flex size-5 items-center justify-center rounded-sm",
|
|
412
|
+
"text-muted-foreground/40 transition-colors",
|
|
413
|
+
"hover:bg-muted/20 hover:text-muted-foreground"
|
|
448
414
|
)}
|
|
449
415
|
onClick={handleToggleVisibility}
|
|
450
416
|
title="Hide related notes"
|
|
@@ -472,7 +438,7 @@ export function RelatedNotesSidebar({
|
|
|
472
438
|
) : similar.length === 0 ? (
|
|
473
439
|
<RelatedNotesEmpty />
|
|
474
440
|
) : (
|
|
475
|
-
<div className="space-y-
|
|
441
|
+
<div className="space-y-0.5 p-2">
|
|
476
442
|
{similar.map((doc, index) => (
|
|
477
443
|
<RelatedNoteItem
|
|
478
444
|
doc={doc}
|
|
@@ -13,10 +13,16 @@ import ReactMarkdown from "react-markdown";
|
|
|
13
13
|
import rehypeSanitize from "rehype-sanitize";
|
|
14
14
|
import remarkGfm from "remark-gfm";
|
|
15
15
|
|
|
16
|
+
import {
|
|
17
|
+
normalizeWikiName,
|
|
18
|
+
parseTargetParts,
|
|
19
|
+
stripWikiMdExt,
|
|
20
|
+
} from "../../../../core/links";
|
|
16
21
|
import {
|
|
17
22
|
extractMarkdownCodeLanguage,
|
|
18
23
|
resolveCodeLanguage,
|
|
19
24
|
} from "../../lib/code-language";
|
|
25
|
+
import { buildDocDeepLink } from "../../lib/deep-links";
|
|
20
26
|
import { cn } from "../../lib/utils";
|
|
21
27
|
import { CodeBlock, CodeBlockCopyButton } from "../ai-elements/code-block";
|
|
22
28
|
|
|
@@ -25,6 +31,53 @@ export interface MarkdownPreviewProps {
|
|
|
25
31
|
content: string;
|
|
26
32
|
/** Additional CSS classes */
|
|
27
33
|
className?: string;
|
|
34
|
+
/** Current collection for wiki-link resolution */
|
|
35
|
+
collection?: string;
|
|
36
|
+
/** Resolved outgoing wiki links for the current document */
|
|
37
|
+
wikiLinks?: Array<{
|
|
38
|
+
targetRef: string;
|
|
39
|
+
targetCollection?: string;
|
|
40
|
+
targetAnchor?: string;
|
|
41
|
+
resolvedUri?: string;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const WIKI_LINK_REGEX = /\[\[([^\]|]+(?:\|[^\]]+)?)\]\]/g;
|
|
46
|
+
|
|
47
|
+
function renderMarkdownWithWikiLinks(
|
|
48
|
+
content: string,
|
|
49
|
+
collection?: string,
|
|
50
|
+
wikiLinks?: MarkdownPreviewProps["wikiLinks"]
|
|
51
|
+
): string {
|
|
52
|
+
if (!content.includes("[[")) {
|
|
53
|
+
return content;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const resolvedWikiLinkMap = new Map<string, string>();
|
|
57
|
+
for (const link of wikiLinks ?? []) {
|
|
58
|
+
const targetCollection = link.targetCollection || collection || "";
|
|
59
|
+
const targetRefKey = normalizeWikiName(stripWikiMdExt(link.targetRef));
|
|
60
|
+
const targetAnchorKey = (link.targetAnchor ?? "").trim().toLowerCase();
|
|
61
|
+
const key = `${targetCollection}::${targetRefKey}::${targetAnchorKey}`;
|
|
62
|
+
if (link.resolvedUri) {
|
|
63
|
+
resolvedWikiLinkMap.set(key, buildDocDeepLink({ uri: link.resolvedUri }));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return content.replace(WIKI_LINK_REGEX, (match, rawContent: string) => {
|
|
68
|
+
const [rawTarget, rawAlias] = rawContent.split("|");
|
|
69
|
+
const displayText = rawAlias?.trim() || rawTarget?.trim() || match;
|
|
70
|
+
const parsed = parseTargetParts(rawTarget ?? "");
|
|
71
|
+
const targetCollection = parsed.collection || collection || "";
|
|
72
|
+
const targetRefKey = normalizeWikiName(stripWikiMdExt(parsed.ref));
|
|
73
|
+
const targetAnchorKey = (parsed.anchor ?? "").trim().toLowerCase();
|
|
74
|
+
const key = `${targetCollection}::${targetRefKey}::${targetAnchorKey}`;
|
|
75
|
+
const href =
|
|
76
|
+
resolvedWikiLinkMap.get(key) ||
|
|
77
|
+
`/search?query=${encodeURIComponent(stripWikiMdExt(parsed.ref))}`;
|
|
78
|
+
|
|
79
|
+
return `[${displayText}](${href})`;
|
|
80
|
+
});
|
|
28
81
|
}
|
|
29
82
|
|
|
30
83
|
// Inline code styling
|
|
@@ -319,7 +372,7 @@ const components = {
|
|
|
319
372
|
* Sanitizes HTML to prevent XSS attacks.
|
|
320
373
|
*/
|
|
321
374
|
export const MarkdownPreview = memo(
|
|
322
|
-
({ content, className }: MarkdownPreviewProps) => {
|
|
375
|
+
({ content, className, collection, wikiLinks }: MarkdownPreviewProps) => {
|
|
323
376
|
if (!content) {
|
|
324
377
|
return (
|
|
325
378
|
<div className={cn("text-muted-foreground italic", className)}>
|
|
@@ -328,6 +381,12 @@ export const MarkdownPreview = memo(
|
|
|
328
381
|
);
|
|
329
382
|
}
|
|
330
383
|
|
|
384
|
+
const renderedContent = renderMarkdownWithWikiLinks(
|
|
385
|
+
content,
|
|
386
|
+
collection,
|
|
387
|
+
wikiLinks
|
|
388
|
+
);
|
|
389
|
+
|
|
331
390
|
return (
|
|
332
391
|
<div
|
|
333
392
|
className={cn(
|
|
@@ -342,7 +401,7 @@ export const MarkdownPreview = memo(
|
|
|
342
401
|
rehypePlugins={[rehypeSanitize]}
|
|
343
402
|
remarkPlugins={[remarkGfm]}
|
|
344
403
|
>
|
|
345
|
-
{
|
|
404
|
+
{renderedContent}
|
|
346
405
|
</ReactMarkdown>
|
|
347
406
|
</div>
|
|
348
407
|
);
|
|
@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
6
6
|
import { cn } from "../../lib/utils";
|
|
7
7
|
|
|
8
8
|
const buttonVariants = cva(
|
|
9
|
-
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
|
9
|
+
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
|
10
10
|
{
|
|
11
11
|
variants: {
|
|
12
12
|
variant: {
|
|
@@ -67,7 +67,7 @@ function DialogContent({
|
|
|
67
67
|
{children}
|
|
68
68
|
{showCloseButton && (
|
|
69
69
|
<DialogPrimitive.Close
|
|
70
|
-
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
|
70
|
+
className="absolute top-4 right-4 cursor-pointer rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
|
71
71
|
data-slot="dialog-close"
|
|
72
72
|
>
|
|
73
73
|
<XIcon />
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
WorkspaceTab,
|
|
5
|
+
WorkspaceTabBrowseState,
|
|
6
|
+
} from "../lib/workspace-tabs";
|
|
7
|
+
|
|
8
|
+
interface WorkspaceContextValue {
|
|
9
|
+
activeTab: WorkspaceTab | null;
|
|
10
|
+
updateActiveTabBrowseState: (
|
|
11
|
+
nextBrowseState:
|
|
12
|
+
| WorkspaceTabBrowseState
|
|
13
|
+
| ((current: WorkspaceTabBrowseState) => WorkspaceTabBrowseState)
|
|
14
|
+
) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const WorkspaceContext = createContext<WorkspaceContextValue>({
|
|
18
|
+
activeTab: null,
|
|
19
|
+
updateActiveTabBrowseState: () => undefined,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export function WorkspaceProvider({
|
|
23
|
+
children,
|
|
24
|
+
value,
|
|
25
|
+
}: {
|
|
26
|
+
children: React.ReactNode;
|
|
27
|
+
value: WorkspaceContextValue;
|
|
28
|
+
}) {
|
|
29
|
+
return (
|
|
30
|
+
<WorkspaceContext.Provider value={value}>
|
|
31
|
+
{children}
|
|
32
|
+
</WorkspaceContext.Provider>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useWorkspace() {
|
|
37
|
+
return useContext(WorkspaceContext);
|
|
38
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { BrowseTreeNode } from "../../browse-tree";
|
|
2
|
+
|
|
3
|
+
export interface BrowseDocument {
|
|
4
|
+
docid: string;
|
|
5
|
+
uri: string;
|
|
6
|
+
title: string | null;
|
|
7
|
+
collection: string;
|
|
8
|
+
relPath: string;
|
|
9
|
+
sourceExt: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DocsResponse {
|
|
13
|
+
documents: BrowseDocument[];
|
|
14
|
+
total: number;
|
|
15
|
+
limit: number;
|
|
16
|
+
offset: number;
|
|
17
|
+
pathPrefix?: string;
|
|
18
|
+
directChildrenOnly?: boolean;
|
|
19
|
+
availableDateFields: string[];
|
|
20
|
+
sortField: string;
|
|
21
|
+
sortOrder: "asc" | "desc";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface BrowseTreeResponse {
|
|
25
|
+
collections: BrowseTreeNode[];
|
|
26
|
+
totalCollections: number;
|
|
27
|
+
totalDocuments: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseBrowseLocation(search: string): {
|
|
31
|
+
collection: string;
|
|
32
|
+
path: string;
|
|
33
|
+
} {
|
|
34
|
+
const params = new URLSearchParams(search);
|
|
35
|
+
return {
|
|
36
|
+
collection: params.get("collection") ?? "",
|
|
37
|
+
path: (params.get("path") ?? "")
|
|
38
|
+
.replaceAll("\\", "/")
|
|
39
|
+
.replace(/^\/+|\/+$/g, ""),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildBrowseLocation(
|
|
44
|
+
collection?: string,
|
|
45
|
+
path?: string
|
|
46
|
+
): string {
|
|
47
|
+
const normalizedCollection = collection?.trim() ?? "";
|
|
48
|
+
const normalizedPath = (path ?? "")
|
|
49
|
+
.replaceAll("\\", "/")
|
|
50
|
+
.replace(/^\/+|\/+$/g, "");
|
|
51
|
+
|
|
52
|
+
if (!normalizedCollection) {
|
|
53
|
+
return "/browse";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const params = new URLSearchParams({
|
|
57
|
+
collection: normalizedCollection,
|
|
58
|
+
});
|
|
59
|
+
if (normalizedPath) {
|
|
60
|
+
params.set("path", normalizedPath);
|
|
61
|
+
}
|
|
62
|
+
return `/browse?${params.toString()}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildBrowseCrumbs(collection: string, path: string) {
|
|
66
|
+
const crumbs = [
|
|
67
|
+
{
|
|
68
|
+
label: collection,
|
|
69
|
+
location: buildBrowseLocation(collection),
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
if (!path) {
|
|
74
|
+
return crumbs;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const parts = path.split("/").filter(Boolean);
|
|
78
|
+
let currentPath = "";
|
|
79
|
+
for (const part of parts) {
|
|
80
|
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
81
|
+
crumbs.push({
|
|
82
|
+
label: part,
|
|
83
|
+
location: buildBrowseLocation(collection, currentPath),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return crumbs;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function formatDateFieldLabel(field: string) {
|
|
90
|
+
return field
|
|
91
|
+
.split("_")
|
|
92
|
+
.filter((token) => token.length > 0)
|
|
93
|
+
.map((token) => token.charAt(0).toUpperCase() + token.slice(1))
|
|
94
|
+
.join(" ");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getExtBadgeVariant(ext: string) {
|
|
98
|
+
switch (ext.toLowerCase()) {
|
|
99
|
+
case ".md":
|
|
100
|
+
case ".markdown":
|
|
101
|
+
return "default";
|
|
102
|
+
case ".pdf":
|
|
103
|
+
return "destructive";
|
|
104
|
+
case ".docx":
|
|
105
|
+
case ".doc":
|
|
106
|
+
return "secondary";
|
|
107
|
+
default:
|
|
108
|
+
return "outline";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -2,6 +2,7 @@ export interface WorkspaceTab {
|
|
|
2
2
|
id: string;
|
|
3
3
|
label: string;
|
|
4
4
|
location: string;
|
|
5
|
+
browseState?: WorkspaceTabBrowseState;
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
export interface WorkspaceState {
|
|
@@ -9,6 +10,10 @@ export interface WorkspaceState {
|
|
|
9
10
|
activeTabId: string;
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
export interface WorkspaceTabBrowseState {
|
|
14
|
+
expandedNodeIds: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
export interface WorkspaceStorageLike {
|
|
13
18
|
getItem(key: string): string | null;
|
|
14
19
|
setItem(key: string, value: string): void;
|
|
@@ -43,6 +48,13 @@ function getLocationLabel(location: string): string {
|
|
|
43
48
|
return "Search";
|
|
44
49
|
case "/browse": {
|
|
45
50
|
const collection = params.get("collection");
|
|
51
|
+
const browsePath = (params.get("path") ?? "")
|
|
52
|
+
.replace(/^\/+|\/+$/g, "")
|
|
53
|
+
.trim();
|
|
54
|
+
if (collection && browsePath) {
|
|
55
|
+
const leaf = browsePath.split("/").at(-1) ?? browsePath;
|
|
56
|
+
return `Browse: ${collection} / ${leaf}`;
|
|
57
|
+
}
|
|
46
58
|
return collection ? `Browse: ${collection}` : "Browse";
|
|
47
59
|
}
|
|
48
60
|
case "/ask":
|
|
@@ -68,6 +80,19 @@ function getLocationLabel(location: string): string {
|
|
|
68
80
|
}
|
|
69
81
|
}
|
|
70
82
|
|
|
83
|
+
function isWorkspaceTabBrowseState(
|
|
84
|
+
value: unknown
|
|
85
|
+
): value is WorkspaceTabBrowseState {
|
|
86
|
+
if (!value || typeof value !== "object") {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
const candidate = value as Record<string, unknown>;
|
|
90
|
+
return (
|
|
91
|
+
Array.isArray(candidate.expandedNodeIds) &&
|
|
92
|
+
candidate.expandedNodeIds.every((entry) => typeof entry === "string")
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
71
96
|
function isWorkspaceTab(value: unknown): value is WorkspaceTab {
|
|
72
97
|
if (!value || typeof value !== "object") {
|
|
73
98
|
return false;
|
|
@@ -76,7 +101,9 @@ function isWorkspaceTab(value: unknown): value is WorkspaceTab {
|
|
|
76
101
|
return (
|
|
77
102
|
typeof candidate.id === "string" &&
|
|
78
103
|
typeof candidate.label === "string" &&
|
|
79
|
-
typeof candidate.location === "string"
|
|
104
|
+
typeof candidate.location === "string" &&
|
|
105
|
+
(candidate.browseState === undefined ||
|
|
106
|
+
isWorkspaceTabBrowseState(candidate.browseState))
|
|
80
107
|
);
|
|
81
108
|
}
|
|
82
109
|
|
|
@@ -187,6 +214,37 @@ export function createWorkspaceTab(
|
|
|
187
214
|
};
|
|
188
215
|
}
|
|
189
216
|
|
|
217
|
+
export function updateActiveTabBrowseState(
|
|
218
|
+
state: WorkspaceState,
|
|
219
|
+
nextBrowseState:
|
|
220
|
+
| WorkspaceTabBrowseState
|
|
221
|
+
| ((current: WorkspaceTabBrowseState) => WorkspaceTabBrowseState)
|
|
222
|
+
): WorkspaceState {
|
|
223
|
+
const nextTabs = state.tabs.map((tab) => {
|
|
224
|
+
if (tab.id !== state.activeTabId) {
|
|
225
|
+
return tab;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const currentBrowseState: WorkspaceTabBrowseState = tab.browseState ?? {
|
|
229
|
+
expandedNodeIds: [],
|
|
230
|
+
};
|
|
231
|
+
const resolvedBrowseState =
|
|
232
|
+
typeof nextBrowseState === "function"
|
|
233
|
+
? nextBrowseState(currentBrowseState)
|
|
234
|
+
: nextBrowseState;
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
...tab,
|
|
238
|
+
browseState: resolvedBrowseState,
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
tabs: nextTabs,
|
|
244
|
+
activeTabId: state.activeTabId,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
190
248
|
export function activateWorkspaceTab(
|
|
191
249
|
state: WorkspaceState,
|
|
192
250
|
tabId: string
|