viteboard 0.1.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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/bin/viteboard.mjs +59 -0
  4. package/content/docs/assets/image-mqjpqlyw.png +0 -0
  5. package/content/docs/assets/image-mqjsmenr.png +0 -0
  6. package/content/docs/boards/product-roadmap.board.json +2130 -0
  7. package/content/docs/capabilities.md +89 -0
  8. package/content/docs/docs.md +84 -0
  9. package/content/docs/guide/boards-in-markdown.md +48 -0
  10. package/content/docs/guide/boards.md +92 -0
  11. package/content/docs/guide/getting-started.md +71 -0
  12. package/content/docs/guide/test.md +6 -0
  13. package/content/docs/index.md +47 -0
  14. package/content/docs/product-roadmap.board.json +2130 -0
  15. package/content/docs/test.md +219 -0
  16. package/content/docs/tetst-dir/test-board.board.json +15 -0
  17. package/content/docs/tetst-dir/test-test.md +1 -0
  18. package/content/docs/workspace.md +73 -0
  19. package/dist/assets/index-CEpxLM2o.css +1 -0
  20. package/dist/assets/index-D4xvJdEQ.js +60 -0
  21. package/dist/index.html +13 -0
  22. package/index.html +13 -0
  23. package/package.json +52 -0
  24. package/src/app/AppController.ts +955 -0
  25. package/src/app/BoardState.ts +130 -0
  26. package/src/app/ClipboardService.ts +159 -0
  27. package/src/app/CommandManager.ts +66 -0
  28. package/src/app/ElementActions.ts +152 -0
  29. package/src/app/EmbedRegionSelector.ts +103 -0
  30. package/src/app/ExportPng.ts +107 -0
  31. package/src/app/ImageInsertService.ts +141 -0
  32. package/src/app/KeyboardShortcuts.ts +124 -0
  33. package/src/app/main.ts +6 -0
  34. package/src/board/BoardPreview.ts +189 -0
  35. package/src/board/BoardService.ts +222 -0
  36. package/src/canvas/CanvasRenderer.ts +253 -0
  37. package/src/canvas/CanvasSurface.ts +70 -0
  38. package/src/canvas/HitTester.ts +110 -0
  39. package/src/canvas/ImageCache.ts +123 -0
  40. package/src/canvas/PerformanceMonitor.ts +31 -0
  41. package/src/canvas/RenderScheduler.ts +26 -0
  42. package/src/canvas/Viewport.ts +77 -0
  43. package/src/content/ContentApi.ts +258 -0
  44. package/src/content/Markdown.ts +431 -0
  45. package/src/content/MarkdownView.ts +70 -0
  46. package/src/editor/DocEditor.ts +799 -0
  47. package/src/editor/htmlToMarkdown.ts +333 -0
  48. package/src/elements/renderElement.ts +509 -0
  49. package/src/elements/types.ts +118 -0
  50. package/src/shell/Shell.ts +2950 -0
  51. package/src/shell/Sidebar.ts +1352 -0
  52. package/src/storage/AssetStore.ts +86 -0
  53. package/src/storage/BoardSerializer.ts +114 -0
  54. package/src/storage/ImportExportService.ts +153 -0
  55. package/src/storage/IndexedDbStore.ts +92 -0
  56. package/src/storage/LocalBoardStore.ts +104 -0
  57. package/src/styles.css +3257 -0
  58. package/src/templates/helpers.ts +124 -0
  59. package/src/templates/index.ts +65 -0
  60. package/src/templates/journeyMapTemplate.ts +52 -0
  61. package/src/templates/opportunityTreeTemplate.ts +45 -0
  62. package/src/templates/personaTemplate.ts +41 -0
  63. package/src/templates/prioritizationMatrixTemplate.ts +44 -0
  64. package/src/templates/requirementsFlowTemplate.ts +45 -0
  65. package/src/templates/screenshotReviewTemplate.ts +52 -0
  66. package/src/templates/systemDiagramTemplate.ts +41 -0
  67. package/src/testing/StressTestGenerator.ts +134 -0
  68. package/src/testing/StressTestPanel.ts +64 -0
  69. package/src/tools/ArrowTool.ts +87 -0
  70. package/src/tools/ImageTool.ts +39 -0
  71. package/src/tools/PanTool.ts +34 -0
  72. package/src/tools/SelectTool.ts +377 -0
  73. package/src/tools/ShapeTool.ts +106 -0
  74. package/src/tools/StickyTool.ts +69 -0
  75. package/src/tools/TaskTool.ts +52 -0
  76. package/src/tools/TextTool.ts +45 -0
  77. package/src/tools/ToolContext.ts +44 -0
  78. package/src/tools/ToolController.ts +178 -0
  79. package/src/ui/Modal.ts +58 -0
  80. package/src/ui/PerformanceOverlay.ts +75 -0
  81. package/src/ui/StorageManager.ts +94 -0
  82. package/src/ui/TemplatePicker.ts +67 -0
  83. package/src/ui/TextEditorOverlay.ts +137 -0
  84. package/src/ui/Toolbar.ts +151 -0
  85. package/src/ui/TopControls.ts +137 -0
  86. package/src/ui/icons.ts +50 -0
  87. package/tsconfig.json +19 -0
  88. package/vite.config.ts +694 -0
