wiki-viewer 1.8.2 → 1.8.3

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.
Files changed (98) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +5 -5
  3. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
  4. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  12. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  15. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  16. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  21. package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/index.html +1 -1
  26. package/.next/standalone/.next/server/app/index.rsc +3 -3
  27. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  29. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  30. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +2 -2
  31. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  32. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  33. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  35. package/.next/standalone/.next/server/app/signin/page/build-manifest.json +2 -2
  36. package/.next/standalone/.next/server/app/signin/page_client-reference-manifest.js +1 -1
  37. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-inabx._.js +1 -1
  38. package/.next/standalone/.next/server/chunks/[root-of-the-server]__00lep-u._.js +1 -1
  39. package/.next/standalone/.next/server/chunks/ssr/{0~w0_katex_dist_katex_mjs_04mhawr._.js → 0~w0_katex_dist_katex_mjs_0hyln.b._.js} +1 -1
  40. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__09~x2v~._.js → [root-of-the-server]__0du58sw._.js} +2 -2
  41. package/.next/standalone/.next/server/chunks/ssr/_0pqaawz._.js +19 -19
  42. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0shlang._.js → node_modules__pnpm_0.kyqfq._.js} +1 -1
  43. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_04n0h6.._.js → node_modules__pnpm_0.~goq~._.js} +1 -1
  44. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_011vy_q._.js → node_modules__pnpm_00y7foi._.js} +2 -2
  45. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0yiz.-t._.js → node_modules__pnpm_03.1jli._.js} +1 -1
  46. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_093dp2q._.js → node_modules__pnpm_03z3na-._.js} +1 -1
  47. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_01o_5qt._.js → node_modules__pnpm_05~w-ok._.js} +1 -1
  48. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0b8qx0g._.js → node_modules__pnpm_09d5yyu._.js} +1 -1
  49. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wmx9kq._.js → node_modules__pnpm_09eo8jh._.js} +2 -2
  50. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0nw5ou2._.js → node_modules__pnpm_0adm8h6._.js} +1 -1
  51. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0bbc---._.js → node_modules__pnpm_0arbjru._.js} +1 -1
  52. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0t9jb7d._.js → node_modules__pnpm_0b5fla7._.js} +1 -1
  53. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_124u4o2._.js → node_modules__pnpm_0beormu._.js} +2 -2
  54. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_13tajgx._.js → node_modules__pnpm_0c7u76a._.js} +2 -2
  55. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09ej62_._.js → node_modules__pnpm_0dbr7c2._.js} +1 -1
  56. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0s0ur~d._.js → node_modules__pnpm_0d~ida~._.js} +1 -1
  57. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09ljbdz._.js → node_modules__pnpm_0elpfsb._.js} +2 -2
  58. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0hlzy8f._.js → node_modules__pnpm_0fh.6ir._.js} +1 -1
  59. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09~_ob2._.js → node_modules__pnpm_0fm6wcx._.js} +1 -1
  60. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0o.dn8o._.js → node_modules__pnpm_0iikbcy._.js} +1 -1
  61. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0hk6xys._.js → node_modules__pnpm_0mvih7b._.js} +2 -2
  62. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0a01ng7._.js → node_modules__pnpm_0qd2ovj._.js} +2 -2
  63. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0c.309.._.js → node_modules__pnpm_0s4yn43._.js} +1 -1
  64. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0sq3c2r._.js → node_modules__pnpm_0s8fvbn._.js} +1 -1
  65. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0xfx0sf._.js → node_modules__pnpm_0tq8zs6._.js} +1 -1
  66. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_11sut1u._.js → node_modules__pnpm_0vyv.lz._.js} +1 -1
  67. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0c8aunj._.js → node_modules__pnpm_0xa.ko1._.js} +1 -1
  68. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0.96tmo._.js → node_modules__pnpm_12.ymhl._.js} +1 -1
  69. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  70. package/.next/standalone/.next/server/middleware-manifest.json +1 -1
  71. package/.next/standalone/.next/server/pages/404.html +1 -1
  72. package/.next/standalone/.next/server/pages/500.html +1 -1
  73. package/.next/standalone/.next/static/chunks/{0vp-ahhagxptc.js → 06iw_iy5e90d4.js} +1 -1
  74. package/.next/standalone/.next/static/chunks/0d~o0h0ssgra7.js +129 -0
  75. package/.next/standalone/.next/static/chunks/167ph4xws_-61.css +2 -0
  76. package/.next/standalone/.next/static/chunks/{turbopack-12~z-mr7b9b50.js → turbopack-0lf4~b-ofzbm6.js} +1 -1
  77. package/.next/standalone/AGENTS.md +104 -0
  78. package/.next/standalone/package.json +1 -1
  79. package/.next/standalone/src/app/api/wiki/app/route.ts +7 -4
  80. package/.next/standalone/src/app/page.tsx +580 -192
  81. package/.next/standalone/src/components/editor/backlinks-panel.tsx +152 -0
  82. package/.next/standalone/src/components/editor/document-outline.tsx +221 -0
  83. package/.next/standalone/src/components/editor/editor.tsx +145 -48
  84. package/.next/standalone/src/components/editor/node-app-viewer.tsx +65 -7
  85. package/.next/standalone/src/components/editor/slash-commands.tsx +216 -41
  86. package/.next/standalone/src/components/editor/source-viewer.tsx +29 -2
  87. package/.next/standalone/src/components/search/search-command-dialog.tsx +183 -46
  88. package/.next/standalone/src/lib/app-runner.ts +57 -16
  89. package/.next/standalone/src/stores/pin-store.ts +55 -0
  90. package/.next/standalone/src/stores/recent-store.ts +50 -0
  91. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  92. package/package.json +1 -1
  93. package/.next/standalone/.next/static/chunks/0c_~zevf2n0r4.css +0 -2
  94. package/.next/standalone/.next/static/chunks/16r--8~rpehcz.js +0 -129
  95. package/.next/standalone/src/components/wiki/markdown-preview.tsx +0 -120
  96. /package/.next/standalone/.next/static/{t_k0ztuf0V-iA-kgeez18 → Pkj31aCfRVk0Zy2rKA1wf}/_buildManifest.js +0 -0
  97. /package/.next/standalone/.next/static/{t_k0ztuf0V-iA-kgeez18 → Pkj31aCfRVk0Zy2rKA1wf}/_clientMiddlewareManifest.js +0 -0
  98. /package/.next/standalone/.next/static/{t_k0ztuf0V-iA-kgeez18 → Pkj31aCfRVk0Zy2rKA1wf}/_ssgManifest.js +0 -0
