@fresh-editor/fresh-editor 0.2.23 → 0.2.25
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/CHANGELOG.md +76 -0
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +497 -119
- package/plugins/audit_mode.ts +2568 -551
- package/plugins/config-schema.json +7 -1
- package/plugins/git_blame.ts +1 -6
- package/plugins/git_log.ts +616 -1025
- package/plugins/lib/fresh.d.ts +76 -4
- package/plugins/lib/git_history.ts +596 -0
- package/plugins/markdown_compose.ts +183 -7
- package/plugins/search_replace.i18n.json +42 -14
- package/plugins/search_replace.ts +146 -96
- package/plugins/vi_mode.ts +8 -3
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
/// <reference path="./fresh.d.ts" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared git history rendering helpers used by the git log plugin and the
|
|
5
|
+
* review-diff plugin's branch review mode.
|
|
6
|
+
*
|
|
7
|
+
* All rendering uses theme-keyed colours (`syntax.keyword`, `editor.fg`, etc.)
|
|
8
|
+
* so the panels stay consistent with the editor's current theme. The entry
|
|
9
|
+
* builders produce `TextPropertyEntry[]` lists whose sub-ranges are styled
|
|
10
|
+
* via `inlineOverlays` — no separate imperative overlay pass is required.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// Types
|
|
15
|
+
// =============================================================================
|
|
16
|
+
|
|
17
|
+
export interface GitCommit {
|
|
18
|
+
hash: string;
|
|
19
|
+
shortHash: string;
|
|
20
|
+
author: string;
|
|
21
|
+
authorEmail: string;
|
|
22
|
+
date: string;
|
|
23
|
+
relativeDate: string;
|
|
24
|
+
subject: string;
|
|
25
|
+
body: string;
|
|
26
|
+
refs: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FetchGitLogOptions {
|
|
30
|
+
/** Max commits to fetch (default: 200). */
|
|
31
|
+
maxCommits?: number;
|
|
32
|
+
/** Optional revision range (e.g. "main..HEAD"). Defaults to HEAD. */
|
|
33
|
+
range?: string;
|
|
34
|
+
/** Working directory. Defaults to `editor.getCwd()`. */
|
|
35
|
+
cwd?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BuildCommitLogEntriesOptions {
|
|
39
|
+
/** Index of the "selected" row — rendered with the selected-bg highlight. */
|
|
40
|
+
selectedIndex?: number;
|
|
41
|
+
/** Optional header string (e.g. "Commits:"). `null` omits the header row. */
|
|
42
|
+
header?: string | null;
|
|
43
|
+
/** Footer line (status hint). Omitted when null/undefined. */
|
|
44
|
+
footer?: string | null;
|
|
45
|
+
/** Target width for padding column alignment (default 0 = no padding). */
|
|
46
|
+
width?: number;
|
|
47
|
+
/** "log" property-type prefix for entries (default "log-commit"). */
|
|
48
|
+
propertyType?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// =============================================================================
|
|
52
|
+
// Theme keys
|
|
53
|
+
// =============================================================================
|
|
54
|
+
|
|
55
|
+
export const GIT_THEME = {
|
|
56
|
+
header: "syntax.keyword" as OverlayColorSpec,
|
|
57
|
+
separator: "ui.split_separator_fg" as OverlayColorSpec,
|
|
58
|
+
hash: "syntax.number" as OverlayColorSpec,
|
|
59
|
+
author: "syntax.function" as OverlayColorSpec,
|
|
60
|
+
date: "syntax.string" as OverlayColorSpec,
|
|
61
|
+
subject: "editor.fg" as OverlayColorSpec,
|
|
62
|
+
subjectMuted: "editor.line_number_fg" as OverlayColorSpec,
|
|
63
|
+
refBranch: "syntax.type" as OverlayColorSpec,
|
|
64
|
+
refRemote: "syntax.function" as OverlayColorSpec,
|
|
65
|
+
refTag: "syntax.number" as OverlayColorSpec,
|
|
66
|
+
refHead: "syntax.keyword" as OverlayColorSpec,
|
|
67
|
+
diffAdd: "editor.diff_add_bg" as OverlayColorSpec,
|
|
68
|
+
diffRemove: "editor.diff_remove_bg" as OverlayColorSpec,
|
|
69
|
+
diffAddFg: "diagnostic.info_fg" as OverlayColorSpec,
|
|
70
|
+
diffRemoveFg: "diagnostic.error_fg" as OverlayColorSpec,
|
|
71
|
+
diffHunk: "syntax.type" as OverlayColorSpec,
|
|
72
|
+
metaLabel: "editor.line_number_fg" as OverlayColorSpec,
|
|
73
|
+
selectionBg: "editor.selection_bg" as OverlayColorSpec,
|
|
74
|
+
sectionBg: "editor.current_line_bg" as OverlayColorSpec,
|
|
75
|
+
footer: "editor.line_number_fg" as OverlayColorSpec,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// =============================================================================
|
|
79
|
+
// Author initials helper — compact "(AL)" / "(JD)" style label used in the
|
|
80
|
+
// aligned log view. Falls back to the raw author when no initials can be
|
|
81
|
+
// extracted.
|
|
82
|
+
// =============================================================================
|
|
83
|
+
|
|
84
|
+
export function authorInitials(author: string): string {
|
|
85
|
+
const cleaned = author.replace(/[<>].*/g, "").trim();
|
|
86
|
+
const parts = cleaned.split(/\s+/).filter(p => p.length > 0);
|
|
87
|
+
if (parts.length === 0) return "??";
|
|
88
|
+
if (parts.length === 1) {
|
|
89
|
+
return parts[0].slice(0, 2).toUpperCase();
|
|
90
|
+
}
|
|
91
|
+
const first = parts[0][0] || "?";
|
|
92
|
+
const last = parts[parts.length - 1][0] || "?";
|
|
93
|
+
return (first + last).toUpperCase();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// =============================================================================
|
|
97
|
+
// Commit fetching
|
|
98
|
+
// =============================================================================
|
|
99
|
+
|
|
100
|
+
export async function fetchGitLog(
|
|
101
|
+
editor: EditorAPI,
|
|
102
|
+
opts: FetchGitLogOptions = {}
|
|
103
|
+
): Promise<GitCommit[]> {
|
|
104
|
+
const maxCommits = opts.maxCommits ?? 200;
|
|
105
|
+
const cwd = opts.cwd ?? editor.getCwd();
|
|
106
|
+
const format = "%H%x00%h%x00%an%x00%ae%x00%ai%x00%ar%x00%D%x00%s%x00%b%x1e";
|
|
107
|
+
const args = ["log", `--format=${format}`, `-n${maxCommits}`];
|
|
108
|
+
if (opts.range) args.push(opts.range);
|
|
109
|
+
|
|
110
|
+
const result = await editor.spawnProcess("git", args, cwd);
|
|
111
|
+
if (result.exit_code !== 0) return [];
|
|
112
|
+
|
|
113
|
+
const commits: GitCommit[] = [];
|
|
114
|
+
const records = result.stdout.split("\x1e");
|
|
115
|
+
for (const record of records) {
|
|
116
|
+
if (!record.trim()) continue;
|
|
117
|
+
const parts = record.split("\x00");
|
|
118
|
+
if (parts.length < 8) continue;
|
|
119
|
+
commits.push({
|
|
120
|
+
hash: parts[0].trim(),
|
|
121
|
+
shortHash: parts[1].trim(),
|
|
122
|
+
author: parts[2].trim(),
|
|
123
|
+
authorEmail: parts[3].trim(),
|
|
124
|
+
date: parts[4].trim(),
|
|
125
|
+
relativeDate: parts[5].trim(),
|
|
126
|
+
refs: parts[6].trim(),
|
|
127
|
+
subject: parts[7].trim(),
|
|
128
|
+
body: parts[8] ? parts[8].trim() : "",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return commits;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A single file's diff exceeding this line count is omitted from the
|
|
136
|
+
* rendered `git show` output. Generated files (lockfiles, bundled SVGs,
|
|
137
|
+
* minified JS) can produce megabyte-scale diffs that balloon the detail
|
|
138
|
+
* panel into hundreds of thousands of entries — slow to render and not
|
|
139
|
+
* useful to read. The stat header still lists the file so the user knows
|
|
140
|
+
* it changed; a footer tells them which ones were skipped.
|
|
141
|
+
*/
|
|
142
|
+
const MAX_DIFF_LINES_PER_FILE = 2000;
|
|
143
|
+
|
|
144
|
+
export async function fetchCommitShow(
|
|
145
|
+
editor: EditorAPI,
|
|
146
|
+
hash: string,
|
|
147
|
+
cwd?: string
|
|
148
|
+
): Promise<string> {
|
|
149
|
+
const workdir = cwd ?? editor.getCwd();
|
|
150
|
+
|
|
151
|
+
// numstat first — small output, lets us spot oversized files before
|
|
152
|
+
// pulling the full diff.
|
|
153
|
+
const numstatResult = await editor.spawnProcess(
|
|
154
|
+
"git",
|
|
155
|
+
["show", "--numstat", "--format=", hash],
|
|
156
|
+
workdir
|
|
157
|
+
);
|
|
158
|
+
const oversized: string[] = [];
|
|
159
|
+
if (numstatResult.exit_code === 0) {
|
|
160
|
+
for (const line of numstatResult.stdout.split("\n")) {
|
|
161
|
+
if (!line) continue;
|
|
162
|
+
// numstat format: "<added>\t<removed>\t<path>"; "-" for binary files.
|
|
163
|
+
const tab1 = line.indexOf("\t");
|
|
164
|
+
const tab2 = tab1 >= 0 ? line.indexOf("\t", tab1 + 1) : -1;
|
|
165
|
+
if (tab1 < 0 || tab2 < 0) continue;
|
|
166
|
+
const addedStr = line.slice(0, tab1);
|
|
167
|
+
const removedStr = line.slice(tab1 + 1, tab2);
|
|
168
|
+
const path = line.slice(tab2 + 1);
|
|
169
|
+
const added = addedStr === "-" ? 0 : parseInt(addedStr, 10) || 0;
|
|
170
|
+
const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10) || 0;
|
|
171
|
+
if (added + removed > MAX_DIFF_LINES_PER_FILE) {
|
|
172
|
+
oversized.push(path);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Stat + patch, excluding oversized paths. `:(exclude,top)` is rooted
|
|
178
|
+
// at the repo root so it matches regardless of git's cwd.
|
|
179
|
+
const showArgs = ["show", "--stat", "--patch", hash];
|
|
180
|
+
if (oversized.length > 0) {
|
|
181
|
+
showArgs.push("--", ".");
|
|
182
|
+
for (const p of oversized) showArgs.push(`:(exclude,top)${p}`);
|
|
183
|
+
}
|
|
184
|
+
const result = await editor.spawnProcess("git", showArgs, workdir);
|
|
185
|
+
if (result.exit_code !== 0) return result.stderr || "(no output)";
|
|
186
|
+
|
|
187
|
+
if (oversized.length === 0) return result.stdout;
|
|
188
|
+
|
|
189
|
+
const plural = oversized.length === 1 ? "" : "s";
|
|
190
|
+
let footer = `\n[${oversized.length} large file${plural} omitted from diff (>${MAX_DIFF_LINES_PER_FILE} lines changed):\n`;
|
|
191
|
+
for (const p of oversized) footer += ` ${p}\n`;
|
|
192
|
+
footer += `Run \`git show ${hash.slice(0, 12)} -- <path>\` to view.]\n`;
|
|
193
|
+
return result.stdout + footer;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// =============================================================================
|
|
197
|
+
// UTF-8 byte-length helper — the runtime's overlay offsets are in bytes, but
|
|
198
|
+
// JS strings are UTF-16. Colocated here so consumers don't have to redefine it.
|
|
199
|
+
// =============================================================================
|
|
200
|
+
|
|
201
|
+
export function byteLength(s: string): number {
|
|
202
|
+
let b = 0;
|
|
203
|
+
for (let i = 0; i < s.length; i++) {
|
|
204
|
+
const code = s.charCodeAt(i);
|
|
205
|
+
if (code <= 0x7f) b += 1;
|
|
206
|
+
else if (code <= 0x7ff) b += 2;
|
|
207
|
+
else if (code >= 0xd800 && code <= 0xdfff) {
|
|
208
|
+
b += 4;
|
|
209
|
+
i++;
|
|
210
|
+
} else b += 3;
|
|
211
|
+
}
|
|
212
|
+
return b;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// =============================================================================
|
|
216
|
+
// Commit log entry building
|
|
217
|
+
// =============================================================================
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Compute column widths for the aligned commit-log table. Returns widths for
|
|
221
|
+
* (hash, date, initials) columns. Subject and refs fill the remainder.
|
|
222
|
+
*/
|
|
223
|
+
function commitLogColumnWidths(commits: GitCommit[]): {
|
|
224
|
+
hashW: number;
|
|
225
|
+
dateW: number;
|
|
226
|
+
authorW: number;
|
|
227
|
+
} {
|
|
228
|
+
let hashW = 7;
|
|
229
|
+
let dateW = 10;
|
|
230
|
+
let authorW = 2;
|
|
231
|
+
for (const c of commits) {
|
|
232
|
+
if (c.shortHash.length > hashW) hashW = c.shortHash.length;
|
|
233
|
+
if (c.relativeDate.length > dateW) dateW = c.relativeDate.length;
|
|
234
|
+
const ini = authorInitials(c.author);
|
|
235
|
+
if (ini.length > authorW) authorW = ini.length;
|
|
236
|
+
}
|
|
237
|
+
// Clamp so a pathological author/date doesn't swallow the subject column.
|
|
238
|
+
if (hashW > 12) hashW = 12;
|
|
239
|
+
if (dateW > 16) dateW = 16;
|
|
240
|
+
if (authorW > 4) authorW = 4;
|
|
241
|
+
return { hashW, dateW, authorW };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Classify a git ref decoration tag so it can be coloured appropriately.
|
|
246
|
+
* Matches a single comma-separated entry from `%D` output, e.g.
|
|
247
|
+
* "HEAD -> main", "origin/main", "tag: v1.0".
|
|
248
|
+
*/
|
|
249
|
+
function refTokenColor(token: string): OverlayColorSpec {
|
|
250
|
+
const t = token.trim();
|
|
251
|
+
if (t.startsWith("tag:")) return GIT_THEME.refTag;
|
|
252
|
+
if (t.startsWith("HEAD")) return GIT_THEME.refHead;
|
|
253
|
+
if (t.includes("/")) return GIT_THEME.refRemote;
|
|
254
|
+
return GIT_THEME.refBranch;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Build a styled commit-log entry row with aligned columns. All styling uses
|
|
259
|
+
* `inlineOverlays` with theme keys — no imperative overlay pass needed.
|
|
260
|
+
*/
|
|
261
|
+
function buildCommitRowEntry(
|
|
262
|
+
commit: GitCommit,
|
|
263
|
+
index: number,
|
|
264
|
+
isSelected: boolean,
|
|
265
|
+
widths: { hashW: number; dateW: number; authorW: number },
|
|
266
|
+
propertyType: string
|
|
267
|
+
): TextPropertyEntry {
|
|
268
|
+
const shortHash = commit.shortHash.padEnd(widths.hashW);
|
|
269
|
+
const date = commit.relativeDate.padEnd(widths.dateW);
|
|
270
|
+
const ini = authorInitials(commit.author).padEnd(widths.authorW);
|
|
271
|
+
|
|
272
|
+
const prefix = " ";
|
|
273
|
+
let byte = byteLength(prefix);
|
|
274
|
+
let text = prefix;
|
|
275
|
+
const overlays: InlineOverlay[] = [];
|
|
276
|
+
|
|
277
|
+
// Hash column
|
|
278
|
+
overlays.push({
|
|
279
|
+
start: byte,
|
|
280
|
+
end: byte + byteLength(shortHash),
|
|
281
|
+
style: { fg: GIT_THEME.hash, bold: true },
|
|
282
|
+
});
|
|
283
|
+
text += shortHash;
|
|
284
|
+
byte += byteLength(shortHash);
|
|
285
|
+
|
|
286
|
+
// Space
|
|
287
|
+
text += " ";
|
|
288
|
+
byte += 2;
|
|
289
|
+
|
|
290
|
+
// Date column
|
|
291
|
+
overlays.push({
|
|
292
|
+
start: byte,
|
|
293
|
+
end: byte + byteLength(date),
|
|
294
|
+
style: { fg: GIT_THEME.date },
|
|
295
|
+
});
|
|
296
|
+
text += date;
|
|
297
|
+
byte += byteLength(date);
|
|
298
|
+
|
|
299
|
+
// Space
|
|
300
|
+
text += " ";
|
|
301
|
+
byte += 2;
|
|
302
|
+
|
|
303
|
+
// Author initials in parentheses
|
|
304
|
+
const authorOpen = "(";
|
|
305
|
+
const authorClose = ")";
|
|
306
|
+
text += authorOpen;
|
|
307
|
+
byte += byteLength(authorOpen);
|
|
308
|
+
overlays.push({
|
|
309
|
+
start: byte,
|
|
310
|
+
end: byte + byteLength(ini),
|
|
311
|
+
style: { fg: GIT_THEME.author, bold: true },
|
|
312
|
+
});
|
|
313
|
+
text += ini;
|
|
314
|
+
byte += byteLength(ini);
|
|
315
|
+
text += authorClose;
|
|
316
|
+
byte += byteLength(authorClose);
|
|
317
|
+
|
|
318
|
+
// Space
|
|
319
|
+
text += " ";
|
|
320
|
+
byte += 1;
|
|
321
|
+
|
|
322
|
+
// Subject
|
|
323
|
+
overlays.push({
|
|
324
|
+
start: byte,
|
|
325
|
+
end: byte + byteLength(commit.subject),
|
|
326
|
+
style: { fg: GIT_THEME.subject },
|
|
327
|
+
});
|
|
328
|
+
text += commit.subject;
|
|
329
|
+
byte += byteLength(commit.subject);
|
|
330
|
+
|
|
331
|
+
// Refs (if any) — tokenise and colour each separately. %D returns a
|
|
332
|
+
// comma-separated list like "HEAD -> main, origin/main, tag: v1".
|
|
333
|
+
if (commit.refs) {
|
|
334
|
+
text += " ";
|
|
335
|
+
byte += 2;
|
|
336
|
+
const tokens = commit.refs.split(",").map(t => t.trim()).filter(t => t.length > 0);
|
|
337
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
338
|
+
if (i > 0) {
|
|
339
|
+
text += " ";
|
|
340
|
+
byte += 1;
|
|
341
|
+
}
|
|
342
|
+
// "HEAD -> main" renders as two logical tokens inside one entry;
|
|
343
|
+
// treat the whole token as one coloured chunk for simplicity.
|
|
344
|
+
const t = tokens[i];
|
|
345
|
+
const bracket = `[${t}]`;
|
|
346
|
+
overlays.push({
|
|
347
|
+
start: byte,
|
|
348
|
+
end: byte + byteLength(bracket),
|
|
349
|
+
style: { fg: refTokenColor(t), bold: true },
|
|
350
|
+
});
|
|
351
|
+
text += bracket;
|
|
352
|
+
byte += byteLength(bracket);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const finalText = text + "\n";
|
|
357
|
+
|
|
358
|
+
const style: Partial<OverlayOptions> = isSelected
|
|
359
|
+
? { bg: GIT_THEME.selectionBg, extendToLineEnd: true, bold: true }
|
|
360
|
+
: {};
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
text: finalText,
|
|
364
|
+
properties: {
|
|
365
|
+
type: propertyType,
|
|
366
|
+
index,
|
|
367
|
+
hash: commit.hash,
|
|
368
|
+
shortHash: commit.shortHash,
|
|
369
|
+
author: commit.author,
|
|
370
|
+
date: commit.relativeDate,
|
|
371
|
+
subject: commit.subject,
|
|
372
|
+
refs: commit.refs,
|
|
373
|
+
},
|
|
374
|
+
style,
|
|
375
|
+
inlineOverlays: overlays,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function buildCommitLogEntries(
|
|
380
|
+
commits: GitCommit[],
|
|
381
|
+
opts: BuildCommitLogEntriesOptions = {}
|
|
382
|
+
): TextPropertyEntry[] {
|
|
383
|
+
const header = opts.header === undefined ? "Commits:" : opts.header;
|
|
384
|
+
const footer = opts.footer;
|
|
385
|
+
const selectedIndex = opts.selectedIndex ?? -1;
|
|
386
|
+
const propertyType = opts.propertyType ?? "log-commit";
|
|
387
|
+
|
|
388
|
+
const entries: TextPropertyEntry[] = [];
|
|
389
|
+
|
|
390
|
+
if (header !== null) {
|
|
391
|
+
entries.push({
|
|
392
|
+
text: header + "\n",
|
|
393
|
+
properties: { type: "log-header" },
|
|
394
|
+
style: { fg: GIT_THEME.header, bold: true, underline: true },
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (commits.length === 0) {
|
|
399
|
+
entries.push({
|
|
400
|
+
text: " (no commits)\n",
|
|
401
|
+
properties: { type: "log-empty" },
|
|
402
|
+
style: { fg: GIT_THEME.metaLabel, italic: true },
|
|
403
|
+
});
|
|
404
|
+
} else {
|
|
405
|
+
const widths = commitLogColumnWidths(commits);
|
|
406
|
+
for (let i = 0; i < commits.length; i++) {
|
|
407
|
+
entries.push(
|
|
408
|
+
buildCommitRowEntry(commits[i], i, i === selectedIndex, widths, propertyType)
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (footer) {
|
|
414
|
+
entries.push({
|
|
415
|
+
text: "\n",
|
|
416
|
+
properties: { type: "log-blank" },
|
|
417
|
+
});
|
|
418
|
+
entries.push({
|
|
419
|
+
text: footer + "\n",
|
|
420
|
+
properties: { type: "log-footer" },
|
|
421
|
+
style: { fg: GIT_THEME.footer, italic: true },
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return entries;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// =============================================================================
|
|
429
|
+
// Commit detail (git show) entry building
|
|
430
|
+
// =============================================================================
|
|
431
|
+
|
|
432
|
+
interface DetailBuildContext {
|
|
433
|
+
currentFile: string | null;
|
|
434
|
+
currentNewLine: number;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Style a single line from `git show --stat --patch` output as a styled
|
|
439
|
+
* TextPropertyEntry with inlineOverlays. Tracks file/line context for click
|
|
440
|
+
* navigation.
|
|
441
|
+
*/
|
|
442
|
+
function buildDetailLineEntry(
|
|
443
|
+
line: string,
|
|
444
|
+
ctx: DetailBuildContext
|
|
445
|
+
): TextPropertyEntry {
|
|
446
|
+
const props: Record<string, unknown> = { type: "detail-line" };
|
|
447
|
+
const overlays: InlineOverlay[] = [];
|
|
448
|
+
let lineStyle: Partial<OverlayOptions> = {};
|
|
449
|
+
|
|
450
|
+
// "diff --git a/... b/..."
|
|
451
|
+
const diffHeader = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
452
|
+
if (diffHeader) {
|
|
453
|
+
ctx.currentFile = diffHeader[2];
|
|
454
|
+
ctx.currentNewLine = 0;
|
|
455
|
+
props.type = "detail-diff-header";
|
|
456
|
+
props.file = ctx.currentFile;
|
|
457
|
+
lineStyle = { fg: GIT_THEME.header, bold: true };
|
|
458
|
+
} else if (line.startsWith("+++ b/")) {
|
|
459
|
+
ctx.currentFile = line.slice(6);
|
|
460
|
+
props.type = "detail-diff-header";
|
|
461
|
+
props.file = ctx.currentFile;
|
|
462
|
+
lineStyle = { fg: GIT_THEME.header, bold: true };
|
|
463
|
+
} else if (line.startsWith("+++ ") || line.startsWith("--- ") || line.startsWith("index ")) {
|
|
464
|
+
props.type = "detail-diff-header";
|
|
465
|
+
lineStyle = { fg: GIT_THEME.subjectMuted };
|
|
466
|
+
} else if (line.startsWith("@@")) {
|
|
467
|
+
const hunkMatch = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
468
|
+
if (hunkMatch) ctx.currentNewLine = parseInt(hunkMatch[1], 10);
|
|
469
|
+
props.type = "detail-hunk-header";
|
|
470
|
+
props.file = ctx.currentFile;
|
|
471
|
+
props.line = ctx.currentNewLine;
|
|
472
|
+
lineStyle = { fg: GIT_THEME.diffHunk, bold: true, extendToLineEnd: true };
|
|
473
|
+
} else if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
474
|
+
props.type = "detail-add";
|
|
475
|
+
props.file = ctx.currentFile;
|
|
476
|
+
props.line = ctx.currentNewLine;
|
|
477
|
+
ctx.currentNewLine++;
|
|
478
|
+
lineStyle = { fg: GIT_THEME.diffAddFg, bg: GIT_THEME.diffAdd, extendToLineEnd: true };
|
|
479
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
480
|
+
props.type = "detail-remove";
|
|
481
|
+
props.file = ctx.currentFile;
|
|
482
|
+
lineStyle = { fg: GIT_THEME.diffRemoveFg, bg: GIT_THEME.diffRemove, extendToLineEnd: true };
|
|
483
|
+
} else if (line.startsWith(" ") && ctx.currentFile && ctx.currentNewLine > 0) {
|
|
484
|
+
props.type = "detail-context";
|
|
485
|
+
props.file = ctx.currentFile;
|
|
486
|
+
props.line = ctx.currentNewLine;
|
|
487
|
+
ctx.currentNewLine++;
|
|
488
|
+
} else if (line.startsWith("commit ")) {
|
|
489
|
+
props.type = "detail-commit-line";
|
|
490
|
+
const hashMatch = line.match(/^commit ([a-f0-9]+)/);
|
|
491
|
+
if (hashMatch) {
|
|
492
|
+
props.hash = hashMatch[1];
|
|
493
|
+
// Colour just "commit" and the hash chunk separately.
|
|
494
|
+
const commitWord = "commit ";
|
|
495
|
+
overlays.push({
|
|
496
|
+
start: 0,
|
|
497
|
+
end: byteLength(commitWord),
|
|
498
|
+
style: { fg: GIT_THEME.metaLabel, bold: true },
|
|
499
|
+
});
|
|
500
|
+
overlays.push({
|
|
501
|
+
start: byteLength(commitWord),
|
|
502
|
+
end: byteLength(commitWord) + byteLength(hashMatch[1]),
|
|
503
|
+
style: { fg: GIT_THEME.hash, bold: true },
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
} else if (/^(Author|Date|Commit|Merge|AuthorDate|CommitDate):/.test(line)) {
|
|
507
|
+
const colonIdx = line.indexOf(":");
|
|
508
|
+
props.type = "detail-meta";
|
|
509
|
+
overlays.push({
|
|
510
|
+
start: 0,
|
|
511
|
+
end: byteLength(line.slice(0, colonIdx + 1)),
|
|
512
|
+
style: { fg: GIT_THEME.metaLabel, bold: true },
|
|
513
|
+
});
|
|
514
|
+
const fieldKey = line.slice(0, colonIdx).toLowerCase();
|
|
515
|
+
if (fieldKey === "author") {
|
|
516
|
+
overlays.push({
|
|
517
|
+
start: byteLength(line.slice(0, colonIdx + 1)),
|
|
518
|
+
end: byteLength(line),
|
|
519
|
+
style: { fg: GIT_THEME.author },
|
|
520
|
+
});
|
|
521
|
+
} else if (fieldKey.includes("date")) {
|
|
522
|
+
overlays.push({
|
|
523
|
+
start: byteLength(line.slice(0, colonIdx + 1)),
|
|
524
|
+
end: byteLength(line),
|
|
525
|
+
style: { fg: GIT_THEME.date },
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
text: line + "\n",
|
|
532
|
+
properties: props,
|
|
533
|
+
style: lineStyle,
|
|
534
|
+
inlineOverlays: overlays,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Build the entries for a commit detail view — a colourful replay of
|
|
540
|
+
* `git show --stat --patch`. The commit message body is already reflowed
|
|
541
|
+
* by `fetchCommitShow`; stat lines and diff lines pass through unchanged.
|
|
542
|
+
*/
|
|
543
|
+
export function buildCommitDetailEntries(
|
|
544
|
+
commit: GitCommit | null,
|
|
545
|
+
showOutput: string,
|
|
546
|
+
opts: { footer?: string | null } = {}
|
|
547
|
+
): TextPropertyEntry[] {
|
|
548
|
+
const entries: TextPropertyEntry[] = [];
|
|
549
|
+
|
|
550
|
+
if (commit) {
|
|
551
|
+
entries.push({
|
|
552
|
+
text: `${commit.shortHash} ${commit.subject}\n`,
|
|
553
|
+
properties: { type: "detail-title", hash: commit.hash },
|
|
554
|
+
style: { fg: GIT_THEME.header, bold: true, underline: true },
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const ctx: DetailBuildContext = { currentFile: null, currentNewLine: 0 };
|
|
559
|
+
for (const line of showOutput.split("\n")) {
|
|
560
|
+
entries.push(buildDetailLineEntry(line, ctx));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const footer = opts.footer;
|
|
564
|
+
if (footer) {
|
|
565
|
+
entries.push({
|
|
566
|
+
text: "\n",
|
|
567
|
+
properties: { type: "detail-blank" },
|
|
568
|
+
});
|
|
569
|
+
entries.push({
|
|
570
|
+
text: footer + "\n",
|
|
571
|
+
properties: { type: "detail-footer" },
|
|
572
|
+
style: { fg: GIT_THEME.footer, italic: true },
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return entries;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// =============================================================================
|
|
580
|
+
// Placeholder entries shown in the detail panel while no commit has been
|
|
581
|
+
// loaded yet (e.g. during initial render or when the log is empty).
|
|
582
|
+
// =============================================================================
|
|
583
|
+
|
|
584
|
+
export function buildDetailPlaceholderEntries(message: string): TextPropertyEntry[] {
|
|
585
|
+
return [
|
|
586
|
+
{
|
|
587
|
+
text: "\n",
|
|
588
|
+
properties: { type: "detail-blank" },
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
text: " " + message + "\n",
|
|
592
|
+
properties: { type: "detail-placeholder" },
|
|
593
|
+
style: { fg: GIT_THEME.metaLabel, italic: true },
|
|
594
|
+
},
|
|
595
|
+
];
|
|
596
|
+
}
|