@ruckc/truss-code 0.0.0-stage → 1.11.0
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/LICENSE +24 -0
- package/dist/CodeBlock.svelte +75 -0
- package/dist/CodeBlock.svelte.d.ts +17 -0
- package/dist/CodeEditor.svelte +227 -0
- package/dist/CodeEditor.svelte.d.ts +41 -0
- package/dist/DiffEditor.svelte +142 -0
- package/dist/DiffEditor.svelte.d.ts +32 -0
- package/dist/DiffFiles.svelte +196 -0
- package/dist/DiffFiles.svelte.d.ts +30 -0
- package/dist/DiffView.svelte +357 -0
- package/dist/DiffView.svelte.d.ts +56 -0
- package/dist/diff.d.ts +135 -0
- package/dist/diff.js +346 -0
- package/dist/editor-util.d.ts +46 -0
- package/dist/editor-util.js +52 -0
- package/dist/files.d.ts +44 -0
- package/dist/files.js +57 -0
- package/dist/highlight.d.ts +27 -0
- package/dist/highlight.js +84 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +12 -0
- package/dist/lang.d.ts +4 -0
- package/dist/lang.js +36 -0
- package/dist/monaco.d.ts +15 -0
- package/dist/monaco.js +40 -0
- package/dist/patch.d.ts +24 -0
- package/dist/patch.js +185 -0
- package/dist/theme.d.ts +80 -0
- package/dist/theme.js +217 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.js +1 -0
- package/dist/worker.d.ts +5 -0
- package/package.json +46 -5
- package/README.md +0 -3
package/dist/diff.d.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure diff logic, no dependencies: line diff (Myers), intra-line word/character diff, hunks in `git diff`'s shape,
|
|
3
|
+
* collapsed regions and split-view pairing. Nothing here touches the DOM, so all of it is unit-tested directly.
|
|
4
|
+
*/
|
|
5
|
+
/** One line of a document: its text without the line ending, and what the ending was. */
|
|
6
|
+
export interface Line {
|
|
7
|
+
text: string;
|
|
8
|
+
/** The line ended in CRLF. */
|
|
9
|
+
cr: boolean;
|
|
10
|
+
/** The last line of the document has no line ending (""). */
|
|
11
|
+
noEol: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Splits text on LF, CRLF or a lone CR. `''` is no lines; a final line ending does not start another line. */
|
|
14
|
+
export declare function splitDocument(text: string): Line[];
|
|
15
|
+
export interface DiffOptions {
|
|
16
|
+
/** Compare lines with all white space removed (and ignore CRLF versus LF and a missing final newline). */
|
|
17
|
+
ignoreWhitespace?: boolean;
|
|
18
|
+
ignoreCase?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Give up on an exact minimal diff after this many edits and show the unmatched middle as one replaced block.
|
|
21
|
+
* Memory grows with its square, so keep it modest. Default 2000.
|
|
22
|
+
*/
|
|
23
|
+
maxCost?: number;
|
|
24
|
+
}
|
|
25
|
+
export type Op = {
|
|
26
|
+
type: 'eq' | 'del' | 'add';
|
|
27
|
+
a: number;
|
|
28
|
+
b: number;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Myers' O(ND) shortest edit script between two sequences. Equal runs at both ends are trimmed first, so a large file with a few edits is
|
|
32
|
+
* cheap. `a` is the index in the old sequence (`eq`, `del`), `b` in the new one (`eq`, `add`). Past `maxCost` edits the unmatched middle
|
|
33
|
+
* becomes all deletions followed by all additions.
|
|
34
|
+
*/
|
|
35
|
+
export declare function diffSeq(a: string[], b: string[], maxCost?: number): Op[];
|
|
36
|
+
/** A word or character run inside a changed line. */
|
|
37
|
+
export interface IntraSeg {
|
|
38
|
+
text: string;
|
|
39
|
+
changed: boolean;
|
|
40
|
+
}
|
|
41
|
+
export interface DiffLine {
|
|
42
|
+
/** `gap` stands for unchanged lines a patch did not include; its size is `count`. */
|
|
43
|
+
type: 'ctx' | 'del' | 'add' | 'gap';
|
|
44
|
+
oldNo?: number;
|
|
45
|
+
newNo?: number;
|
|
46
|
+
text: string;
|
|
47
|
+
cr?: boolean;
|
|
48
|
+
noEol?: boolean;
|
|
49
|
+
count?: number;
|
|
50
|
+
/** Intra-line changes; only on changed lines that were paired. */
|
|
51
|
+
segs?: IntraSeg[];
|
|
52
|
+
}
|
|
53
|
+
/** The line-level diff of two texts. Unchanged lines show the new side's text and numbers on both sides. */
|
|
54
|
+
export declare function diffLines(oldText: string, newText: string, opts?: DiffOptions): DiffLine[];
|
|
55
|
+
/** Words (letters, digits, underscore), runs of white space, and single punctuation marks. Unicode-aware. */
|
|
56
|
+
export declare const wordTokens: (s: string) => string[];
|
|
57
|
+
/** One code point per token, so astral characters and combined emoji halves are never split in the middle of a surrogate pair. */
|
|
58
|
+
export declare const charTokens: (s: string) => string[];
|
|
59
|
+
/** Below this share of unchanged text a pair of lines is treated as different lines, not an edit of one. */
|
|
60
|
+
export declare const MIN_SIMILARITY = 0.3;
|
|
61
|
+
/** Joins neighbouring runs that share a `changed` flag and drops empty ones. */
|
|
62
|
+
export declare const mergeSegs: (segs: IntraSeg[]) => IntraSeg[];
|
|
63
|
+
/**
|
|
64
|
+
* Word- or character-level diff of one changed line against its counterpart. Returns `null` when the lines have too little in common
|
|
65
|
+
* (under `MIN_SIMILARITY`), where marking every fragment would only add noise.
|
|
66
|
+
*/
|
|
67
|
+
export declare function intraline(oldText: string, newText: string, mode?: 'word' | 'char', opts?: DiffOptions): {
|
|
68
|
+
old: IntraSeg[];
|
|
69
|
+
new: IntraSeg[];
|
|
70
|
+
} | null;
|
|
71
|
+
/** Adds `segs` to the changed lines of every replaced block, pairing the i-th removed line with the i-th added one. Mutates and returns `lines`. */
|
|
72
|
+
export declare function annotateIntraline(lines: DiffLine[], mode?: 'word' | 'char', opts?: DiffOptions): DiffLine[];
|
|
73
|
+
export interface Hunk {
|
|
74
|
+
/** First old line of the hunk; for a hunk with no old lines, the line before (as git prints it). */
|
|
75
|
+
oldStart: number;
|
|
76
|
+
oldLines: number;
|
|
77
|
+
newStart: number;
|
|
78
|
+
newLines: number;
|
|
79
|
+
/** Text after the second `@@`. */
|
|
80
|
+
section?: string;
|
|
81
|
+
lines: DiffLine[];
|
|
82
|
+
}
|
|
83
|
+
/** Groups changes with `context` unchanged lines around them. Changes whose unchanged gap is at most `2 * context` lines share a hunk, like git. */
|
|
84
|
+
export declare function toHunks(lines: DiffLine[], context?: number): Hunk[];
|
|
85
|
+
/** The `@@ -a,b +c,d @@` header of a hunk. */
|
|
86
|
+
export declare const hunkHeader: (h: Hunk) => string;
|
|
87
|
+
/** Hunks as unified-diff text: headers, prefixed lines, CRs kept and `` markers. Every line ends in `\n`. */
|
|
88
|
+
export declare function formatHunks(hunks: Hunk[]): string;
|
|
89
|
+
/** A unified patch (with `---`/`+++` headers) for two texts, in the same shape `git diff` prints. Empty when they are equal. */
|
|
90
|
+
export declare function unifiedPatch(oldText: string, newText: string, oldName?: string, newName?: string, context?: number, opts?: DiffOptions): string;
|
|
91
|
+
export declare const diffStats: (lines: DiffLine[]) => {
|
|
92
|
+
additions: number;
|
|
93
|
+
deletions: number;
|
|
94
|
+
};
|
|
95
|
+
/** Indexes of the first line of every block of changed lines. */
|
|
96
|
+
export declare const changeStarts: (lines: DiffLine[]) => number[];
|
|
97
|
+
/** Rows revealed so far from each end of a collapsed region, or `'all'`. Keyed by the region's `key`. */
|
|
98
|
+
export type Expansion = Record<number, {
|
|
99
|
+
top: number;
|
|
100
|
+
bottom: number;
|
|
101
|
+
} | 'all'>;
|
|
102
|
+
export type Item = {
|
|
103
|
+
type: 'row';
|
|
104
|
+
index: number;
|
|
105
|
+
}
|
|
106
|
+
/** `from`..`to` (exclusive) are the hidden line indexes; `key` identifies the region across expansions; `fixed` regions (a patch's gaps) cannot be expanded. */
|
|
107
|
+
| {
|
|
108
|
+
type: 'fold';
|
|
109
|
+
key: number;
|
|
110
|
+
from: number;
|
|
111
|
+
to: number;
|
|
112
|
+
count: number;
|
|
113
|
+
fixed: boolean;
|
|
114
|
+
};
|
|
115
|
+
/** Regions shorter than this are shown instead of collapsed. */
|
|
116
|
+
export declare const MIN_FOLD = 3;
|
|
117
|
+
/**
|
|
118
|
+
* Which lines to show: changed lines with `context` lines around them, and a fold for every longer run of unchanged lines.
|
|
119
|
+
* `expansion` reveals more of a fold from its top or bottom edge. With `context` of `Infinity` nothing is hidden.
|
|
120
|
+
*/
|
|
121
|
+
export declare function foldItems(lines: DiffLine[], context?: number, expansion?: Expansion): Item[];
|
|
122
|
+
/** Reveals up to `by` more lines of a fold from its top, its bottom, or all of it. Returns a new expansion. */
|
|
123
|
+
export declare function expandFold(expansion: Expansion, fold: {
|
|
124
|
+
key: number;
|
|
125
|
+
count: number;
|
|
126
|
+
}, how: 'top' | 'bottom' | 'all', by?: number): Expansion;
|
|
127
|
+
/** One row of the split view: indexes into the line list for the left (old) and right (new) cells; `null` is an aligned filler. */
|
|
128
|
+
export interface SplitRow {
|
|
129
|
+
left: number | null;
|
|
130
|
+
right: number | null;
|
|
131
|
+
}
|
|
132
|
+
/** Pairs a run of consecutive line indexes for side-by-side display: unchanged lines on both sides, the i-th removal beside the i-th addition. */
|
|
133
|
+
export declare function splitRows(lines: DiffLine[], indexes: number[]): SplitRow[];
|
|
134
|
+
/** The screen-reader summary for a change: "Change 2 of 5: 3 lines removed, 4 lines added". */
|
|
135
|
+
export declare function describeChange(lines: DiffLine[], starts: number[], n: number): string;
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure diff logic, no dependencies: line diff (Myers), intra-line word/character diff, hunks in `git diff`'s shape,
|
|
3
|
+
* collapsed regions and split-view pairing. Nothing here touches the DOM, so all of it is unit-tested directly.
|
|
4
|
+
*/
|
|
5
|
+
/** Splits text on LF, CRLF or a lone CR. `''` is no lines; a final line ending does not start another line. */
|
|
6
|
+
export function splitDocument(text) {
|
|
7
|
+
if (text === '')
|
|
8
|
+
return [];
|
|
9
|
+
const out = [];
|
|
10
|
+
const re = /\r\n|\n|\r/g;
|
|
11
|
+
let last = 0;
|
|
12
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
13
|
+
out.push({ text: text.slice(last, m.index), cr: m[0] === '\r\n', noEol: false });
|
|
14
|
+
last = m.index + m[0].length;
|
|
15
|
+
}
|
|
16
|
+
if (last < text.length)
|
|
17
|
+
out.push({ text: text.slice(last), cr: false, noEol: true });
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
const DEFAULT_MAX_COST = 2000;
|
|
21
|
+
function keyOf(l, o) {
|
|
22
|
+
let t = l.text;
|
|
23
|
+
if (o.ignoreCase)
|
|
24
|
+
t = t.toLowerCase();
|
|
25
|
+
if (o.ignoreWhitespace)
|
|
26
|
+
return t.replace(/\s+/g, '');
|
|
27
|
+
return t + (l.cr ? '\r' : '') + (l.noEol ? '\0' : '');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Myers' O(ND) shortest edit script between two sequences. Equal runs at both ends are trimmed first, so a large file with a few edits is
|
|
31
|
+
* cheap. `a` is the index in the old sequence (`eq`, `del`), `b` in the new one (`eq`, `add`). Past `maxCost` edits the unmatched middle
|
|
32
|
+
* becomes all deletions followed by all additions.
|
|
33
|
+
*/
|
|
34
|
+
export function diffSeq(a, b, maxCost = DEFAULT_MAX_COST) {
|
|
35
|
+
let lo = 0;
|
|
36
|
+
while (lo < a.length && lo < b.length && a[lo] === b[lo])
|
|
37
|
+
lo++;
|
|
38
|
+
let ea = a.length, eb = b.length;
|
|
39
|
+
while (ea > lo && eb > lo && a[ea - 1] === b[eb - 1]) {
|
|
40
|
+
ea--;
|
|
41
|
+
eb--;
|
|
42
|
+
}
|
|
43
|
+
const ops = [];
|
|
44
|
+
for (let i = 0; i < lo; i++)
|
|
45
|
+
ops.push({ type: 'eq', a: i, b: i });
|
|
46
|
+
for (const op of middle(a.slice(lo, ea), b.slice(lo, eb), maxCost))
|
|
47
|
+
ops.push({ type: op.type, a: op.a + lo, b: op.b + lo });
|
|
48
|
+
for (let i = 0; i < a.length - ea; i++)
|
|
49
|
+
ops.push({ type: 'eq', a: ea + i, b: eb + i });
|
|
50
|
+
return ops;
|
|
51
|
+
}
|
|
52
|
+
function middle(a, b, maxCost) {
|
|
53
|
+
const n = a.length, m = b.length;
|
|
54
|
+
if (!n || !m)
|
|
55
|
+
return [...a.map((_, i) => ({ type: 'del', a: i, b: 0 })), ...b.map((_, i) => ({ type: 'add', a: 0, b: i }))];
|
|
56
|
+
const max = Math.min(n + m, maxCost);
|
|
57
|
+
const off = max + 1;
|
|
58
|
+
const v = new Int32Array(2 * max + 3);
|
|
59
|
+
const snaps = [];
|
|
60
|
+
let found = -1;
|
|
61
|
+
for (let d = 0; d <= max && found < 0; d++) {
|
|
62
|
+
snaps.push(v.slice(off - d - 1, off + d + 2)); // the frontier of round d-1, which the backtrack reads
|
|
63
|
+
for (let k = -d; k <= d; k += 2) {
|
|
64
|
+
let x = k === -d || (k !== d && v[off + k - 1] < v[off + k + 1]) ? v[off + k + 1] : v[off + k - 1] + 1;
|
|
65
|
+
let y = x - k;
|
|
66
|
+
while (x < n && y < m && a[x] === b[y]) {
|
|
67
|
+
x++;
|
|
68
|
+
y++;
|
|
69
|
+
}
|
|
70
|
+
v[off + k] = x;
|
|
71
|
+
if (x >= n && y >= m) {
|
|
72
|
+
found = d;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (found < 0)
|
|
78
|
+
return [...a.map((_, i) => ({ type: 'del', a: i, b: 0 })), ...b.map((_, i) => ({ type: 'add', a: 0, b: i }))];
|
|
79
|
+
const ops = [];
|
|
80
|
+
let x = n, y = m;
|
|
81
|
+
for (let d = found; d > 0; d--) {
|
|
82
|
+
const s = snaps[d];
|
|
83
|
+
const at = (k) => s[k + d + 1];
|
|
84
|
+
const k = x - y;
|
|
85
|
+
const pk = k === -d || (k !== d && at(k - 1) < at(k + 1)) ? k + 1 : k - 1;
|
|
86
|
+
const px = at(pk), py = px - pk;
|
|
87
|
+
while (x > px && y > py) {
|
|
88
|
+
x--;
|
|
89
|
+
y--;
|
|
90
|
+
ops.push({ type: 'eq', a: x, b: y });
|
|
91
|
+
}
|
|
92
|
+
if (x === px)
|
|
93
|
+
ops.push({ type: 'add', a: 0, b: --y });
|
|
94
|
+
else
|
|
95
|
+
ops.push({ type: 'del', a: --x, b: 0 });
|
|
96
|
+
}
|
|
97
|
+
while (x > 0) {
|
|
98
|
+
x--;
|
|
99
|
+
y--;
|
|
100
|
+
ops.push({ type: 'eq', a: x, b: y });
|
|
101
|
+
} // round 0 is a pure diagonal, so x equals y here
|
|
102
|
+
ops.reverse();
|
|
103
|
+
// an `add` knows its new index only, a `del` its old one: fill in the other side from the running position
|
|
104
|
+
let ai = 0, bi = 0;
|
|
105
|
+
for (const op of ops) {
|
|
106
|
+
if (op.type === 'add')
|
|
107
|
+
op.a = ai;
|
|
108
|
+
else if (op.type === 'del')
|
|
109
|
+
op.b = bi;
|
|
110
|
+
if (op.type !== 'add')
|
|
111
|
+
ai++;
|
|
112
|
+
if (op.type !== 'del')
|
|
113
|
+
bi++;
|
|
114
|
+
}
|
|
115
|
+
return ops;
|
|
116
|
+
}
|
|
117
|
+
/** The line-level diff of two texts. Unchanged lines show the new side's text and numbers on both sides. */
|
|
118
|
+
export function diffLines(oldText, newText, opts = {}) {
|
|
119
|
+
const A = splitDocument(oldText), B = splitDocument(newText);
|
|
120
|
+
const ops = diffSeq(A.map((l) => keyOf(l, opts)), B.map((l) => keyOf(l, opts)), opts.maxCost);
|
|
121
|
+
return ops.map((op) => {
|
|
122
|
+
if (op.type === 'del')
|
|
123
|
+
return { type: 'del', oldNo: op.a + 1, text: A[op.a].text, cr: A[op.a].cr, noEol: A[op.a].noEol };
|
|
124
|
+
if (op.type === 'add')
|
|
125
|
+
return { type: 'add', newNo: op.b + 1, text: B[op.b].text, cr: B[op.b].cr, noEol: B[op.b].noEol };
|
|
126
|
+
return { type: 'ctx', oldNo: op.a + 1, newNo: op.b + 1, text: B[op.b].text, cr: B[op.b].cr, noEol: B[op.b].noEol };
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
// ───── intra-line
|
|
130
|
+
/** Words (letters, digits, underscore), runs of white space, and single punctuation marks. Unicode-aware. */
|
|
131
|
+
export const wordTokens = (s) => s.match(/[\p{L}\p{N}_]+|\s+|[^\p{L}\p{N}_\s]/gu) ?? [];
|
|
132
|
+
/** One code point per token, so astral characters and combined emoji halves are never split in the middle of a surrogate pair. */
|
|
133
|
+
export const charTokens = (s) => [...s];
|
|
134
|
+
const norm = (t, o) => (o.ignoreCase ? t.toLowerCase() : t);
|
|
135
|
+
/** Below this share of unchanged text a pair of lines is treated as different lines, not an edit of one. */
|
|
136
|
+
export const MIN_SIMILARITY = 0.3;
|
|
137
|
+
/** Joins neighbouring runs that share a `changed` flag and drops empty ones. */
|
|
138
|
+
export const mergeSegs = (segs) => {
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const s of segs) {
|
|
141
|
+
if (!s.text)
|
|
142
|
+
continue;
|
|
143
|
+
const last = out[out.length - 1];
|
|
144
|
+
if (last && last.changed === s.changed)
|
|
145
|
+
last.text += s.text;
|
|
146
|
+
else
|
|
147
|
+
out.push({ ...s });
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* Word- or character-level diff of one changed line against its counterpart. Returns `null` when the lines have too little in common
|
|
153
|
+
* (under `MIN_SIMILARITY`), where marking every fragment would only add noise.
|
|
154
|
+
*/
|
|
155
|
+
export function intraline(oldText, newText, mode = 'word', opts = {}) {
|
|
156
|
+
const tok = mode === 'char' ? charTokens : wordTokens;
|
|
157
|
+
const A = tok(oldText), B = tok(newText);
|
|
158
|
+
const ops = diffSeq(A.map((t) => norm(t, opts)), B.map((t) => norm(t, opts)), opts.maxCost);
|
|
159
|
+
const o = [], n = [];
|
|
160
|
+
let same = 0;
|
|
161
|
+
for (const op of ops) {
|
|
162
|
+
if (op.type === 'eq') {
|
|
163
|
+
same += B[op.b].length;
|
|
164
|
+
o.push({ text: A[op.a], changed: false });
|
|
165
|
+
n.push({ text: B[op.b], changed: false });
|
|
166
|
+
}
|
|
167
|
+
else if (op.type === 'del')
|
|
168
|
+
o.push({ text: A[op.a], changed: true });
|
|
169
|
+
else
|
|
170
|
+
n.push({ text: B[op.b], changed: true });
|
|
171
|
+
}
|
|
172
|
+
if (same / Math.max(oldText.length, newText.length, 1) < MIN_SIMILARITY)
|
|
173
|
+
return null;
|
|
174
|
+
return { old: mergeSegs(o), new: mergeSegs(n) };
|
|
175
|
+
}
|
|
176
|
+
/** Adds `segs` to the changed lines of every replaced block, pairing the i-th removed line with the i-th added one. Mutates and returns `lines`. */
|
|
177
|
+
export function annotateIntraline(lines, mode = 'word', opts = {}) {
|
|
178
|
+
for (let i = 0; i < lines.length;) {
|
|
179
|
+
if (lines[i].type !== 'del') {
|
|
180
|
+
i++;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
let j = i;
|
|
184
|
+
while (j < lines.length && lines[j].type === 'del')
|
|
185
|
+
j++;
|
|
186
|
+
let k = j;
|
|
187
|
+
while (k < lines.length && lines[k].type === 'add')
|
|
188
|
+
k++;
|
|
189
|
+
for (let p = 0; p < Math.min(j - i, k - j); p++) {
|
|
190
|
+
const r = intraline(lines[i + p].text, lines[j + p].text, mode, opts);
|
|
191
|
+
if (r) {
|
|
192
|
+
lines[i + p].segs = r.old;
|
|
193
|
+
lines[j + p].segs = r.new;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
i = k;
|
|
197
|
+
}
|
|
198
|
+
return lines;
|
|
199
|
+
}
|
|
200
|
+
/** Groups changes with `context` unchanged lines around them. Changes whose unchanged gap is at most `2 * context` lines share a hunk, like git. */
|
|
201
|
+
export function toHunks(lines, context = 3) {
|
|
202
|
+
const changed = lines.flatMap((l, i) => (l.type === 'ctx' || l.type === 'gap' ? [] : [i]));
|
|
203
|
+
if (!changed.length)
|
|
204
|
+
return [];
|
|
205
|
+
const spans = [];
|
|
206
|
+
for (const i of changed) {
|
|
207
|
+
const last = spans[spans.length - 1];
|
|
208
|
+
if (last && i - last[1] - 1 <= 2 * context)
|
|
209
|
+
last[1] = i;
|
|
210
|
+
else
|
|
211
|
+
spans.push([i, i]);
|
|
212
|
+
}
|
|
213
|
+
return spans.map(([s, e]) => {
|
|
214
|
+
const from = Math.max(0, s - context), to = Math.min(lines.length, e + context + 1);
|
|
215
|
+
const body = lines.slice(from, to);
|
|
216
|
+
const before = lines.slice(0, from);
|
|
217
|
+
const oldBefore = before.filter((l) => l.type !== 'add').length, newBefore = before.filter((l) => l.type !== 'del').length;
|
|
218
|
+
const oldLines = body.filter((l) => l.type !== 'add').length, newLines = body.filter((l) => l.type !== 'del').length;
|
|
219
|
+
return { oldStart: oldLines ? oldBefore + 1 : oldBefore, oldLines, newStart: newLines ? newBefore + 1 : newBefore, newLines, lines: body };
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const range = (start, n) => (n === 1 ? `${start}` : `${start},${n}`);
|
|
223
|
+
/** The `@@ -a,b +c,d @@` header of a hunk. */
|
|
224
|
+
export const hunkHeader = (h) => `@@ -${range(h.oldStart, h.oldLines)} +${range(h.newStart, h.newLines)} @@${h.section ? ' ' + h.section : ''}`;
|
|
225
|
+
/** Hunks as unified-diff text: headers, prefixed lines, CRs kept and `` markers. Every line ends in `\n`. */
|
|
226
|
+
export function formatHunks(hunks) {
|
|
227
|
+
let out = '';
|
|
228
|
+
for (const h of hunks) {
|
|
229
|
+
out += hunkHeader(h) + '\n';
|
|
230
|
+
for (const l of h.lines) {
|
|
231
|
+
if (l.type === 'gap')
|
|
232
|
+
continue;
|
|
233
|
+
out += (l.type === 'add' ? '+' : l.type === 'del' ? '-' : ' ') + l.text + (l.cr ? '\r' : '') + '\n';
|
|
234
|
+
if (l.noEol)
|
|
235
|
+
out += '\\n';
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
/** A unified patch (with `---`/`+++` headers) for two texts, in the same shape `git diff` prints. Empty when they are equal. */
|
|
241
|
+
export function unifiedPatch(oldText, newText, oldName = 'a', newName = 'b', context = 3, opts = {}) {
|
|
242
|
+
const hunks = toHunks(diffLines(oldText, newText, opts), context);
|
|
243
|
+
return hunks.length ? `--- ${oldName}\n+++ ${newName}\n${formatHunks(hunks)}` : '';
|
|
244
|
+
}
|
|
245
|
+
// ───── stats and navigation
|
|
246
|
+
export const diffStats = (lines) => ({ additions: lines.filter((l) => l.type === 'add').length, deletions: lines.filter((l) => l.type === 'del').length });
|
|
247
|
+
/** Indexes of the first line of every block of changed lines. */
|
|
248
|
+
export const changeStarts = (lines) => lines.flatMap((l, i) => ((l.type === 'add' || l.type === 'del') && !(i > 0 && (lines[i - 1].type === 'add' || lines[i - 1].type === 'del')) ? [i] : []));
|
|
249
|
+
/** Regions shorter than this are shown instead of collapsed. */
|
|
250
|
+
export const MIN_FOLD = 3;
|
|
251
|
+
/**
|
|
252
|
+
* Which lines to show: changed lines with `context` lines around them, and a fold for every longer run of unchanged lines.
|
|
253
|
+
* `expansion` reveals more of a fold from its top or bottom edge. With `context` of `Infinity` nothing is hidden.
|
|
254
|
+
*/
|
|
255
|
+
export function foldItems(lines, context = 3, expansion = {}) {
|
|
256
|
+
const items = [];
|
|
257
|
+
const n = lines.length;
|
|
258
|
+
const hasChange = lines.some((l) => l.type === 'add' || l.type === 'del');
|
|
259
|
+
for (let i = 0; i < n;) {
|
|
260
|
+
const l = lines[i];
|
|
261
|
+
if (l.type === 'gap') {
|
|
262
|
+
items.push({ type: 'fold', key: i, from: i, to: i + 1, count: l.count ?? 0, fixed: true });
|
|
263
|
+
i++;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (l.type !== 'ctx' || !hasChange) {
|
|
267
|
+
items.push({ type: 'row', index: i });
|
|
268
|
+
i++;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
let e = i;
|
|
272
|
+
while (e < n && lines[e].type === 'ctx')
|
|
273
|
+
e++;
|
|
274
|
+
// the run [i, e): keep `context` lines beside any change (not beside the start or end of the file)
|
|
275
|
+
const hs = i === 0 ? i : i + context, he = e === n ? e : e - context;
|
|
276
|
+
if (he - hs < MIN_FOLD) {
|
|
277
|
+
for (let r = i; r < e; r++)
|
|
278
|
+
items.push({ type: 'row', index: r });
|
|
279
|
+
i = e;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
for (let r = i; r < hs; r++)
|
|
283
|
+
items.push({ type: 'row', index: r });
|
|
284
|
+
const ex = expansion[hs];
|
|
285
|
+
const top = ex === 'all' ? he - hs : Math.min(ex?.top ?? 0, he - hs);
|
|
286
|
+
const bottom = ex === 'all' ? 0 : Math.min(ex?.bottom ?? 0, he - hs - top);
|
|
287
|
+
for (let r = hs; r < hs + top; r++)
|
|
288
|
+
items.push({ type: 'row', index: r });
|
|
289
|
+
if (hs + top < he - bottom)
|
|
290
|
+
items.push({ type: 'fold', key: hs, from: hs + top, to: he - bottom, count: he - bottom - hs - top, fixed: false });
|
|
291
|
+
for (let r = he - bottom; r < he; r++)
|
|
292
|
+
items.push({ type: 'row', index: r });
|
|
293
|
+
for (let r = he; r < e; r++)
|
|
294
|
+
items.push({ type: 'row', index: r });
|
|
295
|
+
i = e;
|
|
296
|
+
}
|
|
297
|
+
return items;
|
|
298
|
+
}
|
|
299
|
+
/** Reveals up to `by` more lines of a fold from its top, its bottom, or all of it. Returns a new expansion. */
|
|
300
|
+
export function expandFold(expansion, fold, how, by = 20) {
|
|
301
|
+
const cur = expansion[fold.key];
|
|
302
|
+
if (how === 'all' || cur === 'all')
|
|
303
|
+
return { ...expansion, [fold.key]: 'all' };
|
|
304
|
+
const c = cur ?? { top: 0, bottom: 0 };
|
|
305
|
+
return { ...expansion, [fold.key]: how === 'top' ? { top: c.top + Math.min(by, fold.count), bottom: c.bottom } : { top: c.top, bottom: c.bottom + Math.min(by, fold.count) } };
|
|
306
|
+
}
|
|
307
|
+
/** Pairs a run of consecutive line indexes for side-by-side display: unchanged lines on both sides, the i-th removal beside the i-th addition. */
|
|
308
|
+
export function splitRows(lines, indexes) {
|
|
309
|
+
const rows = [];
|
|
310
|
+
for (let p = 0; p < indexes.length;) {
|
|
311
|
+
const i = indexes[p];
|
|
312
|
+
const t = lines[i].type;
|
|
313
|
+
if (t === 'ctx') {
|
|
314
|
+
rows.push({ left: i, right: i });
|
|
315
|
+
p++;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (t === 'add') {
|
|
319
|
+
rows.push({ left: null, right: i });
|
|
320
|
+
p++;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const dels = [], adds = [];
|
|
324
|
+
while (p < indexes.length && lines[indexes[p]].type === 'del')
|
|
325
|
+
dels.push(indexes[p++]);
|
|
326
|
+
while (p < indexes.length && lines[indexes[p]].type === 'add')
|
|
327
|
+
adds.push(indexes[p++]);
|
|
328
|
+
for (let r = 0; r < Math.max(dels.length, adds.length); r++)
|
|
329
|
+
rows.push({ left: dels[r] ?? null, right: adds[r] ?? null });
|
|
330
|
+
}
|
|
331
|
+
return rows;
|
|
332
|
+
}
|
|
333
|
+
/** The screen-reader summary for a change: "Change 2 of 5: 3 lines removed, 4 lines added". */
|
|
334
|
+
export function describeChange(lines, starts, n) {
|
|
335
|
+
const s = starts[n];
|
|
336
|
+
let e = s, del = 0, add = 0;
|
|
337
|
+
for (; e < lines.length && (lines[e].type === 'add' || lines[e].type === 'del'); e++) {
|
|
338
|
+
if (lines[e].type === 'del')
|
|
339
|
+
del++;
|
|
340
|
+
else
|
|
341
|
+
add++;
|
|
342
|
+
}
|
|
343
|
+
const part = (c, w) => `${c} ${c === 1 ? 'line' : 'lines'} ${w}`;
|
|
344
|
+
const parts = [del ? part(del, 'removed') : '', add ? part(add, 'added') : ''].filter(Boolean);
|
|
345
|
+
return `Change ${n + 1} of ${starts.length}: ${parts.join(', ')}`;
|
|
346
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { CodeMarker, CompletionItem, CompletionKind, MarkerSeverity } from './types.js';
|
|
2
|
+
/** Monaco's `MarkerSeverity` enum (numeric), passed in so these helpers stay independent of the Monaco bundle. */
|
|
3
|
+
export interface SeverityEnum {
|
|
4
|
+
Error: number;
|
|
5
|
+
Warning: number;
|
|
6
|
+
Info: number;
|
|
7
|
+
Hint: number;
|
|
8
|
+
}
|
|
9
|
+
export declare const markerSeverity: (s: MarkerSeverity, e: SeverityEnum) => number;
|
|
10
|
+
/** Column just past the word that starts at `col` (1-based) in `lineText`; one column when there is no word there. */
|
|
11
|
+
export declare function wordEnd(lineText: string, col: number): number;
|
|
12
|
+
/** A `CodeMarker` as Monaco's `IMarkerData`. */
|
|
13
|
+
export declare function toMonacoMarker(m: CodeMarker, sev: SeverityEnum, lineText?: string): {
|
|
14
|
+
code?: string | undefined;
|
|
15
|
+
source?: string | undefined;
|
|
16
|
+
severity: number;
|
|
17
|
+
message: string;
|
|
18
|
+
startLineNumber: number;
|
|
19
|
+
startColumn: number;
|
|
20
|
+
endLineNumber: number;
|
|
21
|
+
endColumn: number;
|
|
22
|
+
};
|
|
23
|
+
/** Monaco's `CompletionItemKind` entries used here. */
|
|
24
|
+
export type KindEnum = Record<'Text' | 'Keyword' | 'Function' | 'Method' | 'Variable' | 'Field' | 'Class' | 'Interface' | 'Module' | 'Property' | 'Constant' | 'Snippet' | 'Value', number>;
|
|
25
|
+
export declare function completionKind(k: CompletionKind | undefined, e: KindEnum): number;
|
|
26
|
+
/** A `CompletionItem` as Monaco's suggestion for the word range `range`. `snippetRule` is `CompletionItemInsertTextRule.InsertAsSnippet`. */
|
|
27
|
+
export declare function toMonacoCompletion(item: CompletionItem, kinds: KindEnum, range: {
|
|
28
|
+
startLineNumber: number;
|
|
29
|
+
endLineNumber: number;
|
|
30
|
+
startColumn: number;
|
|
31
|
+
endColumn: number;
|
|
32
|
+
}, snippetRule: number): {
|
|
33
|
+
insertTextRules?: number | undefined;
|
|
34
|
+
sortText?: string | undefined;
|
|
35
|
+
documentation?: string | undefined;
|
|
36
|
+
detail?: string | undefined;
|
|
37
|
+
label: string;
|
|
38
|
+
kind: number;
|
|
39
|
+
insertText: string;
|
|
40
|
+
range: {
|
|
41
|
+
startLineNumber: number;
|
|
42
|
+
endLineNumber: number;
|
|
43
|
+
startColumn: number;
|
|
44
|
+
endColumn: number;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const markerSeverity = (s, e) => (s === 'error' ? e.Error : s === 'warning' ? e.Warning : s === 'hint' ? e.Hint : e.Info);
|
|
2
|
+
/** Column just past the word that starts at `col` (1-based) in `lineText`; one column when there is no word there. */
|
|
3
|
+
export function wordEnd(lineText, col) {
|
|
4
|
+
const m = /^[\w$]+/.exec(lineText.slice(col - 1));
|
|
5
|
+
return col + (m ? m[0].length : 1);
|
|
6
|
+
}
|
|
7
|
+
/** A `CodeMarker` as Monaco's `IMarkerData`. */
|
|
8
|
+
export function toMonacoMarker(m, sev, lineText = '') {
|
|
9
|
+
const endLineNumber = m.endLine ?? m.line;
|
|
10
|
+
return {
|
|
11
|
+
severity: markerSeverity(m.severity, sev),
|
|
12
|
+
message: m.message,
|
|
13
|
+
startLineNumber: m.line,
|
|
14
|
+
startColumn: m.col,
|
|
15
|
+
endLineNumber,
|
|
16
|
+
endColumn: m.endCol ?? (endLineNumber === m.line ? wordEnd(lineText, m.col) : 1),
|
|
17
|
+
...(m.source ? { source: m.source } : {}),
|
|
18
|
+
...(m.code ? { code: m.code } : {})
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function completionKind(k, e) {
|
|
22
|
+
switch (k) {
|
|
23
|
+
case 'keyword': return e.Keyword;
|
|
24
|
+
case 'function': return e.Function;
|
|
25
|
+
case 'method': return e.Method;
|
|
26
|
+
case 'variable': return e.Variable;
|
|
27
|
+
case 'field':
|
|
28
|
+
case 'column': return e.Field;
|
|
29
|
+
case 'class':
|
|
30
|
+
case 'table': return e.Class;
|
|
31
|
+
case 'interface': return e.Interface;
|
|
32
|
+
case 'module': return e.Module;
|
|
33
|
+
case 'property': return e.Property;
|
|
34
|
+
case 'constant': return e.Constant;
|
|
35
|
+
case 'snippet': return e.Snippet;
|
|
36
|
+
case 'value': return e.Value;
|
|
37
|
+
default: return e.Text;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** A `CompletionItem` as Monaco's suggestion for the word range `range`. `snippetRule` is `CompletionItemInsertTextRule.InsertAsSnippet`. */
|
|
41
|
+
export function toMonacoCompletion(item, kinds, range, snippetRule) {
|
|
42
|
+
return {
|
|
43
|
+
label: item.label,
|
|
44
|
+
kind: completionKind(item.kind, kinds),
|
|
45
|
+
insertText: item.insertText ?? item.label,
|
|
46
|
+
range,
|
|
47
|
+
...(item.detail ? { detail: item.detail } : {}),
|
|
48
|
+
...(item.documentation ? { documentation: item.documentation } : {}),
|
|
49
|
+
...(item.sortText ? { sortText: item.sortText } : {}),
|
|
50
|
+
...(item.snippet ? { insertTextRules: snippetRule } : {})
|
|
51
|
+
};
|
|
52
|
+
}
|
package/dist/files.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { TreeNode } from '@ruckc/truss-ui';
|
|
2
|
+
import { type DiffOptions } from './diff.js';
|
|
3
|
+
import { type PatchFile } from './patch.js';
|
|
4
|
+
export type DiffStatus = 'added' | 'modified' | 'deleted' | 'renamed';
|
|
5
|
+
/** One file handed to `DiffFiles`: its two texts, or a unified patch for it. */
|
|
6
|
+
export interface DiffFileInput {
|
|
7
|
+
path: string;
|
|
8
|
+
/** The previous path of a renamed file. */
|
|
9
|
+
oldPath?: string;
|
|
10
|
+
/** Defaults to `renamed` when `oldPath` differs from `path`, else `modified`. */
|
|
11
|
+
status?: DiffStatus;
|
|
12
|
+
old?: string;
|
|
13
|
+
new?: string;
|
|
14
|
+
/** A unified patch for this file (instead of `old`/`new`). */
|
|
15
|
+
patch?: string;
|
|
16
|
+
binary?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** A file ready to show: where it is, what happened to it and its +/- counts. */
|
|
19
|
+
export interface FileEntry {
|
|
20
|
+
path: string;
|
|
21
|
+
oldPath?: string;
|
|
22
|
+
status: DiffStatus;
|
|
23
|
+
binary: boolean;
|
|
24
|
+
additions: number;
|
|
25
|
+
deletions: number;
|
|
26
|
+
/** Set for patch input; the diff view draws from it instead of two texts. */
|
|
27
|
+
file?: PatchFile;
|
|
28
|
+
old?: string;
|
|
29
|
+
new?: string;
|
|
30
|
+
}
|
|
31
|
+
/** The files of a `git diff` as entries. */
|
|
32
|
+
export declare const entriesFromPatch: (patch: string) => FileEntry[];
|
|
33
|
+
/** An input file as an entry, counting its changes with the same options the diff view will use. */
|
|
34
|
+
export declare function entryFromInput(f: DiffFileInput, opts?: DiffOptions): FileEntry;
|
|
35
|
+
export declare const statusLetter: (s: DiffStatus) => string;
|
|
36
|
+
export declare function totals(entries: FileEntry[]): {
|
|
37
|
+
files: number;
|
|
38
|
+
additions: number;
|
|
39
|
+
deletions: number;
|
|
40
|
+
};
|
|
41
|
+
/** A folder tree of the changed files: folders first, then files, each sorted by name. Folder ids are `dir:` plus their path; file ids are the file's path. */
|
|
42
|
+
export declare function buildTree(entries: FileEntry[]): TreeNode[];
|
|
43
|
+
/** Ids of every folder in a tree, so the whole tree can start expanded. */
|
|
44
|
+
export declare const folderIds: (nodes: TreeNode[]) => string[];
|