@@ -6,6 +6,7 @@ import {
6
6
  Check,
7
7
  ChevronDown,
8
8
  ChevronRight,
9
+ Copy,
9
10
  Download,
10
11
  File,
11
12
  FilePlus,
@@ -15,20 +16,24 @@ import {
15
16
  FolderPlus,
16
17
  Globe,
17
18
  Image as ImageIcon,
19
+ Link,
18
20
  Loader2,
19
21
  Maximize2,
22
+ MoreHorizontal,
20
23
  PanelLeftClose,
21
24
  PanelLeftOpen,
22
25
  Pencil,
26
+ Pin,
23
27
  Plus,
24
28
  RefreshCw,
25
29
  Settings,
30
+ Star,
26
31
  Terminal,
27
32
  Trash2,
28
33
  Upload,
29
34
  X,
30
35
  } from "lucide-react";
31
- import { useCallback, useEffect, useRef, useState } from "react";
36
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
32
37
 
33
38
  import { ConfirmDialog } from "@/components/confirm-dialog";
34
39
  import { CsvViewer } from "@/components/editor/csv-viewer";
@@ -54,15 +59,22 @@ import {
54
59
  DropdownMenuTrigger,
55
60
  } from "@/components/ui/dropdown-menu";
56
61
  import { getActiveWorkspaceId, withWs, wsFetch } from "@/lib/workspace-client";
62
+ import { markdownToHtml } from "@/lib/markdown/to-html";
57
63
  import { ThemeToggle } from "@/components/theme-toggle";
58
64
  import { AuthSettingsSheet } from "@/components/auth-settings-sheet";
59
65
  import { Button } from "@/components/ui/button";
60
66
  import { Card } from "@/components/ui/card";
61
- import { FrontmatterHeader } from "@/components/wiki/frontmatter-header";
62
- import { MarkdownPreview } from "@/components/wiki/markdown-preview";
63
- import { parseFrontmatter } from "@/lib/markdown/parse-frontmatter";
64
67
 
65
- import { showError } from "@/lib/toast";
68
+ import { showError, showSuccess } from "@/lib/toast";
69
+ import {
70
+ ContextMenu,
71
+ ContextMenuContent,
72
+ ContextMenuItem,
73
+ ContextMenuSeparator,
74
+ ContextMenuTrigger,
75
+ } from "@/components/ui/context-menu";
76
+ import { useRecentStore } from "@/stores/recent-store";
77
+ import { usePinStore } from "@/stores/pin-store";
66
78
  import { cn } from "@/lib/utils";
67
79
  import { AIPanel } from "@/components/ai-panel/ai-panel";
68
80
  import { SearchCommandDialog } from "@/components/search/search-command-dialog";
@@ -113,8 +125,14 @@ function viewerKindFor(
113
125
  if (nodeType === "node-app") return "node-app";
114
126
  if (nodeType === "app") return "app";
115
127
  if (nodeType === "dir") return "fallback";
128
+ const base = filename.split("/").pop() ?? filename;
129
+ // Dotfile with no real extension (".env", ".gitignore", ".bashrc"):
130
+ // `".env".split(".").pop()` -> "env", which would match nothing below,
131
+ // so treat any leading-dot name as text and let the viewer sniff bytes.
132
+ if (base.startsWith(".") && base.indexOf(".", 1) === -1) return "source";
116
133
  const fileExt = ext(filename);
117
- if (!fileExt) return "fallback";
134
+ // No extension at all ("Makefile", "LICENSE", "Dockerfile"): assume text.
135
+ if (!fileExt) return "source";
118
136
  if (["md", "markdown"].includes(fileExt)) return "editor";
119
137
  if (fileExt === "txt") return "text";
120
138
  if (["csv", "tsv"].includes(fileExt)) return "csv";
@@ -145,7 +163,11 @@ function viewerKindFor(
145
163
  ].includes(fileExt)
146
164
  )
147
165
  return "source";
148
- return "fallback";
166
+ // Default: assume text and let SourceViewer sniff the bytes. If the file is
167
+ // actually binary, SourceViewer degrades to a download/reveal fallback.
168
+ // This avoids a brittle text-extension whitelist that always misses
169
+ // something (.env, .ini, .lock, .gradle, .properties, ...).
170
+ return "source";
149
171
  }
150
172
 
