@gmickel/gno 0.40.1 → 0.40.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.40.1",
3
+ "version": "0.40.2",
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",
@@ -303,8 +303,9 @@ const ValueDisplay: FC<ValueDisplayProps> = ({ keyName, value }) => {
303
303
  <div className="flex flex-wrap gap-1.5">
304
304
  {normalizedValues.map((item, i) => (
305
305
  <Badge
306
- className="rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 font-mono text-[11px] text-primary"
306
+ className="max-w-full overflow-hidden rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 font-mono text-[11px] text-primary whitespace-nowrap text-ellipsis"
307
307
  key={`${item}-${i}`}
308
+ title={String(item)}
308
309
  variant="outline"
309
310
  >
310
311
  {String(item)}
@@ -339,8 +340,9 @@ const ValueDisplay: FC<ValueDisplayProps> = ({ keyName, value }) => {
339
340
  <div className="flex flex-wrap gap-1.5">
340
341
  {normalizedValues.map((item, i) => (
341
342
  <Badge
342
- className="font-mono text-xs"
343
+ className="max-w-full overflow-hidden font-mono text-xs whitespace-nowrap text-ellipsis"
343
344
  key={`${item}-${i}`}
345
+ title={String(item)}
344
346
  variant="secondary"
345
347
  >
346
348
  {String(item)}
@@ -250,14 +250,14 @@ function RelatedNoteItem({
250
250
  <Tooltip>
251
251
  <TooltipTrigger asChild>
252
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">
253
+ <span className="line-clamp-2 block break-all font-medium leading-tight text-foreground/90 group-hover:text-foreground">
254
254
  {doc.title || "Untitled"}
255
255
  </span>
256
256
  <SimilarityBar score={doc.score} />
257
257
  </div>
258
258
  </TooltipTrigger>
259
259
  <TooltipContent side="left" className="max-w-[300px]">
260
- <p className="break-words">{doc.title || "Untitled"}</p>
260
+ <p className="break-all">{doc.title || "Untitled"}</p>
261
261
  </TooltipContent>
262
262
  </Tooltip>
263
263
  </button>
@@ -41,9 +41,46 @@ export interface MarkdownPreviewProps {
41
41
  targetAnchor?: string;
42
42
  resolvedUri?: string;
43
43
  }>;
44
+ /** Current document URI for resolving note-relative assets */
45
+ docUri?: string;
44
46
  }
45
47
 
46
48
  const WIKI_LINK_REGEX = /\[\[([^\]|]+(?:\|[^\]]+)?)\]\]/g;
49
+ const EXTERNAL_OR_APP_SCHEME_REGEX = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;
50
+ const ABSOLUTE_FILESYSTEM_PATH_REGEX =
51
+ /^(?:\/(?:Users|home|var|tmp|private|Volumes)\/|[A-Za-z]:[\\/])/;
52
+
53
+ function resolveMarkdownAssetSrc(
54
+ src: string | undefined,
55
+ docUri?: string
56
+ ): string | undefined {
57
+ if (!src) {
58
+ return src;
59
+ }
60
+
61
+ const trimmed = src.trim();
62
+ if (trimmed.length === 0) {
63
+ return trimmed;
64
+ }
65
+
66
+ if (EXTERNAL_OR_APP_SCHEME_REGEX.test(trimmed)) {
67
+ return trimmed;
68
+ }
69
+
70
+ if (ABSOLUTE_FILESYSTEM_PATH_REGEX.test(trimmed)) {
71
+ return `/api/doc-asset?path=${encodeURIComponent(trimmed)}`;
72
+ }
73
+
74
+ if (trimmed.startsWith("/")) {
75
+ return trimmed;
76
+ }
77
+
78
+ if (!docUri) {
79
+ return trimmed;
80
+ }
81
+
82
+ return `/api/doc-asset?uri=${encodeURIComponent(docUri)}&path=${encodeURIComponent(trimmed)}`;
83
+ }
47
84
 
48
85
  function renderMarkdownWithWikiLinks(
49
86
  content: string,
@@ -364,6 +401,7 @@ const Image: FC<ComponentProps<"img"> & { node?: unknown }> = ({
364
401
  alt,
365
402
  className,
366
403
  node: _node,
404
+ src,
367
405
  ...props
368
406
  }) => (
369
407
  <img
@@ -372,6 +410,7 @@ const Image: FC<ComponentProps<"img"> & { node?: unknown }> = ({
372
410
  "my-4 max-w-full rounded-lg border border-border/40",
373
411
  className
374
412
  )}
413
+ src={src}
375
414
  {...props}
376
415
  />
377
416
  );
@@ -381,7 +420,13 @@ const Image: FC<ComponentProps<"img"> & { node?: unknown }> = ({
381
420
  * Sanitizes HTML to prevent XSS attacks.
382
421
  */
383
422
  export const MarkdownPreview = memo(
384
- ({ content, className, collection, wikiLinks }: MarkdownPreviewProps) => {
423
+ ({
424
+ content,
425
+ className,
426
+ collection,
427
+ wikiLinks,
428
+ docUri,
429
+ }: MarkdownPreviewProps) => {
385
430
  if (!content) {
386
431
  return (
387
432
  <div className={cn("text-muted-foreground italic", className)}>
@@ -416,7 +461,17 @@ export const MarkdownPreview = memo(
416
461
  td: TableCell,
417
462
  th: TableHeaderCell,
418
463
  hr: Hr,
419
- img: Image,
464
+ img: ({
465
+ node,
466
+ src,
467
+ ...props
468
+ }: ComponentProps<"img"> & { node?: unknown }) => (
469
+ <Image
470
+ {...props}
471
+ node={node}
472
+ src={resolveMarkdownAssetSrc(src, docUri)}
473
+ />
474
+ ),
420
475
  };
421
476
 
422
477
  return (
@@ -50,6 +50,11 @@ import {
50
50
  } from "../components/ui/dialog";
51
51
  import { Input } from "../components/ui/input";
52
52
  import { Separator } from "../components/ui/separator";
53
+ import {
54
+ Tooltip,
55
+ TooltipContent,
56
+ TooltipTrigger,
57
+ } from "../components/ui/tooltip";
53
58
  import { apiFetch } from "../hooks/use-api";
54
59
  import { useDocEvents } from "../hooks/use-doc-events";
55
60
  import {
@@ -904,7 +909,10 @@ export default function DocView({ navigate }: PageProps) {
904
909
 
905
910
  /** Left rail — metadata + outline */
906
911
  const renderDocumentFactsRail = () => (
907
- <nav aria-label="Document facts" className="space-y-0">
912
+ <nav
913
+ aria-label="Document facts"
914
+ className="w-full min-w-0 max-w-full space-y-0 overflow-x-hidden"
915
+ >
908
916
  {/* Frontmatter + tags */}
909
917
  {(hasFrontmatter || showStandaloneTags) && (
910
918
  <>
@@ -1005,19 +1013,19 @@ export default function DocView({ navigate }: PageProps) {
1005
1013
  <div className="mb-2 font-mono text-[10px] text-muted-foreground/50 uppercase tracking-[0.15em]">
1006
1014
  Outline
1007
1015
  </div>
1008
- <div className="space-y-0.5">
1016
+ <div className="w-full min-w-0 max-w-full space-y-0.5 overflow-x-hidden">
1009
1017
  {sections.map((section) => (
1010
1018
  <div
1011
- className={`flex items-center gap-1 rounded px-1 py-0.5 ${
1019
+ className={`group relative w-full min-w-0 max-w-full overflow-hidden rounded px-1 py-0.5 ${
1012
1020
  activeSectionAnchor === section.anchor
1013
1021
  ? "bg-primary/10 text-primary"
1014
1022
  : "text-muted-foreground"
1015
1023
  }`}
1016
1024
  key={section.anchor}
1017
- style={{ paddingLeft: `${section.level * 10}px` }}
1025
+ style={{ paddingLeft: `${section.level * 7}px` }}
1018
1026
  >
1019
1027
  <button
1020
- className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-left text-xs transition-colors hover:bg-muted/20 hover:text-foreground"
1028
+ className="flex w-full min-w-0 max-w-full cursor-pointer items-start gap-2 overflow-hidden rounded px-1 py-0.5 pr-7 text-left text-xs transition-colors hover:bg-muted/20 hover:text-foreground"
1021
1029
  onClick={() => {
1022
1030
  setShowRawView(false);
1023
1031
  requestAnimationFrame(() => {
@@ -1040,10 +1048,21 @@ export default function DocView({ navigate }: PageProps) {
1040
1048
  type="button"
1041
1049
  >
1042
1050
  <ChevronRightIcon className="size-3 shrink-0" />
1043
- <span className="truncate">{section.title}</span>
1051
+ <Tooltip>
1052
+ <TooltipTrigger asChild>
1053
+ <div className="min-w-0 max-w-full flex-1 overflow-hidden">
1054
+ <span className="line-clamp-2 block break-words leading-snug">
1055
+ {section.title}
1056
+ </span>
1057
+ </div>
1058
+ </TooltipTrigger>
1059
+ <TooltipContent side="right" className="max-w-[320px]">
1060
+ <p className="break-words">{section.title}</p>
1061
+ </TooltipContent>
1062
+ </Tooltip>
1044
1063
  </button>
1045
1064
  <button
1046
- className="cursor-pointer rounded p-1 transition-colors hover:bg-muted/20 hover:text-foreground"
1065
+ className="absolute top-1 right-1 cursor-pointer rounded p-1 opacity-0 transition-all hover:bg-muted/20 hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
1047
1066
  onClick={() => {
1048
1067
  void navigator.clipboard.writeText(
1049
1068
  `${window.location.origin}${buildDocDeepLink({
@@ -1394,9 +1413,17 @@ export default function DocView({ navigate }: PageProps) {
1394
1413
  <div className="mx-auto flex max-w-[1800px] gap-5 px-6 xl:px-8">
1395
1414
  {/* Left rail — metadata + outline */}
1396
1415
  {doc && (
1397
- <aside className="hidden w-[200px] shrink-0 border-border/15 border-r pr-2 py-6 lg:block">
1398
- <div className="sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto pr-1">
1399
- {renderDocumentFactsRail()}
1416
+ <aside
1417
+ className="hidden min-w-0 flex-none border-border/15 border-r pr-2 pt-2 pb-6 lg:block"
1418
+ style={{ width: 252, minWidth: 252, maxWidth: 252, flexBasis: 252 }}
1419
+ >
1420
+ <div
1421
+ className="sticky min-w-0 max-w-full overflow-x-hidden overflow-y-auto pr-1"
1422
+ style={{ top: 72, maxHeight: "calc(100vh - 5.5rem)" }}
1423
+ >
1424
+ <div className="min-w-0 max-w-full overflow-hidden">
1425
+ {renderDocumentFactsRail()}
1426
+ </div>
1400
1427
  </div>
1401
1428
  </aside>
1402
1429
  )}
@@ -1520,6 +1547,7 @@ export default function DocView({ navigate }: PageProps) {
1520
1547
  <MarkdownPreview
1521
1548
  collection={doc.collection}
1522
1549
  content={parsedContent.body}
1550
+ docUri={doc.uri}
1523
1551
  wikiLinks={resolvedWikiLinks}
1524
1552
  />
1525
1553
  </div>
@@ -1562,8 +1590,14 @@ export default function DocView({ navigate }: PageProps) {
1562
1590
 
1563
1591
  {/* Right rail — properties/path + relationships */}
1564
1592
  {doc && (
1565
- <aside className="hidden w-[250px] min-w-0 shrink-0 overflow-hidden border-border/15 border-l pl-2 pt-2 pb-6 lg:block">
1566
- <div className="sticky top-18 min-w-0 max-h-[calc(100vh-5.5rem)] space-y-1 overflow-y-auto overflow-x-hidden pr-1">
1593
+ <aside
1594
+ className="hidden min-w-0 flex-none overflow-hidden border-border/15 border-l pl-2 pt-2 pb-6 lg:block"
1595
+ style={{ width: 250, minWidth: 250, maxWidth: 250, flexBasis: 250 }}
1596
+ >
1597
+ <div
1598
+ className="sticky min-w-0 space-y-1 overflow-y-auto overflow-x-hidden pr-1"
1599
+ style={{ top: 72, maxHeight: "calc(100vh - 5.5rem)" }}
1600
+ >
1567
1601
  {renderPropertiesPathRail()}
1568
1602
  <BacklinksPanel
1569
1603
  docId={doc.docid}
@@ -1138,7 +1138,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
1138
1138
  ref={previewRef}
1139
1139
  >
1140
1140
  <div className="mx-auto max-w-3xl">
1141
- <MarkdownPreview content={parsedContent.body} />
1141
+ <MarkdownPreview content={parsedContent.body} docUri={doc?.uri} />
1142
1142
  </div>
1143
1143
  </div>
1144
1144
  )}
@@ -430,6 +430,24 @@ async function resolveAbsoluteDocPath(
430
430
  };
431
431
  }
432
432
 
433
+ function isAbsoluteFilesystemPath(pathValue: string): boolean {
434
+ return /^(?:\/(?:Users|home|var|tmp|private|Volumes)\/|[A-Za-z]:[\\/])/.test(
435
+ pathValue
436
+ );
437
+ }
438
+
439
+ async function isPathWithinRoot(
440
+ root: string,
441
+ candidate: string
442
+ ): Promise<boolean> {
443
+ const nodePath = await import("node:path"); // no bun equivalent
444
+ const relative = nodePath.relative(root, candidate);
445
+ return (
446
+ relative === "" ||
447
+ (!relative.startsWith("..") && !nodePath.isAbsolute(relative))
448
+ );
449
+ }
450
+
433
451
  async function listCollectionRelPaths(
434
452
  store: Pick<SqliteAdapter, "listDocuments">,
435
453
  collection: string
@@ -1445,6 +1463,98 @@ export async function handleDoc(
1445
1463
  });
1446
1464
  }
1447
1465
 
1466
+ /**
1467
+ * GET /api/doc-asset
1468
+ * Query params:
1469
+ * - path (required): relative to current doc, or absolute filesystem path
1470
+ * - uri (required for relative paths): current document uri
1471
+ */
1472
+ export async function handleDocAsset(
1473
+ store: SqliteAdapter,
1474
+ config: Config,
1475
+ url: URL
1476
+ ): Promise<Response> {
1477
+ const assetPath = url.searchParams.get("path")?.trim();
1478
+ if (!assetPath) {
1479
+ return errorResponse("VALIDATION", "Missing path parameter");
1480
+ }
1481
+
1482
+ let resolvedPath: string | null = null;
1483
+
1484
+ if (isAbsoluteFilesystemPath(assetPath)) {
1485
+ for (const collection of config.collections) {
1486
+ if (await isPathWithinRoot(collection.path, assetPath)) {
1487
+ resolvedPath = assetPath;
1488
+ break;
1489
+ }
1490
+ }
1491
+
1492
+ if (!resolvedPath) {
1493
+ return errorResponse(
1494
+ "FORBIDDEN",
1495
+ "Absolute asset path is outside configured collections",
1496
+ 403
1497
+ );
1498
+ }
1499
+ } else {
1500
+ const uri = url.searchParams.get("uri");
1501
+ if (!uri) {
1502
+ return errorResponse(
1503
+ "VALIDATION",
1504
+ "uri is required for relative asset paths"
1505
+ );
1506
+ }
1507
+
1508
+ const docResult = await store.getDocumentByUri(uri);
1509
+ if (!docResult.ok) {
1510
+ return errorResponse("RUNTIME", docResult.error.message, 500);
1511
+ }
1512
+ if (!docResult.value) {
1513
+ return errorResponse("NOT_FOUND", "Document not found", 404);
1514
+ }
1515
+
1516
+ const resolvedDoc = await resolveAbsoluteDocPath(
1517
+ config.collections,
1518
+ docResult.value
1519
+ );
1520
+ if (!resolvedDoc) {
1521
+ return errorResponse(
1522
+ "NOT_FOUND",
1523
+ "Document path could not be resolved",
1524
+ 404
1525
+ );
1526
+ }
1527
+
1528
+ const nodePath = await import("node:path"); // no bun equivalent
1529
+ const candidate = nodePath.resolve(
1530
+ nodePath.dirname(resolvedDoc.fullPath),
1531
+ assetPath
1532
+ );
1533
+
1534
+ if (!(await isPathWithinRoot(resolvedDoc.collection.path, candidate))) {
1535
+ return errorResponse(
1536
+ "FORBIDDEN",
1537
+ "Asset path escapes collection root",
1538
+ 403
1539
+ );
1540
+ }
1541
+
1542
+ resolvedPath = candidate;
1543
+ }
1544
+
1545
+ const file = Bun.file(resolvedPath);
1546
+ if (!(await file.exists())) {
1547
+ return errorResponse("NOT_FOUND", "Asset not found", 404);
1548
+ }
1549
+
1550
+ return new Response(file, {
1551
+ headers: {
1552
+ "Cache-Control": "no-store",
1553
+ "Content-Type": file.type || "application/octet-stream",
1554
+ },
1555
+ });
1556
+ }
1557
+
1448
1558
  /**
1449
1559
  * GET /api/tags
1450
1560
  * Query params: collection, prefix
@@ -3796,6 +3906,10 @@ export async function routeApi(
3796
3906
  return handleDoc(store, config, url);
3797
3907
  }
3798
3908
 
3909
+ if (path === "/api/doc-asset") {
3910
+ return handleDocAsset(store, config, url);
3911
+ }
3912
+
3799
3913
  if (path === "/api/search" && req.method === "POST") {
3800
3914
  return handleSearch(store, req);
3801
3915
  }
@@ -27,6 +27,7 @@ import {
27
27
  handleDeactivateDoc,
28
28
  handleDeleteCollection,
29
29
  handleDoc,
30
+ handleDocAsset,
30
31
  handleDocSections,
31
32
  handleDocsAutocomplete,
32
33
  handleDocs,
@@ -423,6 +424,15 @@ export async function startServer(
423
424
  );
424
425
  },
425
426
  },
427
+ "/api/doc-asset": {
428
+ GET: async (req: Request) => {
429
+ const url = new URL(req.url);
430
+ return withSecurityHeaders(
431
+ await handleDocAsset(store, ctxHolder.config, url),
432
+ isDev
433
+ );
434
+ },
435
+ },
426
436
  "/api/events": {
427
437
  GET: () =>
428
438
  withSecurityHeaders(