@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.
- package/README.md +22 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +319 -45
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +77 -14
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +269 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- 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';
|
package/ui/public/diff-view.mjs
CHANGED
|
@@ -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
|
-
|
|
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 >
|
|
92
|
-
const cut = text.lastIndexOf('\n',
|
|
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,
|
|
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
|
-
|
|
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 (
|
|
119
|
-
|
|
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('
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
}
|