@worca/app 0.1.0 → 1.0.0-rc.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.
@@ -0,0 +1,151 @@
1
+ // diff-view.mjs — pure unified-diff parsing for the History Diff tab.
2
+ // DOM-free on purpose (pattern: log-line.mjs / log-filter.mjs) so node:test can
3
+ // exercise it without jsdom. Input is the persisted diff-patch.patch artifact;
4
+ // workspace runs concatenate per-member patches, each prefixed with a
5
+ // "# <projectKey>" comment line (orchestrator.mjs:3443).
6
+
7
+ export const MAX_FILE_SECTION_BYTES = 500_000;
8
+
9
+ // One cheap pass: split the patch into per-file sections. Lines outside any
10
+ // "diff --git" section are ignored, EXCEPT "# <key>" markers, which set the
11
+ // project context for the sections that follow (workspace patches).
12
+ export function splitPatchSections(text) {
13
+ const out = [];
14
+ const lines = String(text || '').split('\n');
15
+ let project = null;
16
+ let cur = null;
17
+ const flush = () => {
18
+ if (!cur) return;
19
+ cur.raw = cur.rawLines.join('\n');
20
+ delete cur.rawLines;
21
+ out.push(cur);
22
+ cur = null;
23
+ };
24
+ for (const line of lines) {
25
+ if (line.startsWith('diff --git ')) {
26
+ flush();
27
+ cur = { project, path: null, oldPath: null, header: line, rawLines: [line] };
28
+ continue;
29
+ }
30
+ // A marker either introduces the first section or terminates the current one.
31
+ // It can never collide with hunk content: adds start '+', deletes '-',
32
+ // context ' ', and "\ No newline" starts '\'.
33
+ if (/^# \S/.test(line)) {
34
+ flush();
35
+ project = line.slice(2).trim();
36
+ continue;
37
+ }
38
+ if (!cur) continue;
39
+
40
+ cur.rawLines.push(line);
41
+ if (cur.oldPath == null && line.startsWith('--- ')) {
42
+ const p = stripSide(line.slice(4));
43
+ if (p) cur.oldPath = p;
44
+ } else if (cur.path == null && line.startsWith('+++ ')) {
45
+ const p = stripSide(line.slice(4));
46
+ if (p) cur.path = p;
47
+ } else if (line.startsWith('rename from ')) {
48
+ cur.oldPath = line.slice('rename from '.length).trim();
49
+ } else if (line.startsWith('rename to ')) {
50
+ cur.path = line.slice('rename to '.length).trim();
51
+ }
52
+ }
53
+ flush();
54
+ for (const s of out) {
55
+ // Deleted files carry "+++ /dev/null" and new files "--- /dev/null"; mirror
56
+ // the surviving side. Binary and mode-only sections have NEITHER header, so
57
+ // fall back to the "diff --git a/X b/X" line — without this they keep
58
+ // path === null, drop out of patchIndex, and always render "(no textual
59
+ // diff for this file)" even when results lists them.
60
+ if (!s.path) s.path = s.oldPath || pathFromHeader(s.header);
61
+ if (!s.oldPath) s.oldPath = s.path;
62
+ delete s.header;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ // "diff --git a/src/x.js b/src/x.js" -> "src/x.js". Split at the LAST " b/" so a
68
+ // path that itself contains " b/" still resolves. null when the shape is unusual
69
+ // (e.g. a C-quoted path — see the DESCOPED note on sectionKey).
70
+ function pathFromHeader(header) {
71
+ const rest = String(header || '').slice('diff --git '.length);
72
+ const i = rest.lastIndexOf(' b/');
73
+ if (i <= 0) return null;
74
+ const newSide = rest.slice(i + 3).trim();
75
+ return newSide || null;
76
+ }
77
+
78
+ // "a/src/x.js" -> "src/x.js"; "/dev/null" and "" -> null. Git terminates names
79
+ // with a tab when they need it, so cut at the first tab.
80
+ function stripSide(s) {
81
+ const t = String(s || '').split('\t')[0].trim();
82
+ if (!t || t === '/dev/null') return null;
83
+ return t.replace(/^[ab]\//, '');
84
+ }
85
+
86
+ // Parse ONE section's hunks. Lazy per selected file — never called for the whole
87
+ // patch up front.
88
+ export function parseFileSection(raw) {
89
+ let text = String(raw || '');
90
+ let truncated = false;
91
+ if (text.length > MAX_FILE_SECTION_BYTES) {
92
+ const cut = text.lastIndexOf('\n', MAX_FILE_SECTION_BYTES);
93
+ if (cut > 0) {
94
+ text = text.slice(0, cut); // snapped to a line boundary
95
+ } else {
96
+ // One line longer than the whole cap: there is no newline to snap to, so
97
+ // cut mid-line and drop a trailing LONE HIGH SURROGATE (a '\n' can never
98
+ // sit inside a pair, which is why the snapped path needs no such guard).
99
+ text = text.slice(0, MAX_FILE_SECTION_BYTES);
100
+ const last = text.charCodeAt(text.length - 1);
101
+ if (last >= 0xd800 && last <= 0xdbff) text = text.slice(0, -1);
102
+ }
103
+ truncated = true;
104
+ }
105
+ const res = { binary: false, truncated, hunks: [] };
106
+ let hunk = null;
107
+ const rows = text.split('\n');
108
+ // A \n-terminated section yields a trailing '' from split (and the workspace
109
+ // '\n\n' member join leaves extras at each seam); without the pop each becomes
110
+ // a phantom empty context line. A GENUINE empty context line is the row ' '
111
+ // (one space), never '', so popping every trailing '' is safe.
112
+ while (rows.length && rows[rows.length - 1] === '') rows.pop();
113
+ for (const line of rows) {
114
+ if (line.startsWith('Binary files ') || line === 'GIT binary patch') {
115
+ res.binary = true;
116
+ continue;
117
+ }
118
+ if (line.startsWith('@@')) {
119
+ hunk = { header: line, lines: [] };
120
+ res.hunks.push(hunk);
121
+ continue;
122
+ }
123
+ 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 });
128
+ }
129
+ return res;
130
+ }
131
+
132
+ // DESCOPED, by design: git C-quotes unusual paths (non-ASCII / control chars /
133
+ // '"' / '\') as `--- "a/caf\303\251.md"`; those sections keep the quoted string
134
+ // as their path and won't match results paths, so the pane shows "(no textual
135
+ // diff for this file)" — graceful. A future unquoteGitPath() can lift this.
136
+ // The separator is NUL, not a space: a space would make
137
+ // sectionKey('a-11111111', 'x y.js') collide with sectionKey(null, 'a-11111111 x y.js').
138
+ // Unreachable in practice (a workspace patch always emits its marker before the
139
+ // first section, so no section in one has project === null) — but free to fix,
140
+ // and no test asserts the key's literal text.
141
+ export function sectionKey(project, path) {
142
+ return project ? `${project}\u0000${path}` : String(path || '');
143
+ }
144
+
145
+ export function patchIndex(sections) {
146
+ const m = new Map();
147
+ for (const s of Array.isArray(sections) ? sections : []) {
148
+ if (s && s.path) m.set(sectionKey(s.project, s.path), s);
149
+ }
150
+ return m;
151
+ }