viteboard 0.1.1 → 0.1.2

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