@gmickel/gno 0.33.3 → 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 +12 -1
- package/bunfig.toml +3 -0
- package/package.json +7 -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 -64
- 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 +487 -296
- package/src/serve/public/pages/DocumentEditor.tsx +1 -11
- package/src/serve/public/pages/GraphView.tsx +5 -4
- package/src/serve/public/pages/Search.tsx +1 -1
- package/src/serve/routes/api.ts +47 -0
- package/src/serve/server.ts +5 -0
- package/src/store/sqlite/adapter.ts +240 -241
|
@@ -29,6 +29,103 @@ interface ParsedFrontmatter {
|
|
|
29
29
|
body: string;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
const INLINE_ARRAY_REGEX = /^\[([^\]]*)\]$/;
|
|
33
|
+
|
|
34
|
+
function normalizeScalar(value: string): unknown {
|
|
35
|
+
const trimmed = value.trim();
|
|
36
|
+
if (
|
|
37
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
38
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
39
|
+
) {
|
|
40
|
+
return trimmed.slice(1, -1);
|
|
41
|
+
}
|
|
42
|
+
if (/^-?\d+\.?\d*$/.test(trimmed)) {
|
|
43
|
+
return Number.parseFloat(trimmed);
|
|
44
|
+
}
|
|
45
|
+
if (trimmed === "true") return true;
|
|
46
|
+
if (trimmed === "false") return false;
|
|
47
|
+
return trimmed;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseYamlFrontmatterBlock(yamlBlock: string): Record<string, unknown> {
|
|
51
|
+
const data: Record<string, unknown> = {};
|
|
52
|
+
const lines = yamlBlock.split("\n");
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < lines.length; i++) {
|
|
55
|
+
const line = lines[i]?.trimEnd();
|
|
56
|
+
if (!line || line.trimStart().startsWith("#")) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const match = line.match(/^([^:]+):\s*(.*)$/);
|
|
61
|
+
if (!match) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const [, rawKey = "", rawValue = ""] = match;
|
|
66
|
+
const key = rawKey.trim();
|
|
67
|
+
const value = rawValue.trim();
|
|
68
|
+
|
|
69
|
+
const inlineArrayMatch = INLINE_ARRAY_REGEX.exec(value);
|
|
70
|
+
if (inlineArrayMatch?.[1]) {
|
|
71
|
+
data[key] = inlineArrayMatch[1]
|
|
72
|
+
.split(",")
|
|
73
|
+
.map((item) => normalizeScalar(item))
|
|
74
|
+
.filter((item) => item !== "");
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (value.length === 0) {
|
|
79
|
+
const arrayItems: unknown[] = [];
|
|
80
|
+
let multilineValue: string[] = [];
|
|
81
|
+
let multilineMode = false;
|
|
82
|
+
|
|
83
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
84
|
+
const nextLine = lines[j];
|
|
85
|
+
if (nextLine === undefined) {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const trimmedNext = nextLine.trimEnd();
|
|
90
|
+
if (!trimmedNext) {
|
|
91
|
+
if (multilineMode) {
|
|
92
|
+
multilineValue.push("");
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!/^\s/.test(nextLine)) {
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const arrayMatch = nextLine.match(/^\s*-\s*(.*)$/);
|
|
102
|
+
if (arrayMatch) {
|
|
103
|
+
arrayItems.push(normalizeScalar(arrayMatch[1] ?? ""));
|
|
104
|
+
i = j;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
multilineMode = true;
|
|
109
|
+
multilineValue.push(nextLine.trim());
|
|
110
|
+
i = j;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (arrayItems.length > 0) {
|
|
114
|
+
data[key] = arrayItems;
|
|
115
|
+
} else if (multilineValue.length > 0) {
|
|
116
|
+
data[key] = multilineValue.join("\n");
|
|
117
|
+
} else {
|
|
118
|
+
data[key] = "";
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
data[key] = normalizeScalar(value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return data;
|
|
127
|
+
}
|
|
128
|
+
|
|
32
129
|
/**
|
|
33
130
|
* Parse YAML frontmatter from markdown content.
|
|
34
131
|
* Returns empty data if no frontmatter found.
|
|
@@ -49,68 +146,25 @@ export function parseFrontmatter(content: string): ParsedFrontmatter {
|
|
|
49
146
|
|
|
50
147
|
const yamlBlock = trimmed.slice(4, endIndex).trim();
|
|
51
148
|
const body = trimmed.slice(endIndex + 4).trimStart();
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// Save previous multiline value
|
|
66
|
-
if (currentKey && multilineValue.length > 0) {
|
|
67
|
-
const existing = data[currentKey];
|
|
68
|
-
if (typeof existing === "string" && existing.endsWith("|")) {
|
|
69
|
-
data[currentKey] = multilineValue.join("\n");
|
|
70
|
-
} else if (typeof existing === "string") {
|
|
71
|
-
data[currentKey] = `${existing}\n${multilineValue.join("\n")}`;
|
|
72
|
-
} else {
|
|
73
|
-
data[currentKey] = multilineValue.join("\n");
|
|
74
|
-
}
|
|
75
|
-
multilineValue = [];
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Parse new key: value
|
|
79
|
-
const match = line.match(/^([^:]+):\s*(.*)$/);
|
|
80
|
-
if (match) {
|
|
81
|
-
currentKey = match[1].trim();
|
|
82
|
-
let value: unknown = match[2].trim();
|
|
83
|
-
|
|
84
|
-
// Remove surrounding quotes
|
|
85
|
-
if (
|
|
86
|
-
(value as string).startsWith('"') &&
|
|
87
|
-
(value as string).endsWith('"')
|
|
88
|
-
) {
|
|
89
|
-
value = (value as string).slice(1, -1);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Parse numbers
|
|
93
|
-
if (/^-?\d+\.?\d*$/.test(value as string)) {
|
|
94
|
-
value = Number.parseFloat(value as string);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
data[currentKey] = value;
|
|
149
|
+
if (typeof Bun !== "undefined" && Bun.YAML) {
|
|
150
|
+
try {
|
|
151
|
+
const parsed = Bun.YAML.parse(yamlBlock);
|
|
152
|
+
return {
|
|
153
|
+
data:
|
|
154
|
+
parsed && typeof parsed === "object"
|
|
155
|
+
? (parsed as Record<string, unknown>)
|
|
156
|
+
: {},
|
|
157
|
+
body,
|
|
158
|
+
};
|
|
159
|
+
} catch {
|
|
160
|
+
// Fall through to the browser-safe parser below.
|
|
98
161
|
}
|
|
99
162
|
}
|
|
100
163
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
data[currentKey] = multilineValue.join("\n");
|
|
106
|
-
} else if (typeof existing === "string") {
|
|
107
|
-
data[currentKey] = `${existing}\n${multilineValue.join("\n")}`;
|
|
108
|
-
} else {
|
|
109
|
-
data[currentKey] = multilineValue.join("\n");
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return { data, body };
|
|
164
|
+
return {
|
|
165
|
+
data: parseYamlFrontmatterBlock(yamlBlock),
|
|
166
|
+
body,
|
|
167
|
+
};
|
|
114
168
|
}
|
|
115
169
|
|
|
116
170
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -235,9 +289,55 @@ interface ValueDisplayProps {
|
|
|
235
289
|
const ValueDisplay: FC<ValueDisplayProps> = ({ keyName, value }) => {
|
|
236
290
|
// Handle arrays (tags, etc.)
|
|
237
291
|
if (Array.isArray(value)) {
|
|
292
|
+
const normalizedValues = value.filter(
|
|
293
|
+
(item): item is string | number =>
|
|
294
|
+
typeof item === "string" || typeof item === "number"
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
if (normalizedValues.length === 0) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (isTagsKey(keyName)) {
|
|
302
|
+
return (
|
|
303
|
+
<div className="flex flex-wrap gap-1.5">
|
|
304
|
+
{normalizedValues.map((item, i) => (
|
|
305
|
+
<Badge
|
|
306
|
+
className="rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 font-mono text-[11px] text-primary"
|
|
307
|
+
key={`${item}-${i}`}
|
|
308
|
+
variant="outline"
|
|
309
|
+
>
|
|
310
|
+
{String(item)}
|
|
311
|
+
</Badge>
|
|
312
|
+
))}
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (
|
|
318
|
+
normalizedValues.every((item) => typeof item === "string" && isUrl(item))
|
|
319
|
+
) {
|
|
320
|
+
return (
|
|
321
|
+
<div className="space-y-2">
|
|
322
|
+
{normalizedValues.map((item, i) => (
|
|
323
|
+
<a
|
|
324
|
+
className="flex max-w-full items-start gap-1 text-primary hover:underline"
|
|
325
|
+
href={String(item)}
|
|
326
|
+
key={`${item}-${i}`}
|
|
327
|
+
rel="noopener noreferrer"
|
|
328
|
+
target="_blank"
|
|
329
|
+
>
|
|
330
|
+
<span className="break-all">{String(item)}</span>
|
|
331
|
+
<ExternalLinkIcon className="mt-0.5 size-3 shrink-0 opacity-60" />
|
|
332
|
+
</a>
|
|
333
|
+
))}
|
|
334
|
+
</div>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
238
338
|
return (
|
|
239
|
-
<div className="flex flex-wrap gap-1">
|
|
240
|
-
{
|
|
339
|
+
<div className="flex flex-wrap gap-1.5">
|
|
340
|
+
{normalizedValues.map((item, i) => (
|
|
241
341
|
<Badge
|
|
242
342
|
className="font-mono text-xs"
|
|
243
343
|
key={`${item}-${i}`}
|
|
@@ -318,15 +418,15 @@ const FrontmatterItem: FC<FrontmatterItemProps> = ({
|
|
|
318
418
|
return (
|
|
319
419
|
<div
|
|
320
420
|
className={cn(
|
|
321
|
-
"group min-w-0 rounded-lg bg-muted/20 p-
|
|
421
|
+
"group min-w-0 rounded-lg bg-muted/20 p-2.5 transition-colors hover:bg-muted/30",
|
|
322
422
|
isLarge && "col-span-full"
|
|
323
423
|
)}
|
|
324
424
|
>
|
|
325
|
-
<div className="mb-1
|
|
425
|
+
<div className="mb-1 flex items-center gap-1.5 text-muted-foreground text-[11px]">
|
|
326
426
|
{icon}
|
|
327
427
|
<span className="uppercase tracking-wider">{formatKey(keyName)}</span>
|
|
328
428
|
</div>
|
|
329
|
-
<div className="text-sm">
|
|
429
|
+
<div className="text-sm leading-relaxed">
|
|
330
430
|
<ValueDisplay keyName={keyName} value={value} />
|
|
331
431
|
</div>
|
|
332
432
|
</div>
|
|
@@ -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
|
+
}
|