151
173
  const TEXT_EDITABLE_EXTS = new Set([
@@ -267,6 +289,44 @@ export default function Page() {
267
289
 
268
290
  const editorCurrentPath = useEditorStore((s) => s.currentPath);
269
291
 
292
+ // Recent files
293
+ const recents = useRecentStore((s) => s.recents);
294
+ const [pinnedCollapsed, setPinnedCollapsed] = useState(false);
295
+ const [recentCollapsed, setRecentCollapsed] = useState(false);
296
+
297
+ // Pins
298
+ const pins = usePinStore((s) => s.pins);
299
+
300
+ // Sidebar scroll container ref (for keyboard nav)
301
+ const sidebarScrollRef = useRef<HTMLDivElement>(null);
302
+
303
+ // Agent presence: paths touched by agents in last 60s
304
+ const activity = useAIPanelStore((s) => s.activity);
305
+ const activePaths = useMemo(() => {
306
+ const cutoff = new Date(Date.now() - 60 * 1000).toISOString();
307
+ const paths = new Set<string>();
308
+ for (const ev of activity) {
309
+ // Only agent-authored events count as "agent presence".
310
+ if (ev.at >= cutoff && ev.path && ev.by?.startsWith("ai:"))
311
+ paths.add(ev.path);
312
+ }
313
+ return paths;
314
+ }, [activity]);
315
+
316
+ // Poll activity for presence indicator
317
+ useEffect(() => {
318
+ const load = () => { void useAIPanelStore.getState().loadActivity(); };
319
+ load();
320
+ const id = setInterval(load, 10_000);
321
+ return () => clearInterval(id);
322
+ }, []);
323
+
324
+ // Reload recents + pins when workspace changes
325
+ useEffect(() => {
326
+ useRecentStore.getState().loadForWorkspace(activeWorkspaceId);
327
+ usePinStore.getState().loadForWorkspace(activeWorkspaceId);
328
+ }, [activeWorkspaceId]);
329
+
270
330
  // Path captured from the URL at first render, before any effect can clear it.
271
331
  // Restore reads from this ref (never the live URL) so URL sync can't break it.
272
332
  const initialUrlPathRef = useRef<string | null>(
@@ -407,7 +467,7 @@ export default function Page() {
407
467
  const collectExpandedPaths = useCallback((nodes: TreeNode[]): string[] => {
408
468
  const paths: string[] = [];
409
469
  for (const n of nodes) {
410
- if ((n.type === "dir" || n.type === "app") && n.expanded && n.children) {
470
+ if ((n.type === "dir" || n.type === "app" || n.type === "node-app") && n.expanded && n.children) {
411
471
  paths.push(n.path);
412
472
  paths.push(...collectExpandedPaths(n.children));
413
473
  }
@@ -438,6 +498,15 @@ export default function Page() {
438
498
 
439
499
  const refreshViewer = useCallback(async () => {
440
500
  if (!openFile) return;
501
+ if (isMarkdown(openFile.name)) {
502
+ setFileLoading(true);
503
+ try {
504
+ await useEditorStore.getState().loadPage(openFile.path);
505
+ } finally {
506
+ setFileLoading(false);
507
+ }
508
+ return;
509
+ }
441
510
  const kind = viewerKindFor(openFile.name, openFile.nodeType);
442
511
  if (!["editor", "text"].includes(kind) && !isText(openFile.name)) return;
443
512
  setFileLoading(true);
@@ -456,6 +525,12 @@ export default function Page() {
456
525
  setFileLoading(false);
457
526
  }, [openFile]);
458
527
 
528
+ useEffect(() => {
529
+ if (!openFile || !isMarkdown(openFile.name)) return;
530
+ if (useEditorStore.getState().currentPath === openFile.path) return;
531
+ void useEditorStore.getState().loadPage(openFile.path);
532
+ }, [openFile]);
533
+
459
534
  // Keep watcher refs in sync
460
535
  useEffect(() => {
461
536
  openFileRef.current = openFile;
@@ -529,7 +604,7 @@ export default function Page() {
529
604
  }, [rootConfigured, reloadDir]);
530
605
 
531
606
  async function toggleFolder(node: TreeNode) {
532
- if (node.type !== "dir" && node.type !== "app") return;
607
+ if (node.type !== "dir" && node.type !== "app" && node.type !== "node-app") return;
533
608
  if (!node.expanded) {
534
609
  if (node.children === undefined) {
535
610
  setRoots((prev) =>
@@ -561,6 +636,13 @@ export default function Page() {
561
636
  }, []);
562
637
 
563
638
  async function openViewer(node: TreeNode) {
639
+ // Push to recent files (only real files, not dirs/apps)
640
+ if (node.type === "file") {
641
+ useRecentStore.getState().push(
642
+ { path: node.path, name: node.name },
643
+ activeWorkspaceId,
644
+ );
645
+ }
564
646
  setOpenFile({
565
647
  path: node.path,
566
648
  name: node.name,
@@ -575,6 +657,15 @@ export default function Page() {
575
657
  setSaveError(null);
576
658
  setFileContent(null);
577
659
  setFileRevision(0);
660
+ if (node.type === "file" && isMarkdown(node.name)) {
661
+ setFileLoading(true);
662
+ try {
663
+ await useEditorStore.getState().loadPage(node.path);
664
+ } finally {
665
+ setFileLoading(false);
666
+ }
667
+ return;
668
+ }
578
669
  const kind = viewerKindFor(node.name, node.type);
579
670
  if (!["editor", "text"].includes(kind) && !isText(node.name)) return;
580
671
  setFileLoading(true);
@@ -837,6 +928,120 @@ export default function Page() {
837
928
  a.remove();
838
929
  }
839
930
 
931
+ function copyPath(path: string) {
932
+ void navigator.clipboard.writeText(path);
933
+ showSuccess("Path copied");
934
+ }
935
+
936
+ function copyWikiLink(name: string) {
937
+ const slug = name.replace(/\.(md|markdown)$/i, "");
938
+ void navigator.clipboard.writeText(`[[${slug}]]`);
939
+ showSuccess("Wiki link copied");
940
+ }
941
+
942
+ function copyUrl(path: string) {
943
+ const url = new URL(location.href);
944
+ url.searchParams.set("path", path);
945
+ if (activeWorkspaceId) url.searchParams.set("ws", activeWorkspaceId);
946
+ void navigator.clipboard.writeText(url.toString());
947
+ showSuccess("URL copied");
948
+ }
949
+
950
+ async function getTextContent(path: string) {
951
+ if (openFile?.path === path && fileContent !== null) return fileContent;
952
+ if (useEditorStore.getState().currentPath === path) {
953
+ return useEditorStore.getState().content;
954
+ }
955
+ const res = await wsFetch(`/api/wiki/content?path=${encodeURIComponent(path)}`);
956
+ if (!res.ok) throw new Error("Cannot copy content");
957
+ const data: { content: string } = await res.json();
958
+ return data.content;
959
+ }
960
+
961
+ async function copyRawContent(path: string) {
962
+ try {
963
+ const content = await getTextContent(path);
964
+ await navigator.clipboard.writeText(content);
965
+ showSuccess("Raw content copied");
966
+ } catch {
967
+ showError("Could not copy file content");
968
+ }
969
+ }
970
+
971
+ async function copyFormattedContent(path: string, name: string) {
972
+ try {
973
+ const content = await getTextContent(path);
974
+ if (!isMarkdown(name)) {
975
+ await navigator.clipboard.writeText(content);
976
+ showSuccess("Content copied");
977
+ return;
978
+ }
979
+ const html = await markdownToHtml(content, { pagePath: path, sanitize: true });
980
+ if ("ClipboardItem" in window && navigator.clipboard.write) {
981
+ await navigator.clipboard.write([
982
+ new ClipboardItem({
983
+ "text/html": new Blob([html], { type: "text/html" }),
984
+ "text/plain": new Blob([content], { type: "text/plain" }),
985
+ }),
986
+ ]);
987
+ } else {
988
+ await navigator.clipboard.writeText(content);
989
+ }
990
+ showSuccess("Formatted content copied");
991
+ } catch {
992
+ showError("Could not copy formatted content");
993
+ }
994
+ }
995
+
996
+ function renderCopyMenu(node: { path: string; name: string }) {
997
+ return (
998
+ <DropdownMenu>
999
+ <DropdownMenuTrigger asChild>
1000
+ <Button
1001
+ size="sm"
1002
+ variant="ghost"
1003
+ className="h-7 gap-1.5 px-2 text-xs"
1004
+ title="Copy path, wiki link, or URL"
1005
+ >
1006
+ <Copy className="h-3.5 w-3.5" />
1007
+ Copy
1008
+ <ChevronDown className="h-3 w-3 opacity-60" />
1009
+ </Button>
1010
+ </DropdownMenuTrigger>
1011
+ <DropdownMenuContent align="end" className="w-44">
1012
+ <DropdownMenuItem onClick={() => copyPath(node.path)}>
1013
+ <Copy className="mr-2 h-3.5 w-3.5" />
1014
+ Copy path
1015
+ </DropdownMenuItem>
1016
+ {isMarkdown(node.name) && (
1017
+ <DropdownMenuItem onClick={() => copyWikiLink(node.name)}>
1018
+ <FileText className="mr-2 h-3.5 w-3.5" />
1019
+ Copy wiki link
1020
+ </DropdownMenuItem>
1021
+ )}
1022
+ <DropdownMenuSeparator />
1023
+ <DropdownMenuItem onClick={() => copyUrl(node.path)}>
1024
+ <Link className="mr-2 h-3.5 w-3.5" />
1025
+ Copy URL
1026
+ </DropdownMenuItem>
1027
+ {isText(node.name) && (
1028
+ <>
1029
+ <DropdownMenuSeparator />
1030
+ <DropdownMenuItem onClick={() => void copyRawContent(node.path)}>
1031
+ <FileText className="mr-2 h-3.5 w-3.5" />
1032
+ Copy raw content
1033
+ </DropdownMenuItem>
1034
+ <DropdownMenuItem onClick={() => void copyFormattedContent(node.path, node.name)}>
1035
+ <FileText className="mr-2 h-3.5 w-3.5" />
1036
+ Copy formatted content
1037
+ </DropdownMenuItem>
1038
+ </>
1039
+ )}
1040
+ </DropdownMenuContent>
1041
+ </DropdownMenu>
1042
+ );
1043
+ }
1044
+
840
1045
  async function handleDelete() {
841
1046
  if (!deletingPath) return;
842
1047
  await wsFetch("/api/wiki", {
@@ -931,149 +1136,241 @@ export default function Page() {
931
1136
  const contentWidthClass = widthAwareViewer ? VIEW_WIDTH_CLASS[viewWidth] : "";
932
1137
 
933
1138
  function renderNodes(nodes: TreeNode[], depth = 0): React.ReactNode {
934
- return nodes.map((node) => (
935
- <div key={node.path}>
936
- <div
937
- role="treeitem"
938
- tabIndex={0}
939
- draggable
940
- onKeyDown={(e) => {
941
- if (e.key === "Enter" || e.key === " ") {
942
- e.preventDefault();
943
- if (node.type === "dir") toggleFolder(node);
944
- else if (node.type === "app") { openViewer(node); toggleFolder(node); }
945
- else openViewer(node);
946
- }
947
- }}
948
- onDragStart={(e) => handleDragStart(e, node)}
949
- onDragOver={(e) =>
950
- node.type === "dir"
951
- ? handleDragOver(e, node.path, "dir")
952
- : e.preventDefault()
953
- }
954
- onDragLeave={() => setDragOverPath(null)}
955
- onDrop={(e) =>
956
- node.type === "dir"
957
- ? handleDropOnFolder(e, node.path)
958
- : e.preventDefault()
959
- }
960
- className={cn(
961
- "flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer group transition-colors select-none",
962
- openFile?.path === node.path
963
- ? "bg-accent-soft text-foreground font-medium"
964
- : "hover:bg-muted",
965
- dragOverPath === node.path && "ring-2 ring-primary bg-primary-soft",
966
- node.name.startsWith(".") && "opacity-40",
967
- )}
968
- style={{ paddingLeft: `${depth * 14 + 8}px` }}
969
- onClick={() => {
970
- if (node.type === "dir") toggleFolder(node);
971
- else if (node.type === "app") { openViewer(node); toggleFolder(node); }
972
- else openViewer(node);
973
- }}
974
- >
975
- {(node.type === "dir" || node.type === "app") ? (
976
- node.loading ? (
977
- <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
978
- ) : node.expanded ? (
979
- <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
980
- ) : (
981
- <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
982
- )
983
- ) : (
984
- <span className="w-3.5 shrink-0" />
985
- )}
986
-
987
- {node.type === "dir" ? (
988
- node.expanded ? (
989
- <FolderOpen className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-warning")} />
990
- ) : (
991
- <Folder className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-warning")} />
992
- )
993
- ) : node.type === "app" ? (
994
- <Globe className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-foreground/70")} />
995
- ) : node.type === "node-app" ? (
996
- <Terminal className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-emerald-500")} />
997
- ) : isHtmlFile(node.name) ? (
998
- <Globe className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-foreground/60")} />
999
- ) : isImage(node.name) ? (
1000
- <ImageIcon className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-sunshine-700")} />
1001
- ) : isText(node.name) ? (
1002
- <FileText className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-foreground/70")} />
1003
- ) : (
1004
- <File className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-foreground/60")} />
1005
- )}
1006
-
1007
- <span className="flex-1 truncate">{node.name}</span>
1008
-
1009
- <div
1010
- className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
1011
- onClick={(e) => e.stopPropagation()}
1012
- onKeyDown={(e) => e.stopPropagation()}
1013
- >
1014
- {node.type === "dir" && (
1015
- <>
1016
- <Button
1017
- size="sm"
1018
- variant="ghost"
1019
- className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
1020
- title="New file here (default .md)"
1021
- onClick={async () => {
1022
- if (!node.expanded) await toggleFolder(node);
1023
- setNewFileParent(node.path);
1024
- setNewFileName("");
1025
- setFileCreateError(null);
1026
- }}
1027
- >
1028
- <FilePlus className="h-3 w-3" />
1029
- </Button>
1030
- <Button
1031
- size="sm"
1032
- variant="ghost"
1033
- className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
1034
- title="Upload here"
1035
- onClick={() => triggerUpload(node.path)}
1036
- >
1037
- <Upload className="h-3 w-3" />
1038
- </Button>
1039
- <Button
1040
- size="sm"
1041
- variant="ghost"
1042
- className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
1043
- title="New subfolder"
1044
- onClick={() => {
1045
- setNewFolderParent(node.path);
1046
- setNewFolderName("");
1047
- setFolderError(null);
1048
- }}
1049
- >
1050
- <FolderPlus className="h-3 w-3" />
1051
- </Button>
1052
- </>
1053
- )}
1054
- <Button
1055
- size="sm"
1056
- variant="ghost"
1057
- className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
1058
- title={node.type === "file" ? "Download" : "Download as zip"}
1059
- onClick={() => handleDownload(node)}
1060
- >
1061
- <Download className="h-3 w-3" />
1062
- </Button>
1063
- <Button
1064
- size="sm"
1065
- variant="ghost"
1066
- className="h-6 w-6 p-0 text-destructive hover:text-destructive"
1067
- title="Delete"
1139
+ return nodes.map((node) => {
1140
+ const isNodePinned = pins.some((p) => p.path === node.path);
1141
+ return (
1142
+ <ContextMenu key={node.path}>
1143
+ <ContextMenuTrigger asChild>
1144
+ <div>
1145
+ <div
1146
+ role="treeitem"
1147
+ tabIndex={0}
1148
+ draggable
1149
+ onKeyDown={(e) => {
1150
+ if (e.key === "Enter" || e.key === " ") {
1151
+ e.preventDefault();
1152
+ if (node.type === "dir") toggleFolder(node);
1153
+ else if (node.type === "app" || node.type === "node-app") { void openViewer(node); void toggleFolder(node); }
1154
+ else void openViewer(node);
1155
+ } else if (e.key === "ArrowDown") {
1156
+ e.preventDefault();
1157
+ const container = sidebarScrollRef.current;
1158
+ if (!container) return;
1159
+ const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
1160
+ const idx = items.indexOf(e.currentTarget as HTMLElement);
1161
+ items[idx + 1]?.focus();
1162
+ } else if (e.key === "ArrowUp") {
1163
+ e.preventDefault();
1164
+ const container = sidebarScrollRef.current;
1165
+ if (!container) return;
1166
+ const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
1167
+ const idx = items.indexOf(e.currentTarget as HTMLElement);
1168
+ items[idx - 1]?.focus();
1169
+ } else if (e.key === "ArrowRight") {
1170
+ e.preventDefault();
1171
+ if (node.type === "dir" || node.type === "app" || node.type === "node-app") {
1172
+ if (!node.expanded) {
1173
+ void toggleFolder(node);
1174
+ } else {
1175
+ // Focus first visible child
1176
+ const container = sidebarScrollRef.current;
1177
+ if (!container) return;
1178
+ const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
1179
+ const idx = items.indexOf(e.currentTarget as HTMLElement);
1180
+ items[idx + 1]?.focus();
1181
+ }
1182
+ }
1183
+ } else if (e.key === "ArrowLeft") {
1184
+ e.preventDefault();
1185
+ if ((node.type === "dir" || node.type === "app" || node.type === "node-app") && node.expanded) {
1186
+ void toggleFolder(node);
1187
+ } else if (depth > 0) {
1188
+ // Focus nearest ancestor (smaller paddingLeft)
1189
+ const container = sidebarScrollRef.current;
1190
+ if (!container) return;
1191
+ const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
1192
+ const current = e.currentTarget as HTMLElement;
1193
+ const idx = items.indexOf(current);
1194
+ const currentPL = Number.parseInt(current.style.paddingLeft ?? "0", 10);
1195
+ for (let i = idx - 1; i >= 0; i--) {
1196
+ const pl = Number.parseInt(items[i].style.paddingLeft ?? "0", 10);
1197
+ if (pl < currentPL) { items[i].focus(); break; }
1198
+ }
1199
+ }
1200
+ }
1201
+ }}
1202
+ onDragStart={(e) => handleDragStart(e, node)}
1203
+ onDragOver={(e) =>
1204
+ node.type === "dir"
1205
+ ? handleDragOver(e, node.path, "dir")
1206
+ : e.preventDefault()
1207
+ }
1208
+ onDragLeave={() => setDragOverPath(null)}
1209
+ onDrop={(e) =>
1210
+ node.type === "dir"
1211
+ ? handleDropOnFolder(e, node.path)
1212
+ : e.preventDefault()
1213
+ }
1214
+ className={cn(
1215
+ "flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer group transition-colors select-none",
1216
+ openFile?.path === node.path
1217
+ ? "bg-accent-soft text-foreground font-medium"
1218
+ : "hover:bg-muted",
1219
+ dragOverPath === node.path && "ring-2 ring-primary bg-primary-soft",
1220
+ node.name.startsWith(".") && "opacity-40",
1221
+ )}
1222
+ style={{ paddingLeft: `${depth * 14 + 8}px` }}
1068
1223
  onClick={() => {
1069
- setDeletingPath(node.path);
1070
- setDeletingIsDir(node.type !== "file");
1224
+ if (node.type === "dir") toggleFolder(node);
1225
+ else if (node.type === "app" || node.type === "node-app") { void openViewer(node); void toggleFolder(node); }
1226
+ else void openViewer(node);
1071
1227
  }}
1072
1228
  >
1073
- <Trash2 className="h-3 w-3" />
1074
- </Button>
1075
- </div>
1076
- </div>
1229
+ {(node.type === "dir" || node.type === "app" || node.type === "node-app") ? (
1230
+ node.loading ? (
1231
+ <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
1232
+ ) : node.expanded ? (
1233
+ <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
1234
+ ) : (
1235
+ <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
1236
+ )
1237
+ ) : (
1238
+ <span className="w-3.5 shrink-0" />
1239
+ )}
1240
+
1241
+ {node.type === "dir" ? (
1242
+ node.expanded ? (
1243
+ <FolderOpen className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-warning")} />
1244
+ ) : (
1245
+ <Folder className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-warning")} />
1246
+ )
1247
+ ) : node.type === "app" ? (
1248
+ <Globe className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-foreground/70")} />
1249
+ ) : node.type === "node-app" ? (
1250
+ <Terminal className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-emerald-500")} />
1251
+ ) : isHtmlFile(node.name) ? (
1252
+ <Globe className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-foreground/60")} />
1253
+ ) : isImage(node.name) ? (
1254
+ <ImageIcon className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-sunshine-700")} />
1255
+ ) : isText(node.name) ? (
1256
+ <FileText className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-foreground/70")} />
1257
+ ) : (
1258
+ <File className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-foreground/60")} />
1259
+ )}
1260
+
1261
+ <span className="min-w-0 flex-1 truncate">{node.name}</span>
1262
+
1263
+ {/* Agent presence dot */}
1264
+ {activePaths.has(node.path) && (
1265
+ <span
1266
+ className="ml-0.5 h-1.5 w-1.5 rounded-full bg-emerald-400 shrink-0 animate-pulse"
1267
+ title="Agent recently active"
1268
+ />
1269
+ )}
1270
+
1271
+ <div
1272
+ className="flex max-w-0 shrink-0 items-center overflow-hidden opacity-0 transition-all duration-150 group-hover:max-w-7 group-hover:opacity-100 focus-within:max-w-7 focus-within:opacity-100"
1273
+ onClick={(e) => e.stopPropagation()}
1274
+ onKeyDown={(e) => e.stopPropagation()}
1275
+ >
1276
+ <DropdownMenu>
1277
+ <DropdownMenuTrigger asChild>
1278
+ <Button
1279
+ size="sm"
1280
+ variant="ghost"
1281
+ className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
1282
+ title="File actions"
1283
+ >
1284
+ <MoreHorizontal className="h-3.5 w-3.5" />
1285
+ </Button>
1286
+ </DropdownMenuTrigger>
1287
+ <DropdownMenuContent align="end" className="w-48">
1288
+ <DropdownMenuItem onClick={() => copyPath(node.path)}>
1289
+ <Copy className="mr-2 h-3.5 w-3.5" />
1290
+ Copy path
1291
+ </DropdownMenuItem>
1292
+ {isMarkdown(node.name) && (
1293
+ <DropdownMenuItem onClick={() => copyWikiLink(node.name)}>
1294
+ <FileText className="mr-2 h-3.5 w-3.5" />
1295
+ Copy wiki link
1296
+ </DropdownMenuItem>
1297
+ )}
1298
+ <DropdownMenuItem onClick={() => copyUrl(node.path)}>
1299
+ <Link className="mr-2 h-3.5 w-3.5" />
1300
+ Copy URL
1301
+ </DropdownMenuItem>
1302
+ {node.type === "file" && isText(node.name) && (
1303
+ <>
1304
+ <DropdownMenuItem onClick={() => void copyRawContent(node.path)}>
1305
+ <FileText className="mr-2 h-3.5 w-3.5" />
1306
+ Copy raw content
1307
+ </DropdownMenuItem>
1308
+ <DropdownMenuItem onClick={() => void copyFormattedContent(node.path, node.name)}>
1309
+ <FileText className="mr-2 h-3.5 w-3.5" />
1310
+ Copy formatted content
1311
+ </DropdownMenuItem>
1312
+ </>
1313
+ )}
1314
+ <DropdownMenuSeparator />
1315
+ {node.type === "dir" && (
1316
+ <>
1317
+ <DropdownMenuItem
1318
+ onClick={async () => {
1319
+ if (!node.expanded) await toggleFolder(node);
1320
+ setNewFileParent(node.path);
1321
+ setNewFileName("");
1322
+ setFileCreateError(null);
1323
+ }}
1324
+ >
1325
+ <FilePlus className="mr-2 h-3.5 w-3.5" />
1326
+ New file here
1327
+ </DropdownMenuItem>
1328
+ <DropdownMenuItem onClick={() => triggerUpload(node.path)}>
1329
+ <Upload className="mr-2 h-3.5 w-3.5" />
1330
+ Upload here
1331
+ </DropdownMenuItem>
1332
+ <DropdownMenuItem
1333
+ onClick={() => {
1334
+ setNewFolderParent(node.path);
1335
+ setNewFolderName("");
1336
+ setFolderError(null);
1337
+ }}
1338
+ >
1339
+ <FolderPlus className="mr-2 h-3.5 w-3.5" />
1340
+ New subfolder
1341
+ </DropdownMenuItem>
1342
+ <DropdownMenuSeparator />
1343
+ </>
1344
+ )}
1345
+ <DropdownMenuItem onClick={() => handleDownload(node)}>
1346
+ <Download className="mr-2 h-3.5 w-3.5" />
1347
+ {node.type === "file" ? "Download" : "Download as zip"}
1348
+ </DropdownMenuItem>
1349
+ <DropdownMenuItem
1350
+ onClick={() =>
1351
+ usePinStore
1352
+ .getState()
1353
+ .toggle({ path: node.path, name: node.name }, activeWorkspaceId)
1354
+ }
1355
+ >
1356
+ <Star className={cn("mr-2 h-3.5 w-3.5", isNodePinned && "fill-current text-amber-400")} />
1357
+ {isNodePinned ? "Unpin" : "Pin to top"}
1358
+ </DropdownMenuItem>
1359
+ <DropdownMenuSeparator />
1360
+ <DropdownMenuItem
1361
+ className="text-destructive focus:text-destructive"
1362
+ onClick={() => {
1363
+ setDeletingPath(node.path);
1364
+ setDeletingIsDir(node.type !== "file");
1365
+ }}
1366
+ >
1367
+ <Trash2 className="mr-2 h-3.5 w-3.5" />
1368
+ Delete
1369
+ </DropdownMenuItem>
1370
+ </DropdownMenuContent>
1371
+ </DropdownMenu>
1372
+ </div>
1373
+ </div>
1077
1374
 
1078
1375
  {newFolderParent === node.path && node.type === "dir" && (
1079
1376
  <div
@@ -1166,12 +1463,12 @@ export default function Page() {
1166
1463
  </div>
1167
1464
  )}
1168
1465
 
1169
- {(node.type === "dir" || node.type === "app") &&
1466
+ {(node.type === "dir" || node.type === "app" || node.type === "node-app") &&
1170
1467
  node.expanded &&
1171
1468
  node.children &&
1172
1469
  node.children.length > 0 &&
1173
1470
  renderNodes(node.children, depth + 1)}
1174
- {(node.type === "dir" || node.type === "app") &&
1471
+ {(node.type === "dir" || node.type === "app" || node.type === "node-app") &&
1175
1472
  node.expanded &&
1176
1473
  node.children?.length === 0 && (
1177
1474
  <div
@@ -1183,8 +1480,36 @@ export default function Page() {
1183
1480
  Empty
1184
1481
  </div>
1185
1482
  )}
1186
- </div>
1187
- ));
1483
+ </div>
1484
+ </ContextMenuTrigger>
1485
+ <ContextMenuContent>
1486
+ <ContextMenuItem onSelect={() => copyPath(node.path)}>
1487
+ Copy path
1488
+ </ContextMenuItem>
1489
+ {isMarkdown(node.name) && (
1490
+ <ContextMenuItem onSelect={() => copyWikiLink(node.name)}>
1491
+ Copy wiki link
1492
+ </ContextMenuItem>
1493
+ )}
1494
+ <ContextMenuSeparator />
1495
+ <ContextMenuItem onSelect={() => copyUrl(node.path)}>
1496
+ Copy URL
1497
+ </ContextMenuItem>
1498
+ {node.type === "file" && isText(node.name) && (
1499
+ <>
1500
+ <ContextMenuSeparator />
1501
+ <ContextMenuItem onSelect={() => void copyRawContent(node.path)}>
1502
+ Copy raw content
1503
+ </ContextMenuItem>
1504
+ <ContextMenuItem onSelect={() => void copyFormattedContent(node.path, node.name)}>
1505
+ Copy formatted content
1506
+ </ContextMenuItem>
1507
+ </>
1508
+ )}
1509
+ </ContextMenuContent>
1510
+ </ContextMenu>
1511
+ );
1512
+ });
1188
1513
  }
1189
1514
 
1190
1515
  return (
@@ -1222,7 +1547,14 @@ export default function Page() {
1222
1547
  </div>
1223
1548
  )}
1224
1549
  {rootConfigured === true && !addingWorkspace && <>
1225
- <SearchCommandDialog onOpenFile={openFromSearch} />
1550
+ <SearchCommandDialog
1551
+ onOpenFile={openFromSearch}
1552
+ onToggleSidebar={() => setSidebarCollapsed((v) => !v)}
1553
+ onNewFile={() => setNewFileParent("")}
1554
+ onCopyPath={() => {
1555
+ if (openFile) void navigator.clipboard.writeText(openFile.path);
1556
+ }}
1557
+ />
1226
1558
  {/* Tree sidebar */}
1227
1559
  {!sidebarCollapsed && (
1228
1560
  <Card className="flex flex-col w-72 shrink-0 overflow-hidden rounded-none border-r border-l-0 border-t-0 border-b-0">
@@ -1427,6 +1759,7 @@ export default function Page() {
1427
1759
  </div>
1428
1760
 
1429
1761
  <div
1762
+ ref={sidebarScrollRef}
1430
1763
  className={cn(
1431
1764
  "flex-1 overflow-auto py-1",
1432
1765
  dragOverPath === "" &&
@@ -1439,6 +1772,80 @@ export default function Page() {
1439
1772
  }}
1440
1773
  onDrop={(e) => handleDropOnFolder(e, "")}
1441
1774
  >
1775
+ {/* Pinned section */}
1776
+ {pins.length > 0 && (
1777
+ <div className="border-b mb-1">
1778
+ <button
1779
+ type="button"
1780
+ className="flex w-full items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
1781
+ onClick={() => setPinnedCollapsed((c) => !c)}
1782
+ >
1783
+ {pinnedCollapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
1784
+ <Pin className="h-3 w-3" />
1785
+ Pinned
1786
+ </button>
1787
+ {!pinnedCollapsed && pins.map((p) => (
1788
+ <div
1789
+ key={p.path}
1790
+ role="button"
1791
+ tabIndex={0}
1792
+ className={cn(
1793
+ "group flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer transition-colors select-none",
1794
+ openFile?.path === p.path ? "bg-accent-soft text-foreground font-medium" : "hover:bg-muted",
1795
+ )}
1796
+ onClick={() => void openViewer({ path: p.path, name: p.name, type: "file", modifiedAt: "" } as TreeNode)}
1797
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); void openViewer({ path: p.path, name: p.name, type: "file", modifiedAt: "" } as TreeNode); } }}
1798
+ >
1799
+ <Star className="h-3 w-3 shrink-0 fill-current text-amber-400" />
1800
+ <span className="min-w-0 flex-1 truncate text-xs">{p.name}</span>
1801
+ <span className="max-w-[80px] truncate text-[10px] text-muted-foreground/60">{p.path.split("/").slice(0, -1).join("/")}</span>
1802
+ <button
1803
+ type="button"
1804
+ className="shrink-0 rounded p-0.5 text-muted-foreground/50 opacity-0 transition-colors hover:bg-muted hover:text-amber-400 group-hover:opacity-100 focus:opacity-100"
1805
+ title="Remove from pinned"
1806
+ onClick={(e) => {
1807
+ e.stopPropagation();
1808
+ usePinStore
1809
+ .getState()
1810
+ .toggle({ path: p.path, name: p.name }, activeWorkspaceId);
1811
+ }}
1812
+ >
1813
+ <X className="h-3 w-3" />
1814
+ </button>
1815
+ </div>
1816
+ ))}
1817
+ </div>
1818
+ )}
1819
+ {/* Recent files section */}
1820
+ {recents.length > 0 && (
1821
+ <div className="border-b mb-1">
1822
+ <button
1823
+ type="button"
1824
+ className="flex w-full items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
1825
+ onClick={() => setRecentCollapsed((c) => !c)}
1826
+ >
1827
+ {recentCollapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
1828
+ Recent
1829
+ </button>
1830
+ {!recentCollapsed && recents.slice(0, 8).map((r) => (
1831
+ <div
1832
+ key={r.path}
1833
+ role="button"
1834
+ tabIndex={0}
1835
+ className={cn(
1836
+ "flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer transition-colors select-none",
1837
+ openFile?.path === r.path ? "bg-accent-soft text-foreground font-medium" : "hover:bg-muted",
1838
+ )}
1839
+ onClick={() => void openViewer({ path: r.path, name: r.name, type: "file", modifiedAt: "" } as TreeNode)}
1840
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); void openViewer({ path: r.path, name: r.name, type: "file", modifiedAt: "" } as TreeNode); } }}
1841
+ >
1842
+ <FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
1843
+ <span className="flex-1 truncate text-xs">{r.name}</span>
1844
+ <span className="text-[10px] text-muted-foreground/60 truncate max-w-[80px]">{r.path.split("/").slice(0, -1).join("/")}</span>
1845
+ </div>
1846
+ ))}
1847
+ </div>
1848
+ )}
1442
1849
  {rootLoading ? (
1443
1850
  <div className="flex justify-center py-6">
1444
1851
  <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
@@ -1582,6 +1989,7 @@ export default function Page() {
1582
1989
  </span>
1583
1990
  </div>
1584
1991
  <div className="flex items-center gap-1 shrink-0">
1992
+ {renderCopyMenu(openFile)}
1585
1993
  {openFileViewerKind === "html" &&
1586
1994
  !editing &&
1587
1995
  fileContent !== null && (
@@ -1697,16 +2105,17 @@ export default function Page() {
1697
2105
  </span>
1698
2106
  </div>
1699
2107
  <div className="flex items-center gap-1 shrink-0">
2108
+ {renderCopyMenu(openFile)}
1700
2109
  {isText(openFile.name) &&
1701
2110
  !editing &&
1702
- fileContent !== null && (
2111
+ (fileContent !== null || isMarkdown(openFile.name)) && (
1703
2112
  <Button
1704
2113
  size="sm"
1705
2114
  variant="ghost"
1706
2115
  className="h-7 w-7 p-0"
1707
2116
  onClick={() => {
1708
2117
  setEditing(true);
1709
- setEditContent(fileContent);
2118
+ setEditContent(fileContent ?? "");
1710
2119
  setSaveError(null);
1711
2120
  if (isMarkdown(openFile.name)) {
1712
2121
  void useEditorStore
@@ -1720,7 +2129,7 @@ export default function Page() {
1720
2129
  )}
1721
2130
  {isText(openFile.name) &&
1722
2131
  !editing &&
1723
- fileContent !== null && (
2132
+ (fileContent !== null || isMarkdown(openFile.name)) && (
1724
2133
  <Button
1725
2134
  size="sm"
1726
2135
  variant="ghost"
@@ -1765,6 +2174,16 @@ export default function Page() {
1765
2174
  />
1766
2175
  )}
1767
2176
  </div>
2177
+ ) : isMarkdown(openFile.name) ? (
2178
+ <div className="flex-1 flex flex-col overflow-hidden min-h-0">
2179
+ {fileLoading ? (
2180
+ <div className="flex justify-center py-8">
2181
+ <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
2182
+ </div>
2183
+ ) : (
2184
+ <KBEditor mode="viewing" />
2185
+ )}
2186
+ </div>
1768
2187
  ) : (
1769
2188
  <div className="flex-1 overflow-auto p-4 min-h-0">
1770
2189
  <div className={cn("mx-auto w-full", contentWidthClass)}>
@@ -1817,40 +2236,9 @@ export default function Page() {
1817
2236
  title={openFile.name}
1818
2237
  />
1819
2238
  ) : fileContent !== null ? (
1820
- ["md", "markdown"].includes(ext(openFile.name)) ? (
1821
- (() => {
1822
- const { data, body } = parseFrontmatter(fileContent);
1823
- return (
1824
- <>
1825
- <FrontmatterHeader
1826
- data={data as Record<string, never>}
1827
- />
1828
- <MarkdownPreview
1829
- markdown={body}
1830
- pagePath={openFile.path}
1831
- onNavigate={(targetPath, anchor) => {
1832
- void openViewer({
1833
- path: targetPath,
1834
- name: targetPath.split('/').pop() ?? targetPath,
1835
- type: 'file',
1836
- } as TreeNode);
1837
- if (anchor) {
1838
- setTimeout(() => {
1839
- document
1840
- .getElementById(anchor)
1841
- ?.scrollIntoView({ behavior: 'smooth' });
1842
- }, 200);
1843
- }
1844
- }}
1845
- />
1846
- </>
1847
- );
1848
- })()
1849
- ) : (
1850
- <pre className="text-xs font-mono whitespace-pre-wrap break-words leading-relaxed">
1851
- {fileContent}
1852
- </pre>
1853
- )
2239
+ <pre className="text-xs font-mono whitespace-pre-wrap break-words leading-relaxed">
2240
+ {fileContent}
2241
+ </pre>
1854
2242
  ) : isText(openFile.name) ? (
1855
2243
  <p className="text-sm text-muted-foreground">
1856
2244
  Could not load file.
@@ -1864,7 +2252,7 @@ export default function Page() {
1864
2252
  </div>
1865
2253
  )}
1866
2254
 
1867
- {editing && (
2255
+ {editing && !isMarkdown(openFile.name) && (
1868
2256
  <div className="border-t px-4 py-2 flex items-center justify-end gap-2 bg-muted shrink-0">
1869
2257
  {saveError && (
1870
2258
  <span className="text-xs text-destructive mr-auto">