@@ -0,0 +1,1352 @@
1
+ import {
2
+ type DocsRootSummary,
3
+ type TreeNode,
4
+ type WorkspaceProject,
5
+ type WorkspaceState,
6
+ } from "../content/ContentApi";
7
+ import { boardIdFromFilePath } from "../board/BoardService";
8
+
9
+ // Directories that are never shown as doc tree nodes.
10
+ const SPECIAL_DIRS = new Set(["capabilities", "assets"]);
11
+
12
+ export type SidebarCallbacks = {
13
+ onAddRepo: () => void;
14
+ onRemoveProject: (projectId: string, label: string) => void;
15
+ // --- create ---
16
+ onCreateDirectory: (rootId: string, dirPath: string) => void;
17
+ onCreateDoc: (rootId: string, dirPath: string) => void;
18
+ onCreateBoard: (rootId: string, dirPath: string) => void;
19
+ onCreateCapability: (rootId: string, dirPath: string) => void;
20
+ onCreateDocsFolder: (projectId: string, parentPath: string) => void;
21
+ onCreateProjectDirectory: (projectId: string) => void;
22
+ onCreateProjectDoc: (projectId: string) => void;
23
+ onCreateProjectBoard: (projectId: string) => void;
24
+ // --- directory ---
25
+ onRenameDirectory: (rootId: string, dirPath: string) => void;
26
+ onDeleteDirectory: (rootId: string, dirPath: string) => void;
27
+ // --- doc file ---
28
+ onOpenDoc: (rootId: string, filePath: string) => void;
29
+ onEditDoc: (rootId: string, filePath: string) => void;
30
+ onRenameDoc: (rootId: string, filePath: string) => void;
31
+ onDeleteDoc: (rootId: string, filePath: string) => void;
32
+ // --- board file ---
33
+ onOpenBoard: (rootId: string, boardId: string) => void;
34
+ onRenameBoard: (rootId: string, boardId: string) => void;
35
+ onDeleteBoard: (rootId: string, boardId: string) => void;
36
+ // --- drag/drop move ---
37
+ onMoveDoc: (rootId: string, filePath: string, targetDir: string) => void;
38
+ onMoveBoard: (rootId: string, boardId: string, targetDir: string) => void;
39
+ onMoveDirectory: (rootId: string, dirPath: string, targetDir: string) => void;
40
+ };
41
+
42
+ type SidebarDragItem =
43
+ | { kind: "doc"; rootId: string; path: string }
44
+ | { kind: "board"; rootId: string; boardId: string }
45
+ | { kind: "dir"; rootId: string; path: string };
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Path-segment tree used to group docs roots into a nested hierarchy.
49
+ // ---------------------------------------------------------------------------
50
+ type PathNode =
51
+ | { kind: "root"; root: DocsRootSummary }
52
+ | { kind: "dir"; segment: string; children: PathNode[] };
53
+
54
+ function buildPathTree(roots: DocsRootSummary[]): PathNode[] {
55
+ const children: PathNode[] = [];
56
+
57
+ function insert(nodes: PathNode[], segments: string[], root: DocsRootSummary): void {
58
+ if (segments.length === 0) return;
59
+ if (segments.length === 1) {
60
+ nodes.push({ kind: "root", root });
61
+ return;
62
+ }
63
+ const [head, ...rest] = segments;
64
+ let dir = nodes.find(
65
+ (n): n is PathNode & { kind: "dir" } => n.kind === "dir" && n.segment === head
66
+ );
67
+ if (!dir) {
68
+ dir = { kind: "dir", segment: head, children: [] };
69
+ nodes.push(dir);
70
+ }
71
+ insert(dir.children, rest, root);
72
+ }
73
+
74
+ for (const root of roots) {
75
+ const segments = root.displayPath.split("/").filter(Boolean);
76
+ insert(children, segments, root);
77
+ }
78
+
79
+ // Collapse single-child dir chains (e.g. frontend → src → merges to frontend/src).
80
+ function collapse(nodes: PathNode[]): PathNode[] {
81
+ return nodes.map((node) => {
82
+ if (node.kind === "root") return node;
83
+ const kids = collapse(node.children);
84
+ if (kids.length === 1 && kids[0].kind === "dir") {
85
+ const only = kids[0] as PathNode & { kind: "dir" };
86
+ return { kind: "dir" as const, segment: `${node.segment}/${only.segment}`, children: only.children };
87
+ }
88
+ return { ...node, children: kids };
89
+ });
90
+ }
91
+
92
+ return collapse(children);
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // A record of every collapsible section built during render, so we can
97
+ // expand the ancestors of the active item without re-rendering.
98
+ // ---------------------------------------------------------------------------
99
+ export type CollapsibleRecord = {
100
+ body: HTMLElement;
101
+ group: HTMLElement;
102
+ btn: HTMLButtonElement;
103
+ };
104
+
105
+ // ---------------------------------------------------------------------------
106
+
107
+ export class Sidebar {
108
+ readonly element: HTMLElement;
109
+ private header: HTMLElement;
110
+ private nav: HTMLElement;
111
+ private footer: HTMLElement;
112
+
113
+ /** href / key → anchor — accessible for inline editing lookups */
114
+ readonly links = new Map<string, HTMLAnchorElement>();
115
+ /** key → collapsible DOM record — built fresh each refresh() */
116
+ readonly collapsibles = new Map<string, CollapsibleRecord>();
117
+ /** key → collapsed state, preserved across refreshes and route changes */
118
+ private collapsedState = new Map<string, boolean>();
119
+
120
+ private callbacks: SidebarCallbacks;
121
+ private contextMenu?: HTMLElement;
122
+ private draggedItem?: SidebarDragItem;
123
+
124
+ /** Last full workspace state — kept so we can re-render on mode toggle. */
125
+ private lastWorkspace: WorkspaceState = { projects: [] };
126
+ private lastTreesByRoot = new Map<string, TreeNode[]>();
127
+ private lastTreesByProject = new Map<string, TreeNode[]>();
128
+ /** Track which projects have "browse mode" enabled (shows full filesystem) */
129
+ private browseModeProjects = new Set<string>();
130
+
131
+ constructor(parent: HTMLElement, callbacks: SidebarCallbacks) {
132
+ this.callbacks = callbacks;
133
+ this.element = document.createElement("aside");
134
+ this.element.className = "sidebar";
135
+
136
+ this.header = document.createElement("div");
137
+ this.header.className = "sidebar-header";
138
+ const brand = document.createElement("a");
139
+ brand.className = "sidebar-brand";
140
+ brand.href = "#/";
141
+ brand.textContent = "ViteBoard";
142
+ this.header.appendChild(brand);
143
+
144
+ this.nav = document.createElement("nav");
145
+ this.nav.className = "sidebar-nav";
146
+
147
+ this.footer = document.createElement("div");
148
+ this.footer.className = "sidebar-footer";
149
+ const addRepo = document.createElement("button");
150
+ addRepo.className = "sidebar-add-repo";
151
+ addRepo.type = "button";
152
+ addRepo.textContent = "Add Repo";
153
+ addRepo.title = "Add a local project root";
154
+ addRepo.addEventListener("click", () => this.callbacks.onAddRepo());
155
+ this.footer.appendChild(addRepo);
156
+
157
+ this.element.append(this.header, this.nav, this.footer);
158
+ parent.appendChild(this.element);
159
+
160
+ document.addEventListener(
161
+ "pointerdown",
162
+ (e) => {
163
+ if (!this.contextMenu) return;
164
+ if (this.contextMenu.contains(e.target as Node)) return;
165
+ this.closeContextMenu();
166
+ },
167
+ true
168
+ );
169
+ }
170
+
171
+ // ---------- Public API ----------
172
+
173
+ refresh(workspace: WorkspaceState, treesByRoot: Map<string, TreeNode[]>, treesByProject?: Map<string, TreeNode[]>): void {
174
+ this.lastWorkspace = workspace;
175
+ this.lastTreesByRoot = treesByRoot;
176
+ if (treesByProject) {
177
+ this.lastTreesByProject = treesByProject;
178
+ }
179
+ this.render();
180
+ }
181
+
182
+ private render(): void {
183
+ const workspace = this.lastWorkspace;
184
+ const treesByRoot = this.lastTreesByRoot;
185
+ const treesByProject = this.lastTreesByProject;
186
+
187
+ this.nav.replaceChildren();
188
+ this.links.clear();
189
+ this.collapsibles.clear();
190
+
191
+ if (workspace.projects.length === 0) {
192
+ const empty = document.createElement("div");
193
+ empty.className = "nav-group";
194
+ empty.appendChild(this.groupLabel("Workspace"));
195
+ const note = document.createElement("div");
196
+ note.className = "nav-label nav-empty-copy";
197
+ note.textContent = "Add a project root to discover docs folders.";
198
+ empty.appendChild(note);
199
+ this.nav.appendChild(empty);
200
+ } else {
201
+ for (const project of workspace.projects) {
202
+ this.nav.appendChild(this.projectSection(project, treesByRoot));
203
+ }
204
+ }
205
+
206
+ const system = document.createElement("div");
207
+ system.className = "nav-group";
208
+ system.appendChild(this.groupLabel("System"));
209
+ system.appendChild(this.link("#/tokens", "Tokens", "tokens-root"));
210
+ this.nav.appendChild(system);
211
+
212
+ this.setActive(location.hash);
213
+ }
214
+
215
+
216
+
217
+ /** Exit browse mode for all projects (called after content refresh). */
218
+ clearBrowseMode(): void {
219
+ if (this.browseModeProjects.size === 0) return;
220
+ this.browseModeProjects.clear();
221
+ this.render();
222
+ }
223
+
224
+ private toggleBrowseMode(projectId: string): void {
225
+ if (this.browseModeProjects.has(projectId)) {
226
+ this.browseModeProjects.delete(projectId);
227
+ } else {
228
+ this.browseModeProjects.add(projectId);
229
+ }
230
+ this.render();
231
+ }
232
+
233
+ setActive(hash: string): void {
234
+ // 1. Clear previous active state.
235
+ for (const a of new Set(this.links.values())) a.classList.remove("active");
236
+
237
+ const exact = this.links.get(hash);
238
+ if (exact) {
239
+ exact.classList.add("active");
240
+ } else if (hash.startsWith("#/root/")) {
241
+ const match = hash.match(/^#\/root\/([^/]+)\/(boards|capabilities|docs)(?:\/(.+))?$/);
242
+ if (match) {
243
+ const [, encodedRootId, section] = match;
244
+ const rootId = decodeURIComponent(encodedRootId);
245
+ if (section === "boards") {
246
+ this.links.get(rootBoardsHref(rootId))?.classList.add("active");
247
+ } else if (section === "capabilities") {
248
+ this.links.get(rootCapabilitiesHref(rootId))?.classList.add("active");
249
+ }
250
+ }
251
+ }
252
+
253
+ // 2. Sync tree open/close state to the active route.
254
+ this.syncCollapsed(hash);
255
+ }
256
+
257
+ /**
258
+ * Open every collapsible that is an ancestor of the active page, while
259
+ * preserving the user's existing open/close state elsewhere.
260
+ */
261
+ private syncCollapsed(hash: string): void {
262
+ const open = this.ancestorKeys(hash);
263
+ for (const key of open) this.applyCollapsed(key, false);
264
+ }
265
+
266
+ /**
267
+ * Compute the set of collapsible keys that must be open for the given hash
268
+ * to be visible in the tree.
269
+ *
270
+ * Key formats:
271
+ * projectId — project header
272
+ * pathseg:{projectId}:{fullSegment} — path segment group
273
+ * doc:{rootId}:{dirPath} — doc directory group
274
+ */
275
+ private ancestorKeys(hash: string): Set<string> {
276
+ const open = new Set<string>();
277
+
278
+ const projectMatch = hash.match(/^#\/project\/([^/]+)(?:\/boards)?$/);
279
+ if (projectMatch) {
280
+ open.add(decodeURIComponent(projectMatch[1]));
281
+ return open;
282
+ }
283
+
284
+ const match = hash.match(/^#\/root\/([^/]+)\/(docs|boards|capabilities)(?:\/(.+))?$/);
285
+ if (!match) return open;
286
+
287
+ const rootId = decodeURIComponent(match[1]);
288
+ const section = match[2];
289
+ const rest = match[3] ? decodeURIComponent(match[3]) : "";
290
+
291
+ // Find which project this root belongs to.
292
+ let projectId: string | undefined;
293
+ let displayPath: string | undefined;
294
+ for (const project of this.lastWorkspace.projects) {
295
+ const root = project.docsRoots.find(r => r.id === rootId);
296
+ if (root) {
297
+ projectId = project.id;
298
+ displayPath = root.displayPath;
299
+ break;
300
+ }
301
+ }
302
+ if (!projectId || !displayPath) return open;
303
+
304
+ // Project header.
305
+ open.add(projectId);
306
+
307
+ // Path-segment keys: auto-open the filesystem scaffolding leading to /docs/
308
+ const displayParts = displayPath.split("/").filter(Boolean).slice(0, -1);
309
+ let acc = "";
310
+ for (const part of displayParts) {
311
+ acc = acc ? `${acc}/${part}` : part;
312
+ const candidate = `pathseg:${projectId}:${acc}`;
313
+ if (this.collapsibles.has(candidate)) open.add(candidate);
314
+ }
315
+
316
+ // Doc directory keys: every parent dir of the current file path.
317
+ if (section === "docs" && rest) {
318
+ const parts = rest.split("/");
319
+ for (let i = 1; i < parts.length; i++) {
320
+ const dirPath = parts.slice(0, i).join("/");
321
+ const key = `doc:${rootId}:${dirPath}`;
322
+ if (this.collapsibles.has(key)) open.add(key);
323
+ }
324
+ }
325
+
326
+ return open;
327
+ }
328
+
329
+ // ---------- Project section ----------
330
+
331
+ private projectSection(
332
+ project: WorkspaceProject,
333
+ treesByRoot: Map<string, TreeNode[]>
334
+ ): HTMLElement {
335
+ const { id: projectId, label, docsRoots: roots } = project;
336
+ const isRaw = this.browseModeProjects.has(projectId);
337
+
338
+ const body = document.createElement("div");
339
+ body.className = "nav-repo-body";
340
+
341
+ const group = document.createElement("div");
342
+ group.className = "nav-group nav-repo-group";
343
+
344
+ const header = document.createElement("button");
345
+ header.className = "nav-repo-header";
346
+ header.type = "button";
347
+
348
+ const chevron = document.createElement("span");
349
+ chevron.className = "nav-repo-chevron";
350
+ chevron.textContent = "›";
351
+ const text = document.createElement("span");
352
+ text.className = "nav-repo-label-text";
353
+ text.textContent = label;
354
+
355
+ // Three-dot menu button (replaces wiki link + toggle)
356
+ const moreBtn = document.createElement("button");
357
+ moreBtn.className = "nav-repo-more";
358
+ moreBtn.type = "button";
359
+ moreBtn.title = "Project options";
360
+ moreBtn.innerHTML = `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><circle cx="8" cy="3" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="8" cy="13" r="1.3"/></svg>`;
361
+ moreBtn.addEventListener("click", (e) => {
362
+ e.stopPropagation();
363
+ const rect = moreBtn.getBoundingClientRect();
364
+ this.showContextMenu(rect.right + 4, rect.top, label, [
365
+ {
366
+ icon: isRaw ? DOC_ICON_HTML : DIRECTORY_ICON_HTML,
367
+ iconClass: isRaw ? "nav-ctx-doc" : "nav-ctx-dir",
368
+ label: isRaw ? "View: Docs" : "View: Raw",
369
+ action: () => this.toggleBrowseMode(projectId),
370
+ },
371
+ {
372
+ icon: EDIT_ICON_HTML, iconClass: "nav-ctx-edit", label: "Wiki",
373
+ action: () => { location.hash = `#/project/${encodeURIComponent(projectId)}`; },
374
+ },
375
+ { kind: "divider" as const },
376
+ {
377
+ icon: DOC_ICON_HTML, iconClass: "nav-ctx-doc", label: "New Doc",
378
+ action: () => this.callbacks.onCreateProjectDoc(projectId),
379
+ },
380
+ {
381
+ icon: BOARD_ICON_HTML, iconClass: "nav-ctx-board", label: "New Board",
382
+ action: () => this.callbacks.onCreateProjectBoard(projectId),
383
+ },
384
+ {
385
+ icon: DIRECTORY_ICON_HTML, iconClass: "nav-ctx-dir", label: "New Directory",
386
+ action: () => this.callbacks.onCreateProjectDirectory(projectId),
387
+ },
388
+ { kind: "divider" as const },
389
+ {
390
+ icon: TRASH_ICON_HTML, iconClass: "nav-ctx-delete", label: "Remove from workspace",
391
+ action: () => this.callbacks.onRemoveProject(projectId, label),
392
+ danger: true,
393
+ },
394
+ ]);
395
+ });
396
+
397
+ header.append(chevron, text, moreBtn);
398
+
399
+ this.registerCollapsible(projectId, body, group, header as HTMLButtonElement, true);
400
+ header.addEventListener("click", () => this.toggleCollapsible(projectId));
401
+
402
+ // Right-click on header itself opens same menu
403
+ header.addEventListener("contextmenu", (e) => {
404
+ e.preventDefault();
405
+ e.stopPropagation();
406
+ moreBtn.click();
407
+ });
408
+
409
+ const projectDropRoot = roots[0];
410
+ if (projectDropRoot) this.enableDirectoryDropTarget(header, projectDropRoot.id, "");
411
+
412
+ // ── Render tree: one unified view, same structure in both modes ──
413
+ if (isRaw) {
414
+ // Raw mode: full project filesystem. /docs/ folders render full content.
415
+ // Non-docs dirs are subtly muted. Right-click non-docs → create /docs/.
416
+ const projectTree = this.lastTreesByProject.get(projectId) ?? [];
417
+ for (const node of projectTree) {
418
+ if (node.type === "dir") {
419
+ body.appendChild(this.renderBrowseNode(project, node, treesByRoot));
420
+ }
421
+ }
422
+ } else {
423
+ // Docs mode: only /docs/ roots and their content.
424
+ if (roots.length === 0) {
425
+ const note = document.createElement("div");
426
+ note.className = "nav-label nav-empty-copy";
427
+ note.textContent = "No docs folders found.";
428
+ body.appendChild(note);
429
+ } else {
430
+ const pathTree = buildPathTree(roots);
431
+ body.appendChild(this.renderPathNodes(pathTree, treesByRoot, projectId));
432
+ }
433
+ }
434
+
435
+ // ── Footer links (always visible when docs exist) ──
436
+ if (roots.length > 0) {
437
+ const boardsRow = document.createElement("div");
438
+ boardsRow.className = "nav-project-boards-row";
439
+ const allBoardsLink = this.link(
440
+ projectBoardsHref(projectId),
441
+ "All Boards",
442
+ `boards:${projectId}`
443
+ );
444
+ allBoardsLink.classList.add("nav-link-board");
445
+ allBoardsLink.insertAdjacentHTML("afterbegin", BOARD_ICON_HTML);
446
+ boardsRow.appendChild(allBoardsLink);
447
+ body.appendChild(boardsRow);
448
+
449
+ const firstRootWithCaps = roots.find((r) => {
450
+ const tree = treesByRoot.get(r.id) ?? [];
451
+ const capsDir = tree.find((n) => n.type === "dir" && n.name === "capabilities");
452
+ return capsDir?.type === "dir" &&
453
+ capsDir.children.some((n) => n.type === "file" && n.name.endsWith(".md"));
454
+ }) ?? roots[0];
455
+ const capsRow = document.createElement("div");
456
+ capsRow.className = "nav-project-boards-row";
457
+ const allCapsLink = this.link(
458
+ rootCapabilitiesHref(firstRootWithCaps.id),
459
+ "All Capabilities",
460
+ `caps:${projectId}`
461
+ );
462
+ allCapsLink.classList.add("nav-link-capability");
463
+ allCapsLink.insertAdjacentHTML("afterbegin", CAPABILITY_ICON_HTML);
464
+ capsRow.appendChild(allCapsLink);
465
+ body.appendChild(capsRow);
466
+ }
467
+
468
+ group.append(header, body);
469
+ return group;
470
+ }
471
+
472
+ // ---------- Browse mode (filesystem view) ----------
473
+
474
+ /**
475
+ * Render a directory node in the filesystem browse tree.
476
+ * /docs/ directories render their full docs content (identical to normal mode).
477
+ * Non-docs directories are greyed out with right-click to create /docs/.
478
+ */
479
+ private renderBrowseNode(
480
+ project: WorkspaceProject,
481
+ node: TreeNode,
482
+ treesByRoot: Map<string, TreeNode[]>
483
+ ): HTMLElement {
484
+ if (node.type === "file") {
485
+ const div = document.createElement("div");
486
+ div.className = "nav-browse-file";
487
+ div.textContent = node.name;
488
+ return div;
489
+ }
490
+
491
+ const isDocsFolder = node.name === "docs";
492
+
493
+ // /docs/ folder → render full docs content, same as normal mode.
494
+ if (isDocsFolder) {
495
+ const matchingRoot = project.docsRoots.find((r) =>
496
+ r.displayPath === node.path || r.displayPath.endsWith("/" + node.path)
497
+ );
498
+ if (matchingRoot) {
499
+ const tree = treesByRoot.get(matchingRoot.id) ?? [];
500
+ const section = this.docsRootSection(matchingRoot, tree);
501
+ return section;
502
+ }
503
+ }
504
+
505
+ // Non-docs directory. Directories that contain /docs/ somewhere below
506
+ // them look solid (they're part of the docs path). Others are muted.
507
+ const hasDocsDescendant = hasDocs(node);
508
+ const key = `browse:${project.id}:${node.path}`;
509
+
510
+ const group = document.createElement("div");
511
+ group.className = hasDocsDescendant ? "nav-subgroup nav-collapsible-dir" : "nav-subgroup nav-browse-dir";
512
+
513
+ const childrenContainer = document.createElement("div");
514
+ childrenContainer.className = "nav-dir-body";
515
+
516
+ const toggle = document.createElement("button");
517
+ toggle.className = hasDocsDescendant ? "nav-dir-toggle nav-dir-project" : "nav-browse-dir-toggle";
518
+ toggle.type = "button";
519
+
520
+ const chevronSpan = document.createElement("span");
521
+ chevronSpan.className = "nav-repo-chevron";
522
+ chevronSpan.textContent = "›";
523
+ const nameSpan = document.createElement("span");
524
+ nameSpan.textContent = node.name;
525
+ toggle.append(chevronSpan, nameSpan);
526
+ toggle.addEventListener("click", () => this.toggleCollapsible(key));
527
+
528
+ // Right-click behaviour depends on whether this dir leads to /docs/
529
+ if (hasDocsDescendant) {
530
+ // This dir is part of the docs path — offer docs root actions
531
+ const nearestRoot = project.docsRoots.find((r) =>
532
+ r.displayPath.startsWith(node.path + "/") || r.displayPath === node.path
533
+ );
534
+ if (nearestRoot) {
535
+ toggle.addEventListener("contextmenu", (e) => {
536
+ e.preventDefault();
537
+ e.stopPropagation();
538
+ this.openDirContextMenu(e.clientX, e.clientY, nearestRoot, "");
539
+ });
540
+ }
541
+ } else {
542
+ // Non-docs directory — only offer to create /docs/
543
+ toggle.addEventListener("contextmenu", (e) => {
544
+ e.preventDefault();
545
+ e.stopPropagation();
546
+ this.showContextMenu(e.clientX, e.clientY, node.name, [
547
+ {
548
+ icon: DIRECTORY_ICON_HTML,
549
+ iconClass: "nav-ctx-dir",
550
+ label: "Create /docs/",
551
+ action: () => this.callbacks.onCreateDocsFolder(project.id, node.path),
552
+ },
553
+ ]);
554
+ });
555
+ }
556
+
557
+ for (const child of node.children) {
558
+ if (child.type === "dir") {
559
+ childrenContainer.appendChild(this.renderBrowseNode(project, child, treesByRoot));
560
+ }
561
+ }
562
+
563
+ this.registerCollapsible(key, childrenContainer, group, toggle, true);
564
+ group.append(toggle, childrenContainer);
565
+
566
+ return group;
567
+ }
568
+
569
+ // ---------- Nested path-segment tree rendering ----------
570
+
571
+ private renderPathNodes(
572
+ nodes: PathNode[],
573
+ treesByRoot: Map<string, TreeNode[]>,
574
+ projectId: string,
575
+ parentPrefix = ""
576
+ ): HTMLElement {
577
+ const frag = document.createElement("div");
578
+ for (const node of nodes) {
579
+ if (node.kind === "root") {
580
+ frag.appendChild(this.docsRootSection(node.root, treesByRoot.get(node.root.id) ?? []));
581
+ } else {
582
+ const fullSegment = parentPrefix ? `${parentPrefix}/${node.segment}` : node.segment;
583
+ const key = `pathseg:${projectId}:${fullSegment}`;
584
+ const body = this.renderPathNodes(node.children, treesByRoot, projectId, fullSegment);
585
+ body.classList.add("nav-dir-body");
586
+ const group = document.createElement("div");
587
+ group.className = "nav-subgroup nav-collapsible-dir";
588
+
589
+ const firstRoot = firstLeafRoot(node);
590
+
591
+ const { row: dirHeader, toggle: btn } = this.createDirectoryRow(
592
+ node.segment,
593
+ `#/project/${encodeURIComponent(projectId)}`,
594
+ `${node.segment} wiki`,
595
+ () => this.toggleCollapsible(key),
596
+ true
597
+ );
598
+ if (firstRoot) {
599
+ group.addEventListener("contextmenu", (e) => {
600
+ e.preventDefault();
601
+ e.stopPropagation();
602
+ this.openDirContextMenu(e.clientX, e.clientY, firstRoot, "");
603
+ });
604
+ }
605
+
606
+ this.registerCollapsible(key, body, group, btn, true);
607
+ group.append(dirHeader, body);
608
+ frag.appendChild(group);
609
+ }
610
+ }
611
+ return frag;
612
+ }
613
+
614
+ // ---------- Docs-root section ----------
615
+
616
+ private docsRootSection(root: DocsRootSummary, tree: TreeNode[]): HTMLElement {
617
+ const section = document.createElement("div");
618
+ section.className = "nav-subgroup nav-docs-root";
619
+
620
+ const finalSegment = root.displayPath.split("/").pop() ?? root.displayPath;
621
+ const shouldShowRootLabel = finalSegment.toLowerCase() !== "docs";
622
+ if (shouldShowRootLabel) {
623
+ const label = document.createElement("div");
624
+ label.className = "nav-root-path-label";
625
+ label.textContent = finalSegment;
626
+ section.appendChild(label);
627
+
628
+ label.addEventListener("contextmenu", (e) => {
629
+ e.preventDefault();
630
+ e.stopPropagation();
631
+ this.openDirContextMenu(e.clientX, e.clientY, root, "");
632
+ });
633
+ }
634
+
635
+ section.addEventListener("contextmenu", (e) => {
636
+ if ((e.target as HTMLElement | null)?.closest(".nav-link, .nav-dir-toggle")) return;
637
+ e.preventDefault();
638
+ e.stopPropagation();
639
+ this.openDirContextMenu(e.clientX, e.clientY, root, "");
640
+ });
641
+ this.enableDirectoryDropTarget(section, root.id, "");
642
+
643
+ section.appendChild(this.docsNodes(root, flattenRedundantDocsDir(tree), "", tree));
644
+ return section;
645
+ }
646
+
647
+ // ---------- Unified directory rendering ----------
648
+
649
+ private createDirectoryRow(
650
+ name: string,
651
+ wikiHref: string,
652
+ wikiTitle: string,
653
+ onToggle: (e?: Event) => void,
654
+ isProjectStructure = false // true = greyed out, false = docs content
655
+ ): { row: HTMLElement; toggle: HTMLButtonElement } {
656
+ const row = document.createElement("div");
657
+ row.className = "nav-dir-header-row";
658
+
659
+ const btn = document.createElement("button");
660
+ btn.className = isProjectStructure ? "nav-dir-toggle nav-dir-project" : "nav-dir-toggle nav-dir-docs";
661
+ btn.type = "button";
662
+
663
+ const chevron = document.createElement("span");
664
+ chevron.className = "nav-repo-chevron";
665
+ chevron.innerHTML = `<svg viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><path d="M6 4l4 4-4 4"/></svg>`;
666
+
667
+ const textSpan = document.createElement("span");
668
+ textSpan.textContent = name;
669
+
670
+ btn.append(chevron, textSpan);
671
+ btn.addEventListener("click", onToggle);
672
+
673
+ const wikiLink = document.createElement("a");
674
+ wikiLink.className = "nav-repo-wiki-link nav-dir-wiki-link";
675
+ wikiLink.href = wikiHref;
676
+ wikiLink.title = wikiTitle;
677
+ wikiLink.innerHTML =
678
+ `<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
679
+ `<rect x="2" y="2" width="10" height="10" rx="2"/>` +
680
+ `<path d="M5 5h4M5 7h4M5 9h2"/>` +
681
+ `</svg>`;
682
+ wikiLink.addEventListener("click", (e) => e.stopPropagation());
683
+ this.links.set(wikiHref, wikiLink);
684
+
685
+ row.append(btn, wikiLink);
686
+ return { row, toggle: btn };
687
+ }
688
+
689
+ // ---------- Mixed docs + boards tree ----------
690
+
691
+ private docsNodes(root: DocsRootSummary, nodes: TreeNode[], parentPath: string, boardTree: TreeNode[]): HTMLElement {
692
+ const frag = document.createElement("div");
693
+
694
+ const docNodes = visibleDocsNodes(nodes);
695
+ const boardFiles = boardNodesAt(boardTree, parentPath);
696
+
697
+ type MixedNode =
698
+ | { kind: "doc"; node: TreeNode }
699
+ | { kind: "board"; name: string; boardId: string };
700
+
701
+ const mixed: MixedNode[] = [
702
+ ...docNodes.map((node) => ({ kind: "doc" as const, node })),
703
+ ...boardFiles.map((f) => ({
704
+ kind: "board" as const,
705
+ name: prettify(boardIdFromFilePath(f.path).split("/").pop() ?? boardIdFromFilePath(f.path)),
706
+ boardId: boardIdFromFilePath(f.path),
707
+ })),
708
+ ].sort((a, b) => {
709
+ const aIsIndex = a.kind === "doc" && a.node.type === "file" && a.node.name === "index.md";
710
+ const bIsIndex = b.kind === "doc" && b.node.type === "file" && b.node.name === "index.md";
711
+ if (aIsIndex !== bIsIndex) return aIsIndex ? -1 : 1;
712
+ const aIsDir = a.kind === "doc" && a.node.type === "dir";
713
+ const bIsDir = b.kind === "doc" && b.node.type === "dir";
714
+ if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
715
+ const aName = a.kind === "doc" ? a.node.name : a.name;
716
+ const bName = b.kind === "doc" ? b.node.name : b.name;
717
+ return aName.localeCompare(bName);
718
+ });
719
+
720
+ for (const item of mixed) {
721
+ if (item.kind === "board") {
722
+ const a = document.createElement("a");
723
+ a.className = "nav-link nav-link-board";
724
+ a.href = rootBoardHref(root.id, item.boardId);
725
+ a.insertAdjacentHTML("afterbegin", BOARD_ICON_HTML);
726
+ a.appendChild(document.createTextNode(item.name));
727
+ const key = `board:${root.id}:${item.boardId}`;
728
+ this.links.set(key, a);
729
+ this.links.set(a.href, a);
730
+ this.enableDragSource(a, {
731
+ kind: "board",
732
+ rootId: root.id,
733
+ boardId: item.boardId,
734
+ });
735
+ // Right-click on board link
736
+ a.addEventListener("contextmenu", (e) => {
737
+ e.preventDefault();
738
+ e.stopPropagation();
739
+ this.openBoardContextMenu(e.clientX, e.clientY, root, item.boardId);
740
+ });
741
+ frag.appendChild(a);
742
+ } else {
743
+ const { node } = item;
744
+ if (node.type === "file") {
745
+ if (!node.name.endsWith(".md")) continue;
746
+ const linkLabel = node.name === "index.md" ? "Overview" : prettify(node.name);
747
+ const a = this.link(rootDocHref(root.id, node.path), linkLabel, `doc:${root.id}:${node.path}`);
748
+ a.insertAdjacentHTML("afterbegin", DOC_ICON_HTML);
749
+ this.enableDragSource(a, {
750
+ kind: "doc",
751
+ rootId: root.id,
752
+ path: node.path,
753
+ });
754
+ // Right-click on doc link
755
+ a.addEventListener("contextmenu", (e) => {
756
+ e.preventDefault();
757
+ e.stopPropagation();
758
+ this.openDocContextMenu(e.clientX, e.clientY, root, node.path);
759
+ });
760
+ frag.appendChild(a);
761
+ } else {
762
+ const key = `doc:${root.id}:${node.path}`;
763
+ const childNodes = this.docsNodes(root, node.children, node.path, boardTree);
764
+ childNodes.classList.add("nav-dir-body");
765
+ const group = document.createElement("div");
766
+ group.className = "nav-subgroup nav-collapsible-dir";
767
+
768
+ // Use unified directory row creator (docs content = darker)
769
+ const { row: dirHeader, toggle: btn } = this.createDirectoryRow(
770
+ prettify(node.name),
771
+ `#/root/${encodeURIComponent(root.id)}/directory/${encodeURIComponent(node.path)}`,
772
+ `${node.name} wiki`,
773
+ (e) => {
774
+ if ((e as MouseEvent).button !== 0) return;
775
+ this.toggleCollapsible(key);
776
+ },
777
+ false // isProjectStructure = false (darker, inside docs root)
778
+ );
779
+
780
+ this.enableDragSource(btn, {
781
+ kind: "dir",
782
+ rootId: root.id,
783
+ path: node.path,
784
+ });
785
+
786
+ // Right-click on directory group
787
+ group.addEventListener("contextmenu", (e) => {
788
+ e.preventDefault();
789
+ e.stopPropagation();
790
+ this.openDirContextMenu(e.clientX, e.clientY, root, node.path);
791
+ });
792
+ this.enableDirectoryDropTarget(group, root.id, node.path);
793
+
794
+ this.registerCollapsible(key, childNodes, group, btn, true);
795
+ group.append(dirHeader, childNodes);
796
+ frag.appendChild(group);
797
+ }
798
+ }
799
+ }
800
+
801
+ return frag;
802
+ }
803
+
804
+ // ---------- Drag/drop movement ----------
805
+
806
+ private enableDragSource(el: HTMLElement, item: SidebarDragItem): void {
807
+ el.draggable = true;
808
+ el.addEventListener("dragstart", (e) => {
809
+ this.closeContextMenu();
810
+ this.draggedItem = item;
811
+ el.classList.add("nav-dragging");
812
+ e.dataTransfer?.setData("application/x-docs-nav-item", JSON.stringify(item));
813
+ e.dataTransfer?.setData("text/plain", dragItemLabel(item));
814
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
815
+ });
816
+ el.addEventListener("dragend", () => {
817
+ el.classList.remove("nav-dragging");
818
+ this.draggedItem = undefined;
819
+ this.clearDropTargets();
820
+ });
821
+ }
822
+
823
+ private enableDirectoryDropTarget(el: HTMLElement, rootId: string, targetDir: string): void {
824
+ el.addEventListener("dragover", (e) => {
825
+ const item = this.currentDragItem(e);
826
+ if (!item || !this.canDropItem(item, rootId, targetDir)) return;
827
+ e.preventDefault();
828
+ e.stopPropagation();
829
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
830
+ el.classList.add("nav-drop-target");
831
+ });
832
+ el.addEventListener("dragleave", (e) => {
833
+ if (el.contains(e.relatedTarget as Node | null)) return;
834
+ el.classList.remove("nav-drop-target");
835
+ });
836
+ el.addEventListener("drop", (e) => {
837
+ const item = this.currentDragItem(e);
838
+ if (!item || !this.canDropItem(item, rootId, targetDir)) return;
839
+ e.preventDefault();
840
+ e.stopPropagation();
841
+ this.clearDropTargets();
842
+ this.moveDraggedItem(item, targetDir);
843
+ });
844
+ }
845
+
846
+ private currentDragItem(e: DragEvent): SidebarDragItem | undefined {
847
+ if (this.draggedItem) return this.draggedItem;
848
+ const raw = e.dataTransfer?.getData("application/x-docs-nav-item");
849
+ if (!raw) return undefined;
850
+ try {
851
+ const parsed = JSON.parse(raw) as SidebarDragItem;
852
+ if (
853
+ parsed &&
854
+ (parsed.kind === "doc" || parsed.kind === "board" || parsed.kind === "dir") &&
855
+ typeof parsed.rootId === "string"
856
+ ) {
857
+ return parsed;
858
+ }
859
+ } catch {
860
+ /* ignore */
861
+ }
862
+ return undefined;
863
+ }
864
+
865
+ private canDropItem(item: SidebarDragItem, rootId: string, targetDir: string): boolean {
866
+ if (item.rootId !== rootId) return false;
867
+ const normalizedTarget = normalizeDirPath(targetDir);
868
+ if (item.kind === "doc") return parentDir(item.path) !== normalizedTarget;
869
+ if (item.kind === "board") return parentDir(item.boardId) !== normalizedTarget;
870
+
871
+ const source = normalizeDirPath(item.path);
872
+ if (!source) return false;
873
+ if (source === normalizedTarget) return false;
874
+ if (parentDir(source) === normalizedTarget) return false;
875
+ return !normalizedTarget.startsWith(`${source}/`);
876
+ }
877
+
878
+ private moveDraggedItem(item: SidebarDragItem, targetDir: string): void {
879
+ const normalizedTarget = normalizeDirPath(targetDir);
880
+ if (item.kind === "doc") {
881
+ this.callbacks.onMoveDoc(item.rootId, item.path, normalizedTarget);
882
+ } else if (item.kind === "board") {
883
+ this.callbacks.onMoveBoard(item.rootId, item.boardId, normalizedTarget);
884
+ } else {
885
+ this.callbacks.onMoveDirectory(item.rootId, item.path, normalizedTarget);
886
+ }
887
+ }
888
+
889
+ private clearDropTargets(): void {
890
+ this.element.querySelectorAll(".nav-drop-target").forEach((node) => {
891
+ node.classList.remove("nav-drop-target");
892
+ });
893
+ }
894
+
895
+ // ---------- Inline editing (VS Code style) ----------
896
+
897
+ /**
898
+ * Replace a nav link's text with an inline input for renaming.
899
+ * Enter confirms, Escape cancels, blur confirms.
900
+ */
901
+ startInlineRename(
902
+ linkEl: HTMLElement,
903
+ currentName: string,
904
+ onConfirm: (newName: string) => void
905
+ ): void {
906
+ const input = document.createElement("input");
907
+ input.className = "nav-inline-input";
908
+ input.value = currentName.replace(/\.md$/, "");
909
+ // Preserve the link's position in the tree
910
+ const parent = linkEl.parentElement;
911
+ if (!parent) return;
912
+ linkEl.style.display = "none";
913
+ parent.insertBefore(input, linkEl.nextSibling);
914
+ input.focus();
915
+ input.select();
916
+
917
+ let settled = false;
918
+ const finish = (accept: boolean) => {
919
+ if (settled) return;
920
+ settled = true;
921
+ input.remove();
922
+ linkEl.style.display = "";
923
+ if (accept) {
924
+ const raw = input.value.trim();
925
+ if (raw && raw !== currentName.replace(/\.md$/, "")) {
926
+ onConfirm(raw);
927
+ }
928
+ }
929
+ };
930
+
931
+ input.addEventListener("keydown", (e) => {
932
+ e.stopPropagation();
933
+ if (e.key === "Enter") { e.preventDefault(); finish(true); }
934
+ if (e.key === "Escape") { e.preventDefault(); finish(false); }
935
+ });
936
+ input.addEventListener("blur", () => finish(true));
937
+ }
938
+
939
+ /**
940
+ * Show an inline input inside a container for creating a new item.
941
+ * Enter confirms, Escape cancels.
942
+ */
943
+ startInlineCreate(
944
+ container: HTMLElement,
945
+ placeholder: string,
946
+ onConfirm: (name: string) => void
947
+ ): void {
948
+ const input = document.createElement("input");
949
+ input.className = "nav-inline-input";
950
+ input.placeholder = placeholder;
951
+ container.insertBefore(input, container.firstChild);
952
+ input.focus();
953
+
954
+ let settled = false;
955
+ const finish = (accept: boolean) => {
956
+ if (settled) return;
957
+ settled = true;
958
+ input.remove();
959
+ if (accept) {
960
+ const raw = input.value.trim();
961
+ if (raw) onConfirm(raw);
962
+ }
963
+ };
964
+
965
+ input.addEventListener("keydown", (e) => {
966
+ e.stopPropagation();
967
+ if (e.key === "Enter") { e.preventDefault(); finish(true); }
968
+ if (e.key === "Escape") { e.preventDefault(); finish(false); }
969
+ });
970
+ input.addEventListener("blur", () => finish(true));
971
+ }
972
+
973
+ // ---------- Collapse / expand machinery ----------
974
+
975
+ /**
976
+ * Register a collapsible section. `defaultCollapsed` is always true —
977
+ * everything starts collapsed. Apply initial DOM state immediately.
978
+ */
979
+ private registerCollapsible(
980
+ key: string,
981
+ body: HTMLElement,
982
+ group: HTMLElement,
983
+ btn: HTMLButtonElement,
984
+ defaultCollapsed: boolean
985
+ ): void {
986
+ this.collapsibles.set(key, { body, group, btn });
987
+ this.applyCollapsed(key, this.collapsedState.get(key) ?? defaultCollapsed);
988
+ }
989
+
990
+ private applyCollapsed(key: string, collapsed: boolean): void {
991
+ const rec = this.collapsibles.get(key);
992
+ if (!rec) return;
993
+ this.collapsedState.set(key, collapsed);
994
+ rec.body.hidden = collapsed;
995
+ rec.group.classList.toggle("collapsed", collapsed);
996
+ rec.btn.setAttribute("aria-expanded", String(!collapsed));
997
+ }
998
+
999
+ private toggleCollapsible(key: string): void {
1000
+ const rec = this.collapsibles.get(key);
1001
+ if (!rec) return;
1002
+ this.applyCollapsed(key, !rec.body.hidden);
1003
+ }
1004
+
1005
+
1006
+
1007
+ // ---------- Context menus ----------
1008
+
1009
+ private openDirContextMenu(x: number, y: number, root: DocsRootSummary, dirPath: string): void {
1010
+ const label = dirPath
1011
+ ? prettify(dirPath.split("/").pop() ?? dirPath)
1012
+ : (root.displayPath.split("/").pop() ?? root.displayPath);
1013
+
1014
+ const items = [
1015
+ {
1016
+ icon: DIRECTORY_ICON_HTML, iconClass: "nav-ctx-dir", label: "New Directory",
1017
+ action: () => this.callbacks.onCreateDirectory(root.id, dirPath),
1018
+ },
1019
+ {
1020
+ icon: BOARD_ICON_HTML, iconClass: "nav-ctx-board", label: "New Board",
1021
+ action: () => this.callbacks.onCreateBoard(root.id, dirPath),
1022
+ },
1023
+ {
1024
+ icon: DOC_ICON_HTML, iconClass: "nav-ctx-doc", label: "New Doc",
1025
+ action: () => this.callbacks.onCreateDoc(root.id, dirPath),
1026
+ },
1027
+ {
1028
+ icon: CAPABILITY_ICON_HTML, iconClass: "nav-ctx-cap", label: "New Capability",
1029
+ action: () => this.callbacks.onCreateCapability(root.id, dirPath),
1030
+ },
1031
+ ];
1032
+
1033
+ // Add rename/delete for non-root directories
1034
+ if (dirPath) {
1035
+ items.push(
1036
+ {
1037
+ icon: RENAME_ICON_HTML, iconClass: "nav-ctx-rename", label: "Rename",
1038
+ action: () => this.callbacks.onRenameDirectory(root.id, dirPath),
1039
+ },
1040
+ {
1041
+ icon: TRASH_ICON_HTML, iconClass: "nav-ctx-delete", label: "Delete",
1042
+ action: () => this.callbacks.onDeleteDirectory(root.id, dirPath),
1043
+ }
1044
+ );
1045
+ }
1046
+
1047
+ this.showContextMenu(x, y, label, items);
1048
+ }
1049
+
1050
+ private openEmptyProjectContextMenu(x: number, y: number, projectId: string, label: string): void {
1051
+ this.showContextMenu(x, y, label, [
1052
+ {
1053
+ icon: DIRECTORY_ICON_HTML, iconClass: "nav-ctx-dir", label: "New Directory",
1054
+ action: () => this.callbacks.onCreateProjectDirectory(projectId),
1055
+ },
1056
+ {
1057
+ icon: BOARD_ICON_HTML, iconClass: "nav-ctx-board", label: "New Board",
1058
+ action: () => this.callbacks.onCreateProjectBoard(projectId),
1059
+ },
1060
+ {
1061
+ icon: DOC_ICON_HTML, iconClass: "nav-ctx-doc", label: "New Doc",
1062
+ action: () => this.callbacks.onCreateProjectDoc(projectId),
1063
+ },
1064
+ ]);
1065
+ }
1066
+
1067
+ private openDocContextMenu(x: number, y: number, root: DocsRootSummary, filePath: string): void {
1068
+ const name = filePath.split("/").pop() ?? filePath;
1069
+ this.showContextMenu(x, y, name, [
1070
+ {
1071
+ icon: DOC_ICON_HTML, iconClass: "nav-ctx-doc", label: "Open",
1072
+ action: () => this.callbacks.onOpenDoc(root.id, filePath),
1073
+ },
1074
+ {
1075
+ icon: EDIT_ICON_HTML, iconClass: "nav-ctx-edit", label: "Edit",
1076
+ action: () => this.callbacks.onEditDoc(root.id, filePath),
1077
+ },
1078
+ { kind: "divider" as const },
1079
+ {
1080
+ icon: RENAME_ICON_HTML, iconClass: "nav-ctx-rename", label: "Rename",
1081
+ action: () => this.callbacks.onRenameDoc(root.id, filePath),
1082
+ },
1083
+ {
1084
+ icon: TRASH_ICON_HTML, iconClass: "nav-ctx-delete", label: "Delete",
1085
+ action: () => this.callbacks.onDeleteDoc(root.id, filePath),
1086
+ danger: true,
1087
+ },
1088
+ ]);
1089
+ }
1090
+
1091
+ private openBoardContextMenu(x: number, y: number, root: DocsRootSummary, boardId: string): void {
1092
+ const name = prettify(boardId.split("/").pop() ?? boardId);
1093
+ this.showContextMenu(x, y, name, [
1094
+ {
1095
+ icon: BOARD_ICON_HTML, iconClass: "nav-ctx-board", label: "Open",
1096
+ action: () => this.callbacks.onOpenBoard(root.id, boardId),
1097
+ },
1098
+ { kind: "divider" as const },
1099
+ {
1100
+ icon: RENAME_ICON_HTML, iconClass: "nav-ctx-rename", label: "Rename",
1101
+ action: () => this.callbacks.onRenameBoard(root.id, boardId),
1102
+ },
1103
+ {
1104
+ icon: TRASH_ICON_HTML, iconClass: "nav-ctx-delete", label: "Delete",
1105
+ action: () => this.callbacks.onDeleteBoard(root.id, boardId),
1106
+ danger: true,
1107
+ },
1108
+ ]);
1109
+ }
1110
+
1111
+ private showContextMenu(
1112
+ x: number,
1113
+ y: number,
1114
+ label: string,
1115
+ items: Array<
1116
+ | { kind?: undefined; icon: string; iconClass: string; label: string; action: () => void; danger?: boolean }
1117
+ | { kind: "divider" }
1118
+ >
1119
+ ): void {
1120
+ this.closeContextMenu();
1121
+
1122
+ const menu = document.createElement("div");
1123
+ menu.className = "nav-context-menu";
1124
+ menu.addEventListener("pointerdown", (e) => e.stopPropagation());
1125
+
1126
+ const lbl = document.createElement("div");
1127
+ lbl.className = "nav-context-label";
1128
+ lbl.textContent = label;
1129
+ menu.appendChild(lbl);
1130
+
1131
+ const topDivider = document.createElement("div");
1132
+ topDivider.className = "nav-context-divider";
1133
+ menu.appendChild(topDivider);
1134
+
1135
+ for (const item of items) {
1136
+ if (item.kind === "divider") {
1137
+ const d = document.createElement("div");
1138
+ d.className = "nav-context-divider";
1139
+ menu.appendChild(d);
1140
+ continue;
1141
+ }
1142
+ const btn = document.createElement("button");
1143
+ btn.className = "nav-context-item" + (item.danger ? " nav-context-item-danger" : "");
1144
+ btn.type = "button";
1145
+ const icon = document.createElement("span");
1146
+ icon.className = `nav-ctx-icon ${item.iconClass}`;
1147
+ icon.innerHTML = item.icon;
1148
+ const text = document.createElement("span");
1149
+ text.textContent = item.label;
1150
+ btn.append(icon, text);
1151
+ btn.addEventListener("click", () => {
1152
+ this.closeContextMenu();
1153
+ item.action();
1154
+ });
1155
+ menu.appendChild(btn);
1156
+ }
1157
+
1158
+ menu.style.position = "fixed";
1159
+ menu.style.left = `${x}px`;
1160
+ menu.style.top = `${y}px`;
1161
+ document.body.appendChild(menu);
1162
+ this.contextMenu = menu;
1163
+
1164
+ requestAnimationFrame(() => {
1165
+ if (!this.contextMenu) return;
1166
+ const rect = menu.getBoundingClientRect();
1167
+ if (rect.right > window.innerWidth) menu.style.left = `${Math.max(0, x - rect.width)}px`;
1168
+ if (rect.bottom > window.innerHeight) menu.style.top = `${Math.max(0, y - rect.height)}px`;
1169
+ });
1170
+ }
1171
+
1172
+ private closeContextMenu(): void {
1173
+ this.contextMenu?.remove();
1174
+ this.contextMenu = undefined;
1175
+ }
1176
+
1177
+ // ---------- Helpers ----------
1178
+
1179
+ private groupLabel(text: string): HTMLElement {
1180
+ const el = document.createElement("div");
1181
+ el.className = "nav-label";
1182
+ el.textContent = text;
1183
+ return el;
1184
+ }
1185
+
1186
+ private link(href: string, label: string, key: string): HTMLAnchorElement {
1187
+ const a = document.createElement("a");
1188
+ a.className = "nav-link";
1189
+ a.href = href;
1190
+ a.textContent = label;
1191
+ this.links.set(key, a);
1192
+ this.links.set(href, a);
1193
+ return a;
1194
+ }
1195
+ }
1196
+
1197
+ // ---------- Inline SVG icons ----------
1198
+
1199
+ const BOARD_ICON_HTML =
1200
+ `<svg class="nav-node-icon nav-node-icon-board" viewBox="0 0 16 16" aria-hidden="true">` +
1201
+ `<rect x="1" y="1" width="6" height="6" rx="1"/>` +
1202
+ `<rect x="9" y="1" width="6" height="6" rx="1"/>` +
1203
+ `<rect x="1" y="9" width="6" height="6" rx="1"/>` +
1204
+ `<rect x="9" y="9" width="6" height="6" rx="1"/>` +
1205
+ `</svg>`;
1206
+
1207
+ /** Skill icon: a star/spark shape. */
1208
+ const CAPABILITY_ICON_HTML =
1209
+ `<svg class="nav-node-icon nav-node-icon-capability" viewBox="0 0 16 16" aria-hidden="true">` +
1210
+ `<path d="M8 1l2 4.5 4.5 0.7-3.3 3.1 0.8 4.7-4-2.1-4 2.1 0.8-4.7L1.5 6.2l4.5-0.7z"/>` +
1211
+ `</svg>`;
1212
+
1213
+ const DOC_ICON_HTML =
1214
+ `<svg class="nav-node-icon nav-node-icon-doc" viewBox="0 0 16 16" aria-hidden="true">` +
1215
+ `<path d="M3 1.5h6.5l3 3V14.5H3z"/>` +
1216
+ `<path d="M9.5 1.5V4.5h3" stroke="none"/>` +
1217
+ `<line x1="5" y1="7" x2="11" y2="7" stroke="var(--bg-canvas)" stroke-width="0.8"/>` +
1218
+ `<line x1="5" y1="9.5" x2="11" y2="9.5" stroke="var(--bg-canvas)" stroke-width="0.8"/>` +
1219
+ `<line x1="5" y1="12" x2="9" y2="12" stroke="var(--bg-canvas)" stroke-width="0.8"/>` +
1220
+ `</svg>`;
1221
+
1222
+ const DIRECTORY_ICON_HTML =
1223
+ `<svg class="nav-ctx-svg" viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round">` +
1224
+ `<path d="M2 4.5h4l1.4 1.6H14v6.4a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1z"/>` +
1225
+ `<path d="M2 4.5v-1a1 1 0 0 1 1-1h3l1.4 2"/>` +
1226
+ `</svg>`;
1227
+
1228
+ // Context-menu-only icons (stroke-based, not fill).
1229
+ const EDIT_ICON_HTML =
1230
+ `<svg class="nav-ctx-svg" viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">` +
1231
+ `<path d="M11 2l3 3-8 8H3v-3z"/>` +
1232
+ `</svg>`;
1233
+
1234
+ const RENAME_ICON_HTML =
1235
+ `<svg class="nav-ctx-svg" viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">` +
1236
+ `<path d="M2 12h12M8 3v9M5 6l3-3 3 3"/>` +
1237
+ `</svg>`;
1238
+
1239
+ const TRASH_ICON_HTML =
1240
+ `<svg class="nav-ctx-svg" viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">` +
1241
+ `<path d="M2 4h12M5 4V2h6v2M6 7v5M10 7v5M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/>` +
1242
+ `</svg>`;
1243
+
1244
+ // ---------- Tree utilities ----------
1245
+
1246
+ function visibleDocsNodes(nodes: TreeNode[]): TreeNode[] {
1247
+ return nodes
1248
+ .filter((node) => {
1249
+ if (node.type === "file") return node.name.endsWith(".md");
1250
+ if (SPECIAL_DIRS.has(node.name)) return false;
1251
+ return true;
1252
+ })
1253
+ .map((node) => {
1254
+ if (node.type === "file") return node;
1255
+ return { ...node, children: visibleDocsNodes(node.children) };
1256
+ });
1257
+ }
1258
+
1259
+ function flattenRedundantDocsDir(nodes: TreeNode[]): TreeNode[] {
1260
+ const docsDir = nodes.find((node) => node.type === "dir" && node.name.toLowerCase() === "docs");
1261
+ if (!docsDir || docsDir.type !== "dir") return nodes;
1262
+ return nodes
1263
+ .filter((node) => node !== docsDir)
1264
+ .concat(docsDir.children);
1265
+ }
1266
+
1267
+ function boardNodesAt(tree: TreeNode[], parentPath: string): TreeNode[] {
1268
+ const prefix = parentPath ? `${parentPath}/` : "";
1269
+ return allBoardFiles(tree).filter((n) => {
1270
+ if (!prefix) return !n.path.includes("/");
1271
+ if (!n.path.startsWith(prefix)) return false;
1272
+ return !n.path.slice(prefix.length).includes("/");
1273
+ });
1274
+ }
1275
+
1276
+ function allBoardFiles(tree: TreeNode[]): Array<Extract<TreeNode, { type: "file" }>> {
1277
+ const out: Array<Extract<TreeNode, { type: "file" }>> = [];
1278
+ const walk = (nodes: TreeNode[]) => {
1279
+ for (const node of nodes) {
1280
+ if (node.type === "file") {
1281
+ if (node.name.endsWith(".board.json")) out.push(node);
1282
+ } else {
1283
+ walk(node.children);
1284
+ }
1285
+ }
1286
+ };
1287
+ walk(tree);
1288
+ return out;
1289
+ }
1290
+
1291
+ // ---------- Hash / href builders ----------
1292
+
1293
+ function rootDocHref(rootId: string, path: string): string {
1294
+ return `#/root/${encodeURIComponent(rootId)}/docs/${encodeURIComponent(path)}`;
1295
+ }
1296
+
1297
+ function rootBoardsHref(rootId: string): string {
1298
+ return `#/root/${encodeURIComponent(rootId)}/boards`;
1299
+ }
1300
+
1301
+ function projectBoardsHref(projectId: string): string {
1302
+ return `#/project/${encodeURIComponent(projectId)}/boards`;
1303
+ }
1304
+
1305
+ function rootBoardHref(rootId: string, id: string): string {
1306
+ return `#/root/${encodeURIComponent(rootId)}/boards/${encodeURIComponent(id)}`;
1307
+ }
1308
+
1309
+ function rootCapabilitiesHref(rootId: string): string {
1310
+ return `#/root/${encodeURIComponent(rootId)}/capabilities`;
1311
+ }
1312
+
1313
+ function prettify(name: string): string {
1314
+ const base = name.replace(/\.md$/, "").replace(/[-_]/g, " ");
1315
+ return base.charAt(0).toUpperCase() + base.slice(1);
1316
+ }
1317
+
1318
+ /** Check if a directory node has a "docs" child anywhere in its subtree. */
1319
+ function hasDocs(node: TreeNode): boolean {
1320
+ if (node.type === "file") return false;
1321
+ for (const child of node.children) {
1322
+ if (child.type === "dir" && child.name === "docs") return true;
1323
+ if (child.type === "dir" && hasDocs(child)) return true;
1324
+ }
1325
+ return false;
1326
+ }
1327
+
1328
+ function normalizeDirPath(path: string): string {
1329
+ return path.replace(/^\/+|\/+$/g, "");
1330
+ }
1331
+
1332
+ function parentDir(path: string): string {
1333
+ const normalized = normalizeDirPath(path);
1334
+ const idx = normalized.lastIndexOf("/");
1335
+ return idx === -1 ? "" : normalized.slice(0, idx);
1336
+ }
1337
+
1338
+ function dragItemLabel(item: SidebarDragItem): string {
1339
+ if (item.kind === "doc") return item.path;
1340
+ if (item.kind === "board") return item.boardId;
1341
+ return item.path;
1342
+ }
1343
+
1344
+ /** Walk a PathNode subtree and return the first leaf DocsRootSummary found. */
1345
+ function firstLeafRoot(node: PathNode): DocsRootSummary | null {
1346
+ if (node.kind === "root") return node.root;
1347
+ for (const child of node.children) {
1348
+ const found = firstLeafRoot(child);
1349
+ if (found) return found;
1350
+ }
1351
+ return null;
1352
+ }