@worca/app 1.0.0 → 1.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.
Files changed (138) hide show
  1. package/README.md +22 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +319 -45
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +189 -21
  32. package/src/core/ask/catalog.mjs +111 -0
  33. package/src/core/ask/comment-deps.mjs +55 -0
  34. package/src/core/ask/events.mjs +506 -0
  35. package/src/core/ask/follow.mjs +107 -0
  36. package/src/core/ask/git-allowlist.mjs +226 -0
  37. package/src/core/ask/limits.mjs +54 -0
  38. package/src/core/ask/mcp-stdio.mjs +135 -0
  39. package/src/core/ask/models.mjs +125 -0
  40. package/src/core/ask/prompt.mjs +261 -0
  41. package/src/core/ask/proposal.mjs +170 -0
  42. package/src/core/ask/redact.mjs +30 -0
  43. package/src/core/ask/spawn.mjs +153 -0
  44. package/src/core/ask/store.mjs +360 -0
  45. package/src/core/ask/tool-deps.mjs +63 -0
  46. package/src/core/ask/tools.mjs +848 -0
  47. package/src/core/ask/turn.mjs +416 -0
  48. package/src/core/ask/worktree-deps.mjs +27 -0
  49. package/src/core/ask/worktrees.mjs +285 -0
  50. package/src/core/chat/command-router.mjs +20 -3
  51. package/src/core/claude-runner.mjs +434 -57
  52. package/src/core/config.mjs +264 -41
  53. package/src/core/cost-budget.mjs +29 -2
  54. package/src/core/db.mjs +684 -47
  55. package/src/core/diff-anchor.mjs +213 -0
  56. package/src/core/diff-comments.mjs +273 -0
  57. package/src/core/engine-select.mjs +32 -0
  58. package/src/core/git-info.mjs +49 -10
  59. package/src/core/graph/builtin-workflows.mjs +51 -0
  60. package/src/core/graph/executor.mjs +894 -0
  61. package/src/core/graph/registry-ports.mjs +12 -0
  62. package/src/core/graph/scheduler.mjs +1065 -0
  63. package/src/core/graph/seed-templates.mjs +318 -0
  64. package/src/core/model-env.mjs +112 -8
  65. package/src/core/model-test.mjs +79 -0
  66. package/src/core/orchestrator.mjs +902 -4098
  67. package/src/core/overview-agent.mjs +15 -3
  68. package/src/core/phases.mjs +208 -537
  69. package/src/core/pipeline-delete.mjs +13 -2
  70. package/src/core/plugin-api.mjs +8 -3
  71. package/src/core/plugin-config.mjs +178 -28
  72. package/src/core/plugin-inventory.mjs +6 -2
  73. package/src/core/plugin-manifest.mjs +199 -11
  74. package/src/core/plugin-models.mjs +1 -0
  75. package/src/core/plugin-repo.mjs +16 -4
  76. package/src/core/plugin-shim-child.mjs +9 -3
  77. package/src/core/plugin-shim.mjs +77 -14
  78. package/src/core/plugin-store.mjs +236 -29
  79. package/src/core/plugin-workflows.mjs +90 -41
  80. package/src/core/preflight.mjs +135 -3
  81. package/src/core/projects.mjs +7 -5
  82. package/src/core/protocol.mjs +8 -35
  83. package/src/core/recoverable-error.mjs +1 -1
  84. package/src/core/run-harness.mjs +3585 -0
  85. package/src/core/run-manifest.mjs +5 -1
  86. package/src/core/settings.mjs +109 -13
  87. package/src/core/skills.mjs +10 -3
  88. package/src/core/source-bindings.mjs +175 -0
  89. package/src/core/sources.mjs +87 -25
  90. package/src/core/stats.mjs +25 -6
  91. package/src/core/title.mjs +51 -4
  92. package/src/core/workflows.mjs +358 -259
  93. package/src/core/workspace-scan.mjs +4 -0
  94. package/src/core/worktree.mjs +98 -7
  95. package/src/shared/graph/agent-meta.mjs +278 -0
  96. package/src/shared/graph/constants.mjs +105 -0
  97. package/src/shared/graph/geometry.mjs +157 -0
  98. package/src/shared/graph/layout.mjs +134 -0
  99. package/src/shared/graph/loops.mjs +130 -0
  100. package/src/shared/graph/manifest.mjs +257 -0
  101. package/src/shared/graph/ports.mjs +153 -0
  102. package/src/shared/graph/route.mjs +397 -0
  103. package/src/shared/graph/template.mjs +165 -0
  104. package/src/shared/graph/thumbnail.mjs +67 -0
  105. package/src/shared/graph/validate.mjs +491 -0
  106. package/src/shared/graph/verdict.mjs +41 -0
  107. package/ui/public/app.js +4008 -1670
  108. package/ui/public/ask-markdown.mjs +145 -0
  109. package/ui/public/ask-model.mjs +264 -0
  110. package/ui/public/ask-panel.mjs +1880 -0
  111. package/ui/public/chat-settings-view.mjs +6 -2
  112. package/ui/public/diff-view.mjs +66 -11
  113. package/ui/public/file-tree.mjs +305 -0
  114. package/ui/public/graph/composer.mjs +889 -0
  115. package/ui/public/graph/inspector.mjs +183 -0
  116. package/ui/public/graph/model.mjs +37 -0
  117. package/ui/public/graph/palette.mjs +144 -0
  118. package/ui/public/graph/run-decor.mjs +410 -0
  119. package/ui/public/graph/run-hosts.mjs +201 -0
  120. package/ui/public/graph/save-dialog.mjs +56 -0
  121. package/ui/public/graph/view.mjs +858 -0
  122. package/ui/public/guardrails-view.mjs +4 -2
  123. package/ui/public/hljs-loader.mjs +180 -0
  124. package/ui/public/index.html +269 -265
  125. package/ui/public/log-filter.mjs +22 -4
  126. package/ui/public/log-line.mjs +45 -19
  127. package/ui/public/models-view.mjs +171 -9
  128. package/ui/public/plugins-view.mjs +106 -4
  129. package/ui/public/source-pane.mjs +190 -8
  130. package/ui/public/stats-view.mjs +81 -1
  131. package/ui/public/style.css +1459 -229
  132. package/ui/public/syntax-highlight.mjs +270 -0
  133. package/ui/public/thinking-orb.mjs +110 -0
  134. package/ui/server.mjs +1667 -98
  135. package/src/core/channels.mjs +0 -302
  136. package/src/core/runners.mjs +0 -167
  137. package/src/core/workflow-validator.mjs +0 -185
  138. package/ui/public/composer-core.mjs +0 -211
