viteboard 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -61
- package/content/docs/guide/boards-in-markdown.md +28 -26
- package/content/docs/guide/boards.md +44 -82
- package/content/docs/guide/getting-started.md +36 -48
- package/content/docs/index.md +21 -32
- package/dist/assets/index-C30zRPsW.js +60 -0
- package/dist/assets/index-e5aqtN8W.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/app/AppController.ts +7 -0
- package/src/elements/renderElement.ts +89 -0
- package/src/elements/types.ts +1 -0
- package/src/shell/Shell.ts +4 -6
- package/src/shell/Sidebar.ts +382 -83
- package/src/styles.css +75 -0
- package/src/templates/handoffTemplate.ts +34 -0
- package/src/templates/helpers.ts +10 -2
- package/src/templates/index.ts +14 -0
- package/src/templates/systemDiagramTemplate.ts +6 -6
- package/src/templates/workflowTemplate.ts +35 -0
- package/src/tools/ArrowTool.ts +11 -1
- package/src/tools/SelectTool.ts +1 -1
- package/src/tools/ShapeTool.ts +2 -2
- package/src/tools/ToolContext.ts +7 -0
- package/src/ui/TextEditorOverlay.ts +136 -11
- package/src/ui/Toolbar.ts +39 -1
- package/src/ui/icons.ts +6 -0
- package/vite.config.ts +16 -10
- package/content/docs/capabilities.md +0 -89
- package/content/docs/docs.md +0 -84
- package/content/docs/guide/test.md +0 -6
- package/content/docs/test.md +0 -219
- package/content/docs/tetst-dir/test-board.board.json +0 -15
- package/content/docs/tetst-dir/test-test.md +0 -1
- package/content/docs/workspace.md +0 -73
- package/dist/assets/index-CEpxLM2o.css +0 -1
- package/dist/assets/index-D4xvJdEQ.js +0 -60
package/src/shell/Sidebar.ts
CHANGED
|
@@ -41,9 +41,22 @@ export type SidebarCallbacks = {
|
|
|
41
41
|
|
|
42
42
|
type SidebarDragItem =
|
|
43
43
|
| { kind: "doc"; rootId: string; path: string }
|
|
44
|
+
| { kind: "capability"; rootId: string; path: string }
|
|
44
45
|
| { kind: "board"; rootId: string; boardId: string }
|
|
45
46
|
| { kind: "dir"; rootId: string; path: string };
|
|
46
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
|
+
|
|
47
60
|
// ---------------------------------------------------------------------------
|
|
48
61
|
// Path-segment tree used to group docs roots into a nested hierarchy.
|
|
49
62
|
// ---------------------------------------------------------------------------
|
|
@@ -120,13 +133,15 @@ export class Sidebar {
|
|
|
120
133
|
private callbacks: SidebarCallbacks;
|
|
121
134
|
private contextMenu?: HTMLElement;
|
|
122
135
|
private draggedItem?: SidebarDragItem;
|
|
136
|
+
private dropExpandTimer?: number;
|
|
137
|
+
private dropExpandTarget?: HTMLElement;
|
|
123
138
|
|
|
124
139
|
/** Last full workspace state — kept so we can re-render on mode toggle. */
|
|
125
140
|
private lastWorkspace: WorkspaceState = { projects: [] };
|
|
126
141
|
private lastTreesByRoot = new Map<string, TreeNode[]>();
|
|
127
142
|
private lastTreesByProject = new Map<string, TreeNode[]>();
|
|
128
|
-
/**
|
|
129
|
-
private
|
|
143
|
+
/** Raw mode adds muted project files and folders to the docs-focused tree. */
|
|
144
|
+
private rawModeProjects = new Set<string>();
|
|
130
145
|
|
|
131
146
|
constructor(parent: HTMLElement, callbacks: SidebarCallbacks) {
|
|
132
147
|
this.callbacks = callbacks;
|
|
@@ -214,22 +229,6 @@ export class Sidebar {
|
|
|
214
229
|
|
|
215
230
|
|
|
216
231
|
|
|
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
232
|
setActive(hash: string): void {
|
|
234
233
|
// 1. Clear previous active state.
|
|
235
234
|
for (const a of new Set(this.links.values())) a.classList.remove("active");
|
|
@@ -254,6 +253,12 @@ export class Sidebar {
|
|
|
254
253
|
this.syncCollapsed(hash);
|
|
255
254
|
}
|
|
256
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
|
+
|
|
257
262
|
/**
|
|
258
263
|
* Open every collapsible that is an ancestor of the active page, while
|
|
259
264
|
* preserving the user's existing open/close state elsewhere.
|
|
@@ -303,18 +308,21 @@ export class Sidebar {
|
|
|
303
308
|
|
|
304
309
|
// Project header.
|
|
305
310
|
open.add(projectId);
|
|
311
|
+
// The docs-root folder is a real, collapsible tree row.
|
|
312
|
+
open.add(rootId);
|
|
306
313
|
|
|
307
|
-
//
|
|
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.
|
|
308
316
|
const displayParts = displayPath.split("/").filter(Boolean).slice(0, -1);
|
|
309
317
|
let acc = "";
|
|
310
318
|
for (const part of displayParts) {
|
|
311
319
|
acc = acc ? `${acc}/${part}` : part;
|
|
312
|
-
const
|
|
313
|
-
if (this.collapsibles.has(
|
|
320
|
+
const folderKey = `folder:${projectId}:${acc}`;
|
|
321
|
+
if (this.collapsibles.has(folderKey)) open.add(folderKey);
|
|
314
322
|
}
|
|
315
323
|
|
|
316
324
|
// Doc directory keys: every parent dir of the current file path.
|
|
317
|
-
if (section === "docs" && rest) {
|
|
325
|
+
if ((section === "docs" || section === "capabilities") && rest) {
|
|
318
326
|
const parts = rest.split("/");
|
|
319
327
|
for (let i = 1; i < parts.length; i++) {
|
|
320
328
|
const dirPath = parts.slice(0, i).join("/");
|
|
@@ -333,7 +341,7 @@ export class Sidebar {
|
|
|
333
341
|
treesByRoot: Map<string, TreeNode[]>
|
|
334
342
|
): HTMLElement {
|
|
335
343
|
const { id: projectId, label, docsRoots: roots } = project;
|
|
336
|
-
const isRaw = this.
|
|
344
|
+
const isRaw = this.rawModeProjects.has(projectId);
|
|
337
345
|
|
|
338
346
|
const body = document.createElement("div");
|
|
339
347
|
body.className = "nav-repo-body";
|
|
@@ -351,8 +359,13 @@ export class Sidebar {
|
|
|
351
359
|
const text = document.createElement("span");
|
|
352
360
|
text.className = "nav-repo-label-text";
|
|
353
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;
|
|
354
367
|
|
|
355
|
-
//
|
|
368
|
+
// Project-level actions never alter the navigation model.
|
|
356
369
|
const moreBtn = document.createElement("button");
|
|
357
370
|
moreBtn.className = "nav-repo-more";
|
|
358
371
|
moreBtn.type = "button";
|
|
@@ -366,7 +379,7 @@ export class Sidebar {
|
|
|
366
379
|
icon: isRaw ? DOC_ICON_HTML : DIRECTORY_ICON_HTML,
|
|
367
380
|
iconClass: isRaw ? "nav-ctx-doc" : "nav-ctx-dir",
|
|
368
381
|
label: isRaw ? "View: Docs" : "View: Raw",
|
|
369
|
-
action: () => this.
|
|
382
|
+
action: () => this.toggleRawMode(projectId),
|
|
370
383
|
},
|
|
371
384
|
{
|
|
372
385
|
icon: EDIT_ICON_HTML, iconClass: "nav-ctx-edit", label: "Wiki",
|
|
@@ -394,7 +407,7 @@ export class Sidebar {
|
|
|
394
407
|
]);
|
|
395
408
|
});
|
|
396
409
|
|
|
397
|
-
header.append(chevron, text, moreBtn);
|
|
410
|
+
header.append(chevron, text, docsBadge, moreBtn);
|
|
398
411
|
|
|
399
412
|
this.registerCollapsible(projectId, body, group, header as HTMLButtonElement, true);
|
|
400
413
|
header.addEventListener("click", () => this.toggleCollapsible(projectId));
|
|
@@ -406,67 +419,137 @@ export class Sidebar {
|
|
|
406
419
|
moreBtn.click();
|
|
407
420
|
});
|
|
408
421
|
|
|
409
|
-
const
|
|
410
|
-
|
|
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
|
+
}
|
|
411
434
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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;
|
|
421
446
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
+
});
|
|
429
497
|
} else {
|
|
430
|
-
|
|
431
|
-
|
|
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
|
+
});
|
|
432
507
|
}
|
|
433
|
-
}
|
|
434
508
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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);
|
|
509
|
+
body.appendChild(this.renderCanonicalNodes(project, node.children, depth + 1, isRaw));
|
|
510
|
+
group.append(toggle, body);
|
|
511
|
+
fragment.appendChild(group);
|
|
466
512
|
}
|
|
513
|
+
return fragment;
|
|
514
|
+
}
|
|
467
515
|
|
|
468
|
-
|
|
469
|
-
|
|
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;
|
|
470
553
|
}
|
|
471
554
|
|
|
472
555
|
// ---------- Browse mode (filesystem view) ----------
|
|
@@ -828,21 +911,45 @@ export class Sidebar {
|
|
|
828
911
|
e.stopPropagation();
|
|
829
912
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
830
913
|
el.classList.add("nav-drop-target");
|
|
914
|
+
this.scheduleDropExpand(el);
|
|
831
915
|
});
|
|
832
916
|
el.addEventListener("dragleave", (e) => {
|
|
833
917
|
if (el.contains(e.relatedTarget as Node | null)) return;
|
|
834
918
|
el.classList.remove("nav-drop-target");
|
|
919
|
+
this.clearDropExpand(el);
|
|
835
920
|
});
|
|
836
921
|
el.addEventListener("drop", (e) => {
|
|
837
922
|
const item = this.currentDragItem(e);
|
|
838
923
|
if (!item || !this.canDropItem(item, rootId, targetDir)) return;
|
|
839
924
|
e.preventDefault();
|
|
840
925
|
e.stopPropagation();
|
|
926
|
+
this.clearDropExpand(el);
|
|
841
927
|
this.clearDropTargets();
|
|
842
928
|
this.moveDraggedItem(item, targetDir);
|
|
843
929
|
});
|
|
844
930
|
}
|
|
845
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
|
+
|
|
846
953
|
private currentDragItem(e: DragEvent): SidebarDragItem | undefined {
|
|
847
954
|
if (this.draggedItem) return this.draggedItem;
|
|
848
955
|
const raw = e.dataTransfer?.getData("application/x-docs-nav-item");
|
|
@@ -851,7 +958,7 @@ export class Sidebar {
|
|
|
851
958
|
const parsed = JSON.parse(raw) as SidebarDragItem;
|
|
852
959
|
if (
|
|
853
960
|
parsed &&
|
|
854
|
-
(parsed.kind === "doc" || parsed.kind === "board" || parsed.kind === "dir") &&
|
|
961
|
+
(parsed.kind === "doc" || parsed.kind === "capability" || parsed.kind === "board" || parsed.kind === "dir") &&
|
|
855
962
|
typeof parsed.rootId === "string"
|
|
856
963
|
) {
|
|
857
964
|
return parsed;
|
|
@@ -866,6 +973,10 @@ export class Sidebar {
|
|
|
866
973
|
if (item.rootId !== rootId) return false;
|
|
867
974
|
const normalizedTarget = normalizeDirPath(targetDir);
|
|
868
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
|
+
}
|
|
869
980
|
if (item.kind === "board") return parentDir(item.boardId) !== normalizedTarget;
|
|
870
981
|
|
|
871
982
|
const source = normalizeDirPath(item.path);
|
|
@@ -877,7 +988,7 @@ export class Sidebar {
|
|
|
877
988
|
|
|
878
989
|
private moveDraggedItem(item: SidebarDragItem, targetDir: string): void {
|
|
879
990
|
const normalizedTarget = normalizeDirPath(targetDir);
|
|
880
|
-
if (item.kind === "doc") {
|
|
991
|
+
if (item.kind === "doc" || item.kind === "capability") {
|
|
881
992
|
this.callbacks.onMoveDoc(item.rootId, item.path, normalizedTarget);
|
|
882
993
|
} else if (item.kind === "board") {
|
|
883
994
|
this.callbacks.onMoveBoard(item.rootId, item.boardId, normalizedTarget);
|
|
@@ -887,6 +998,7 @@ export class Sidebar {
|
|
|
887
998
|
}
|
|
888
999
|
|
|
889
1000
|
private clearDropTargets(): void {
|
|
1001
|
+
this.clearDropExpand();
|
|
890
1002
|
this.element.querySelectorAll(".nav-drop-target").forEach((node) => {
|
|
891
1003
|
node.classList.remove("nav-drop-target");
|
|
892
1004
|
});
|
|
@@ -984,6 +1096,7 @@ export class Sidebar {
|
|
|
984
1096
|
defaultCollapsed: boolean
|
|
985
1097
|
): void {
|
|
986
1098
|
this.collapsibles.set(key, { body, group, btn });
|
|
1099
|
+
group.dataset.collapsibleKey = key;
|
|
987
1100
|
this.applyCollapsed(key, this.collapsedState.get(key) ?? defaultCollapsed);
|
|
988
1101
|
}
|
|
989
1102
|
|
|
@@ -1310,6 +1423,192 @@ function rootCapabilitiesHref(rootId: string): string {
|
|
|
1310
1423
|
return `#/root/${encodeURIComponent(rootId)}/capabilities`;
|
|
1311
1424
|
}
|
|
1312
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
|
+
|
|
1313
1612
|
function prettify(name: string): string {
|
|
1314
1613
|
const base = name.replace(/\.md$/, "").replace(/[-_]/g, " ");
|
|
1315
1614
|
return base.charAt(0).toUpperCase() + base.slice(1);
|
|
@@ -1336,7 +1635,7 @@ function parentDir(path: string): string {
|
|
|
1336
1635
|
}
|
|
1337
1636
|
|
|
1338
1637
|
function dragItemLabel(item: SidebarDragItem): string {
|
|
1339
|
-
if (item.kind === "doc") return item.path;
|
|
1638
|
+
if (item.kind === "doc" || item.kind === "capability") return item.path;
|
|
1340
1639
|
if (item.kind === "board") return item.boardId;
|
|
1341
1640
|
return item.path;
|
|
1342
1641
|
}
|