@@ -32,11 +32,13 @@ export function renderChatSettings({ prefs, channels } = {}, { doc = globalThis.
32
32
  evBox.appendChild(h(doc, 'div', 'label-row', 'Notify on'));
33
33
  for (const [key, label, hint] of EVENTS) {
34
34
  const row = h(doc, 'label', 'chat-event-row');
35
- const cb = h(doc, 'input', 'chat-ev');
35
+ const cb = h(doc, 'input', 'sw-input chat-ev');
36
36
  cb.type = 'checkbox';
37
37
  cb.dataset.ev = key;
38
38
  cb.checked = p.notify?.[key] !== false;
39
+ cb.setAttribute('aria-label', label);
39
40
  row.appendChild(cb);
41
+ row.appendChild(h(doc, 'span', 'switch switch-sm'));
40
42
  row.appendChild(h(doc, 'span', '', label));
41
43
  if (hint) row.appendChild(h(doc, 'small', 'hint', hint));
42
44
  evBox.appendChild(row);
@@ -54,11 +56,13 @@ export function renderChatSettings({ prefs, channels } = {}, { doc = globalThis.
54
56
  const row = h(doc, 'div', 'chat-channel-row');
55
57
  row.dataset.channelKey = key;
56
58
  const toggle = h(doc, 'label', 'chat-channel-toggle');
57
- const cb = h(doc, 'input', 'chat-ch');
59
+ const cb = h(doc, 'input', 'sw-input chat-ch');
58
60
  cb.type = 'checkbox';
59
61
  cb.dataset.channelKey = key;
60
62
  cb.checked = p.channels?.[key]?.enabled !== false;
63
+ cb.setAttribute('aria-label', `Enable ${c.displayName || c.channelId}`);
61
64
  toggle.appendChild(cb);
65
+ toggle.appendChild(h(doc, 'span', 'switch switch-sm'));
62
66
  toggle.appendChild(h(doc, 'span', '', `${c.displayName || c.channelId} (${c.platform})`));
63
67
  row.appendChild(toggle);
64
68
  const stateCls = { connected: 'green', degraded: 'waiting', connecting: 'waiting', unconfigured: 'waiting' }[c.state] || 'red';
@@ -4,7 +4,32 @@
4
4
  // workspace runs concatenate per-member patches, each prefixed with a
5
5
  // "# <projectKey>" comment line (orchestrator.mjs:3443).
6
6
 
7
- export const MAX_FILE_SECTION_BYTES = 500_000;
7
+ // The ONLY cap here: it bounds parsing work and is the only point where rows
8
+ // are lost (and said so via `truncated`). Bounding the DOM is the renderer's
9
+ // job — app.js connects rows in windows — so a 6,000-line lockfile hunk parses
10
+ // in full and stays one click away instead of silently disappearing.
11
+ export const MAX_FILE_SECTION_CODE_UNITS = 500_000;
12
+
13
+ // `[^\n]` rather than `.`: git copies the function-context line verbatim, and
14
+ // `.` excludes CR, U+2028 and U+2029 — a header such as
15
+ // `@@ -5,7 +5,7 @@ function f(sep = "\u2028") {` would otherwise lose its range,
16
+ // blanking both gutters and disabling highlighting for the hunk.
17
+ const HUNK_RANGE_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: [^\n]*)?$/;
18
+
19
+ export function hunkRange(header) {
20
+ const raw = String(header || '');
21
+ const m = HUNK_RANGE_RE.exec(raw.endsWith('\r') ? raw.slice(0, -1) : raw);
22
+ if (!m) return null;
23
+ const values = [m[1], m[2] ?? '1', m[3], m[4] ?? '1'].map(Number);
24
+ if (!values.every((n) => Number.isSafeInteger(n) && n >= 0)) return null;
25
+ const [oldStart, oldCount, newStart, newCount] = values;
26
+ const safeRange = (start, count) => {
27
+ if (count === 0) return start >= 0;
28
+ return start >= 1 && start <= Number.MAX_SAFE_INTEGER - (count - 1);
29
+ };
30
+ if (!safeRange(oldStart, oldCount) || !safeRange(newStart, newCount)) return null;
31
+ return { oldStart, oldCount, newStart, newCount };
32
+ }
8
33
 
9
34
  // One cheap pass: split the patch into per-file sections. Lines outside any
10
35
  // "diff --git" section are ignored, EXCEPT "# <key>" markers, which set the
@@ -88,15 +113,15 @@ function stripSide(s) {
88
113
  export function parseFileSection(raw) {
89
114
  let text = String(raw || '');
90
115
  let truncated = false;
91
- if (text.length > MAX_FILE_SECTION_BYTES) {
92
- const cut = text.lastIndexOf('\n', MAX_FILE_SECTION_BYTES);
116
+ if (text.length > MAX_FILE_SECTION_CODE_UNITS) {
117
+ const cut = text.lastIndexOf('\n', MAX_FILE_SECTION_CODE_UNITS);
93
118
  if (cut > 0) {
94
119
  text = text.slice(0, cut); // snapped to a line boundary
95
120
  } else {
96
121
  // One line longer than the whole cap: there is no newline to snap to, so
97
122
  // cut mid-line and drop a trailing LONE HIGH SURROGATE (a '\n' can never
98
123
  // sit inside a pair, which is why the snapped path needs no such guard).
99
- text = text.slice(0, MAX_FILE_SECTION_BYTES);
124
+ text = text.slice(0, MAX_FILE_SECTION_CODE_UNITS);
100
125
  const last = text.charCodeAt(text.length - 1);
101
126
  if (last >= 0xd800 && last <= 0xdbff) text = text.slice(0, -1);
102
127
  }
@@ -104,6 +129,10 @@ export function parseFileSection(raw) {
104
129
  }
105
130
  const res = { binary: false, truncated, hunks: [] };
106
131
  let hunk = null;
132
+ let oldNo = null;
133
+ let newNo = null;
134
+ let oldRemaining = 0;
135
+ let newRemaining = 0;
107
136
  const rows = text.split('\n');
108
137
  // A \n-terminated section yields a trailing '' from split (and the workspace
109
138
  // '\n\n' member join leaves extras at each seam); without the pop each becomes
@@ -111,20 +140,46 @@ export function parseFileSection(raw) {
111
140
  // (one space), never '', so popping every trailing '' is safe.
112
141
  while (rows.length && rows[rows.length - 1] === '') rows.pop();
113
142
  for (const line of rows) {
114
- if (line.startsWith('Binary files ') || line === 'GIT binary patch') {
143
+ const structuralLine = line.endsWith('\r') ? line.slice(0, -1) : line;
144
+ if (structuralLine.startsWith('Binary files ')
145
+ || structuralLine === 'GIT binary patch') {
115
146
  res.binary = true;
116
147
  continue;
117
148
  }
118
- if (line.startsWith('@@')) {
119
- hunk = { header: line, lines: [] };
149
+ if (structuralLine.startsWith('@@')) {
150
+ const range = hunkRange(structuralLine);
151
+ hunk = {
152
+ header: structuralLine,
153
+ oldStart: range?.oldStart ?? null,
154
+ oldCount: range?.oldCount ?? null,
155
+ newStart: range?.newStart ?? null,
156
+ newCount: range?.newCount ?? null,
157
+ lines: [],
158
+ };
159
+ oldNo = range?.oldStart ?? null;
160
+ newNo = range?.newStart ?? null;
161
+ oldRemaining = range?.oldCount ?? 0;
162
+ newRemaining = range?.newCount ?? 0;
120
163
  res.hunks.push(hunk);
121
164
  continue;
122
165
  }
123
166
  if (!hunk) continue; // still in the header block
124
- if (line.startsWith('+')) hunk.lines.push({ kind: 'add', text: line.slice(1) });
125
- else if (line.startsWith('-')) hunk.lines.push({ kind: 'del', text: line.slice(1) });
126
- else if (line.startsWith('\\')) continue; // ""
127
- else hunk.lines.push({ kind: 'ctx', text: line.startsWith(' ') ? line.slice(1) : line });
167
+ if (line.startsWith('\\')) continue; // ""
168
+
169
+ const kind = line.startsWith('+') ? 'add' : line.startsWith('-') ? 'del' : 'ctx';
170
+ const source = kind === 'ctx'
171
+ ? (line.startsWith(' ') ? line.slice(1) : line)
172
+ : line.slice(1);
173
+ const numbered = { kind, text: source, oldNo: null, newNo: null };
174
+ if (kind !== 'add' && oldNo != null && oldRemaining > 0) {
175
+ numbered.oldNo = oldNo++;
176
+ oldRemaining -= 1;
177
+ }
178
+ if (kind !== 'del' && newNo != null && newRemaining > 0) {
179
+ numbered.newNo = newNo++;
180
+ newRemaining -= 1;
181
+ }
182
+ hunk.lines.push(numbered);
128
183
  }
129
184
  return res;
130
185
  }
@@ -0,0 +1,305 @@
1
+ // Deterministic, project-scoped model and renderer for changed-file navigation.
2
+
3
+ const SVG_NS = 'http://www.w3.org/2000/svg';
4
+
5
+ function svgNode(doc, tag, attrs = {}) {
6
+ const node = doc.createElementNS(SVG_NS, tag);
7
+ for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, String(value));
8
+ return node;
9
+ }
10
+
11
+ function treeIcon(doc, kind) {
12
+ const svg = svgNode(doc, 'svg', {
13
+ viewBox: '0 0 20 20', fill: 'none', stroke: 'currentColor',
14
+ 'stroke-width': '1.7', 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
15
+ 'aria-hidden': 'true', focusable: 'false',
16
+ });
17
+
18
+ if (kind === 'folder') {
19
+ svg.classList.add('hd-tree-folder-icon');
20
+ svg.appendChild(svgNode(doc, 'path', {
21
+ d: 'M2.5 5.25h5l1.7 2h8.3v7.5a1.5 1.5 0 0 1-1.5 1.5H4a1.5 1.5 0 0 1-1.5-1.5z',
22
+ }));
23
+ return svg;
24
+ }
25
+
26
+ svg.classList.add('hd-tree-file-icon');
27
+ svg.setAttribute('stroke-width', '1.45');
28
+ svg.append(
29
+ svgNode(doc, 'path', { d: 'M5 2.5h6.5l3.5 3.5v11.5H5z' }),
30
+ svgNode(doc, 'path', { d: 'M11.5 2.5V6H15' }),
31
+ );
32
+ if (kind === 'add') {
33
+ svg.appendChild(svgNode(doc, 'path', { d: 'M7.5 12h5M10 9.5v5' }));
34
+ } else if (kind === 'del') {
35
+ svg.appendChild(svgNode(doc, 'path', { d: 'M7.5 12h5' }));
36
+ } else {
37
+ svg.append(
38
+ svgNode(doc, 'path', { d: 'M7.5 10h5' }),
39
+ svgNode(doc, 'path', { d: 'M7.5 13h5' }),
40
+ );
41
+ }
42
+ return svg;
43
+ }
44
+
45
+ function chevronIcon(doc) {
46
+ const svg = svgNode(doc, 'svg', {
47
+ viewBox: '0 0 18 20', fill: 'none', stroke: 'currentColor',
48
+ 'stroke-width': '1.8', 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
49
+ 'aria-hidden': 'true', focusable: 'false',
50
+ });
51
+ svg.classList.add('hd-tree-chevron');
52
+ svg.appendChild(svgNode(doc, 'path', { d: 'M6.5 5.5 11 10l-4.5 4.5' }));
53
+ return svg;
54
+ }
55
+
56
+ const nodeKey = (project, type, fullPath) =>
57
+ JSON.stringify([project ?? null, type, fullPath]);
58
+
59
+ const cmpText = (a, b) => {
60
+ const af = a.toLowerCase();
61
+ const bf = b.toLowerCase();
62
+ return af < bf ? -1 : af > bf ? 1 : a < b ? -1 : a > b ? 1 : 0;
63
+ };
64
+
65
+ const sortNodes = (nodes) => nodes.sort((a, b) => {
66
+ if (a.type !== b.type) {
67
+ if (a.type === 'project') return -1;
68
+ if (b.type === 'project') return 1;
69
+ return a.type === 'dir' ? -1 : 1;
70
+ }
71
+ return cmpText(a.name, b.name);
72
+ });
73
+
74
+ function compactDir(node) {
75
+ let current = node;
76
+ const names = [current.name];
77
+ while (current.children.length === 1 && current.children[0].type === 'dir') {
78
+ current = current.children[0];
79
+ names.push(current.name);
80
+ }
81
+ return {
82
+ ...current,
83
+ name: names.join('/'),
84
+ children: current.children.map((child) =>
85
+ child.type === 'dir' ? compactDir(child) : child),
86
+ };
87
+ }
88
+
89
+ function makeChildren(rows, project) {
90
+ const root = new Map();
91
+ const seenFiles = new Set();
92
+ for (const entry of rows) {
93
+ const path = String(entry?.f?.path || '');
94
+ if (!path) continue;
95
+ const parts = path.split('/').filter(Boolean);
96
+ if (!parts.length) continue;
97
+ const fileKey = nodeKey(project, 'file', path);
98
+ if (seenFiles.has(fileKey)) continue;
99
+ seenFiles.add(fileKey);
100
+ let children = root;
101
+ let dirPath = '';
102
+ for (const segment of parts.slice(0, -1)) {
103
+ dirPath = dirPath ? `${dirPath}/${segment}` : segment;
104
+ const childKey = JSON.stringify(['dir', segment]);
105
+ let dir = children.get(childKey);
106
+ if (!dir) {
107
+ dir = {
108
+ type: 'dir', key: nodeKey(project, 'dir', dirPath), name: segment,
109
+ path: dirPath, project, childMap: new Map(),
110
+ };
111
+ children.set(childKey, dir);
112
+ }
113
+ children = dir.childMap;
114
+ }
115
+ const name = parts[parts.length - 1];
116
+ children.set(JSON.stringify(['file', name]), {
117
+ type: 'file', key: fileKey, name, path, project, entry,
118
+ });
119
+ }
120
+
121
+ const materialize = (children) => sortNodes([...children.values()].map((node) => {
122
+ if (node.type === 'file') return node;
123
+ const { childMap, ...dir } = node;
124
+ return compactDir({ ...dir, children: materialize(childMap) });
125
+ }));
126
+ return materialize(root);
127
+ }
128
+
129
+ export function buildFileTree(rows) {
130
+ const input = Array.isArray(rows) ? rows : [];
131
+ const projectRows = new Map();
132
+ const plainRows = [];
133
+ for (const entry of input) {
134
+ if (!entry?.f?.path) continue;
135
+ if (entry.project == null) {
136
+ plainRows.push(entry);
137
+ continue;
138
+ }
139
+ const project = String(entry.project);
140
+ if (!projectRows.has(project)) projectRows.set(project, []);
141
+ projectRows.get(project).push(entry);
142
+ }
143
+ const nodes = makeChildren(plainRows, null);
144
+ for (const project of [...projectRows.keys()].sort(cmpText)) {
145
+ nodes.push({
146
+ type: 'project',
147
+ key: nodeKey(project, 'project', project),
148
+ name: project,
149
+ project,
150
+ children: makeChildren(projectRows.get(project), project),
151
+ });
152
+ }
153
+ return sortNodes(nodes);
154
+ }
155
+
156
+ export function firstFile(nodes) {
157
+ for (const node of Array.isArray(nodes) ? nodes : []) {
158
+ if (node?.type === 'file') return node;
159
+ if (node?.children) {
160
+ const found = firstFile(node.children);
161
+ if (found) return found;
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+
167
+ export function fileStatus(entry) {
168
+ if (entry?.isNew || entry?.f?.status === 'A' || entry?.f?.status === 'C') return 'add';
169
+ if (entry?.f?.status === 'D') return 'del';
170
+ return 'mod';
171
+ }
172
+
173
+ let nextTreeInstance = 0;
174
+
175
+ export function renderFileTree(nodes, options = {}) {
176
+ // `collapsed` is a Set of DIRECTORY keys the caller owns across re-renders; this
177
+ // function reads it for the initial state and writes it on every toggle. The
178
+ // default is a throwaway Set, so a caller that does not pass one keeps the old
179
+ // always-expanded behaviour.
180
+ const { doc = document, onPick, counts = () => doc.createTextNode(''), initialKey,
181
+ collapsed = new Set() } = options;
182
+ const idPrefix = `hd-tree-${nextTreeInstance++}`;
183
+ let nextGroup = 0;
184
+ const nav = doc.createElement('nav');
185
+ nav.className = 'hd-tree';
186
+ nav.setAttribute('aria-label', 'Changed files');
187
+ const fileButtons = new Map();
188
+ let activeButton = null;
189
+
190
+ function activate(button) {
191
+ if (!button) return;
192
+ if (activeButton && activeButton !== button) {
193
+ activeButton.classList.remove('active');
194
+ activeButton.removeAttribute('aria-current');
195
+ }
196
+ activeButton = button;
197
+ button.classList.add('active');
198
+ button.setAttribute('aria-current', 'true');
199
+ }
200
+
201
+ function renderFile(node, depth) {
202
+ const button = doc.createElement('button');
203
+ button.type = 'button';
204
+ const state = fileStatus(node.entry);
205
+ button.className = `hd-diff-file hd-tree-file ${state}`;
206
+ if (state === 'add') button.classList.add('new');
207
+ if (state === 'del') button.classList.add('deleted');
208
+ button.dataset.fileKey = node.key;
209
+ button.dataset.project = node.project ?? '';
210
+ button.dataset.path = node.path;
211
+ button.style.setProperty('--tree-indent', `${10 + depth * 14}px`);
212
+
213
+ const f = node.entry?.f || {};
214
+ const status = ({ A: 'Added', C: 'Copied', D: 'Deleted', R: 'Renamed', M: 'Modified' })[f.status]
215
+ || (node.entry?.isNew ? 'Added' : 'Changed');
216
+ const scope = node.project ? ` in project ${node.project}` : '';
217
+ const identity = f.from
218
+ ? `from ${String(f.from)} to ${node.path}`
219
+ : `file ${node.path}`;
220
+ const amount = f.binary
221
+ ? 'binary file'
222
+ : `${Number(f.added) || 0} lines added, ${Number(f.removed) || 0} lines removed`;
223
+ const label = `${status} ${identity}${scope}, ${amount}`;
224
+ button.setAttribute('aria-label', label);
225
+ button.title = label;
226
+
227
+ const marker = doc.createElement('span');
228
+ marker.className = 'hd-tree-status';
229
+ marker.setAttribute('aria-hidden', 'true');
230
+ marker.appendChild(treeIcon(doc, state));
231
+ const path = doc.createElement('span');
232
+ path.className = 'hd-diff-path mono';
233
+ path.textContent = node.name;
234
+ const countNode = counts(node.entry);
235
+ button.append(marker, path);
236
+ if (countNode) button.appendChild(countNode);
237
+ button.addEventListener('click', () => {
238
+ activate(button);
239
+ onPick?.(node.entry, node.key);
240
+ });
241
+ fileButtons.set(node.key, button);
242
+ return button;
243
+ }
244
+
245
+ function renderDir(node, depth) {
246
+ const fragment = doc.createDocumentFragment();
247
+ const button = doc.createElement('button');
248
+ button.type = 'button';
249
+ button.className = 'hd-tree-dir';
250
+ button.style.setProperty('--tree-indent', `${10 + depth * 14}px`);
251
+ button.dataset.dirKey = node.key;
252
+ const open = !collapsed.has(node.key);
253
+ button.setAttribute('aria-expanded', String(open));
254
+ const fullLabel = node.project ? `${node.project}/${node.path}` : node.path;
255
+ button.setAttribute('aria-label', `${open ? 'Collapse' : 'Expand'} directory ${fullLabel}`);
256
+ const group = doc.createElement('div');
257
+ group.className = 'hd-tree-group';
258
+ group.id = `${idPrefix}-group-${nextGroup++}`;
259
+ group.hidden = !open;
260
+ button.setAttribute('aria-controls', group.id);
261
+ const chevron = chevronIcon(doc);
262
+ const folder = treeIcon(doc, 'folder');
263
+ const label = doc.createElement('span');
264
+ label.className = 'hd-tree-dir-label mono';
265
+ label.textContent = node.name;
266
+ button.append(chevron, folder, label);
267
+ renderNodes(node.children, group, depth + 1);
268
+ button.addEventListener('click', () => {
269
+ const expanded = button.getAttribute('aria-expanded') === 'true';
270
+ button.setAttribute('aria-expanded', String(!expanded));
271
+ group.hidden = expanded;
272
+ if (expanded) collapsed.add(node.key); else collapsed.delete(node.key);
273
+ button.setAttribute('aria-label',
274
+ `${expanded ? 'Expand' : 'Collapse'} directory ${fullLabel}`);
275
+ });
276
+ fragment.append(button, group);
277
+ return fragment;
278
+ }
279
+
280
+ function renderNodes(items, host, depth) {
281
+ for (const node of items || []) {
282
+ if (node.type === 'file') {
283
+ host.appendChild(renderFile(node, depth));
284
+ } else if (node.type === 'dir') {
285
+ host.appendChild(renderDir(node, depth));
286
+ } else if (node.type === 'project') {
287
+ const heading = doc.createElement('div');
288
+ heading.className = 'hd-tree-project';
289
+ heading.setAttribute('role', 'heading');
290
+ heading.setAttribute('aria-level', '3');
291
+ heading.textContent = node.name;
292
+ const group = doc.createElement('div');
293
+ group.className = 'hd-tree-group';
294
+ group.id = `${idPrefix}-group-${nextGroup++}`;
295
+ renderNodes(node.children, group, depth);
296
+ host.append(heading, group);
297
+ }
298
+ }
299
+ }
300
+
301
+ renderNodes(nodes, nav, 0);
302
+ const initial = fileButtons.get(initialKey);
303
+ if (initial) activate(initial);
304
+ return nav;
305
+ }