@involvex/fresh-editor 0.1.76 → 0.1.78
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/bin/CHANGELOG.md +1017 -0
- package/bin/LICENSE +117 -0
- package/bin/README.md +248 -0
- package/bin/fresh.exe +0 -0
- package/bin/plugins/README.md +71 -0
- package/bin/plugins/audit_mode.i18n.json +821 -0
- package/bin/plugins/audit_mode.ts +1810 -0
- package/bin/plugins/buffer_modified.i18n.json +67 -0
- package/bin/plugins/buffer_modified.ts +281 -0
- package/bin/plugins/calculator.i18n.json +93 -0
- package/bin/plugins/calculator.ts +770 -0
- package/bin/plugins/clangd-lsp.ts +168 -0
- package/bin/plugins/clangd_support.i18n.json +223 -0
- package/bin/plugins/clangd_support.md +20 -0
- package/bin/plugins/clangd_support.ts +325 -0
- package/bin/plugins/color_highlighter.i18n.json +145 -0
- package/bin/plugins/color_highlighter.ts +304 -0
- package/bin/plugins/config-schema.json +768 -0
- package/bin/plugins/csharp-lsp.ts +147 -0
- package/bin/plugins/csharp_support.i18n.json +80 -0
- package/bin/plugins/csharp_support.ts +170 -0
- package/bin/plugins/css-lsp.ts +143 -0
- package/bin/plugins/diagnostics_panel.i18n.json +236 -0
- package/bin/plugins/diagnostics_panel.ts +642 -0
- package/bin/plugins/examples/README.md +85 -0
- package/bin/plugins/examples/async_demo.ts +165 -0
- package/bin/plugins/examples/bookmarks.ts +329 -0
- package/bin/plugins/examples/buffer_query_demo.ts +110 -0
- package/bin/plugins/examples/git_grep.ts +262 -0
- package/bin/plugins/examples/hello_world.ts +93 -0
- package/bin/plugins/examples/virtual_buffer_demo.ts +116 -0
- package/bin/plugins/find_references.i18n.json +275 -0
- package/bin/plugins/find_references.ts +359 -0
- package/bin/plugins/git_blame.i18n.json +496 -0
- package/bin/plugins/git_blame.ts +707 -0
- package/bin/plugins/git_find_file.i18n.json +314 -0
- package/bin/plugins/git_find_file.ts +300 -0
- package/bin/plugins/git_grep.i18n.json +171 -0
- package/bin/plugins/git_grep.ts +191 -0
- package/bin/plugins/git_gutter.i18n.json +93 -0
- package/bin/plugins/git_gutter.ts +477 -0
- package/bin/plugins/git_log.i18n.json +481 -0
- package/bin/plugins/git_log.ts +1285 -0
- package/bin/plugins/go-lsp.ts +143 -0
- package/bin/plugins/html-lsp.ts +145 -0
- package/bin/plugins/json-lsp.ts +145 -0
- package/bin/plugins/lib/fresh.d.ts +1321 -0
- package/bin/plugins/lib/index.ts +24 -0
- package/bin/plugins/lib/navigation-controller.ts +214 -0
- package/bin/plugins/lib/panel-manager.ts +220 -0
- package/bin/plugins/lib/types.ts +72 -0
- package/bin/plugins/lib/virtual-buffer-factory.ts +130 -0
- package/bin/plugins/live_grep.i18n.json +171 -0
- package/bin/plugins/live_grep.ts +422 -0
- package/bin/plugins/markdown_compose.i18n.json +223 -0
- package/bin/plugins/markdown_compose.ts +630 -0
- package/bin/plugins/merge_conflict.i18n.json +821 -0
- package/bin/plugins/merge_conflict.ts +1810 -0
- package/bin/plugins/path_complete.i18n.json +80 -0
- package/bin/plugins/path_complete.ts +165 -0
- package/bin/plugins/python-lsp.ts +162 -0
- package/bin/plugins/rust-lsp.ts +166 -0
- package/bin/plugins/search_replace.i18n.json +405 -0
- package/bin/plugins/search_replace.ts +484 -0
- package/bin/plugins/test_i18n.i18n.json +67 -0
- package/bin/plugins/test_i18n.ts +18 -0
- package/bin/plugins/theme_editor.i18n.json +3746 -0
- package/bin/plugins/theme_editor.ts +2063 -0
- package/bin/plugins/todo_highlighter.i18n.json +184 -0
- package/bin/plugins/todo_highlighter.ts +206 -0
- package/bin/plugins/typescript-lsp.ts +167 -0
- package/bin/plugins/vi_mode.i18n.json +1549 -0
- package/bin/plugins/vi_mode.ts +2747 -0
- package/bin/plugins/welcome.i18n.json +236 -0
- package/bin/plugins/welcome.ts +76 -0
- package/bin/themes/dark.json +102 -0
- package/bin/themes/dracula.json +62 -0
- package/bin/themes/high-contrast.json +102 -0
- package/bin/themes/light.json +102 -0
- package/bin/themes/nord.json +62 -0
- package/bin/themes/nostalgia.json +102 -0
- package/bin/themes/solarized-dark.json +62 -0
- package/binary-install.js +1 -1
- package/dist/bin/fresh.js +9 -0
- package/dist/binary-install.js +149 -0
- package/dist/binary.js +30 -0
- package/dist/fresh-6yhknp07.exe +0 -0
- package/dist/install.js +158 -0
- package/dist/run-fresh.js +43 -0
- package/package.json +7 -2
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
/// <reference path="../types/fresh.d.ts" />
|
|
2
|
+
const editor = getEditor();
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Git Blame Plugin - Magit-style Git Blame Interface
|
|
7
|
+
*
|
|
8
|
+
* Provides an interactive git blame view using Virtual Lines (Emacs-like model):
|
|
9
|
+
* - Virtual buffer contains pure file content (for syntax highlighting)
|
|
10
|
+
* - Virtual lines are added above each blame block using addVirtualLine API
|
|
11
|
+
* - Headers have dark gray background and no line numbers
|
|
12
|
+
* - Content lines preserve source line numbers and syntax highlighting
|
|
13
|
+
*
|
|
14
|
+
* This uses the persistent state model where:
|
|
15
|
+
* - Plugin adds virtual lines when blame data loads (async)
|
|
16
|
+
* - Render loop reads virtual lines synchronously from memory
|
|
17
|
+
* - No view transform hooks needed - eliminates frame lag issues
|
|
18
|
+
*
|
|
19
|
+
* Features:
|
|
20
|
+
* - 'b' to go back in history (show blame at parent commit)
|
|
21
|
+
* - 'q' to close the blame view
|
|
22
|
+
* - 'y' to yank (copy) the commit hash at cursor
|
|
23
|
+
*
|
|
24
|
+
* Inspired by magit's git-blame-additions feature.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// Types and Interfaces
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
interface BlameLine {
|
|
32
|
+
hash: string;
|
|
33
|
+
shortHash: string;
|
|
34
|
+
author: string;
|
|
35
|
+
authorTime: string; // Unix timestamp
|
|
36
|
+
relativeDate: string;
|
|
37
|
+
summary: string;
|
|
38
|
+
lineNumber: number; // Original line number
|
|
39
|
+
finalLineNumber: number; // Final line number in the file
|
|
40
|
+
content: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface BlameBlock {
|
|
44
|
+
hash: string;
|
|
45
|
+
shortHash: string;
|
|
46
|
+
author: string;
|
|
47
|
+
relativeDate: string;
|
|
48
|
+
summary: string;
|
|
49
|
+
lines: BlameLine[];
|
|
50
|
+
startLine: number; // First line number in block (1-indexed)
|
|
51
|
+
endLine: number; // Last line number in block (1-indexed)
|
|
52
|
+
startByte: number; // Start byte offset in the buffer
|
|
53
|
+
endByte: number; // End byte offset in the buffer
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface BlameState {
|
|
57
|
+
isOpen: boolean;
|
|
58
|
+
bufferId: number | null;
|
|
59
|
+
splitId: number | null;
|
|
60
|
+
sourceBufferId: number | null; // The buffer that was open before blame
|
|
61
|
+
sourceFilePath: string | null; // Path to the file being blamed
|
|
62
|
+
currentCommit: string | null; // Current commit being viewed (null = HEAD)
|
|
63
|
+
commitStack: string[]; // Stack of commits for navigation
|
|
64
|
+
blocks: BlameBlock[]; // Blame blocks with byte offsets
|
|
65
|
+
fileContent: string; // Pure file content (for virtual buffer)
|
|
66
|
+
lineByteOffsets: number[]; // Byte offset of each line start
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// =============================================================================
|
|
70
|
+
// State Management
|
|
71
|
+
// =============================================================================
|
|
72
|
+
|
|
73
|
+
const blameState: BlameState = {
|
|
74
|
+
isOpen: false,
|
|
75
|
+
bufferId: null,
|
|
76
|
+
splitId: null,
|
|
77
|
+
sourceBufferId: null,
|
|
78
|
+
sourceFilePath: null,
|
|
79
|
+
currentCommit: null,
|
|
80
|
+
commitStack: [],
|
|
81
|
+
blocks: [],
|
|
82
|
+
fileContent: "",
|
|
83
|
+
lineByteOffsets: [],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// =============================================================================
|
|
87
|
+
// Color Definitions for Header Styling
|
|
88
|
+
// =============================================================================
|
|
89
|
+
|
|
90
|
+
const colors = {
|
|
91
|
+
headerFg: [0, 0, 0] as [number, number, number], // Black text
|
|
92
|
+
headerBg: [200, 200, 200] as [number, number, number], // Light gray background
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// =============================================================================
|
|
96
|
+
// Mode Definition
|
|
97
|
+
// =============================================================================
|
|
98
|
+
|
|
99
|
+
editor.defineMode(
|
|
100
|
+
"git-blame",
|
|
101
|
+
"normal", // inherit from normal mode for cursor movement
|
|
102
|
+
[
|
|
103
|
+
["b", "git_blame_go_back"],
|
|
104
|
+
["q", "git_blame_close"],
|
|
105
|
+
["Escape", "git_blame_close"],
|
|
106
|
+
["y", "git_blame_copy_hash"],
|
|
107
|
+
],
|
|
108
|
+
true // read-only
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// =============================================================================
|
|
112
|
+
// Git Blame Parsing
|
|
113
|
+
// =============================================================================
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse git blame --porcelain output
|
|
117
|
+
*/
|
|
118
|
+
async function fetchGitBlame(filePath: string, commit: string | null): Promise<BlameLine[]> {
|
|
119
|
+
const args = ["blame", "--porcelain"];
|
|
120
|
+
|
|
121
|
+
if (commit) {
|
|
122
|
+
args.push(commit);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
args.push("--", filePath);
|
|
126
|
+
|
|
127
|
+
const result = await editor.spawnProcess("git", args);
|
|
128
|
+
|
|
129
|
+
if (result.exit_code !== 0) {
|
|
130
|
+
editor.setStatus(editor.t("status.git_error", { error: result.stderr }));
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const lines: BlameLine[] = [];
|
|
135
|
+
const output = result.stdout;
|
|
136
|
+
const outputLines = output.split("\n");
|
|
137
|
+
|
|
138
|
+
let currentHash = "";
|
|
139
|
+
let currentAuthor = "";
|
|
140
|
+
let currentAuthorTime = "";
|
|
141
|
+
let currentSummary = "";
|
|
142
|
+
let currentOrigLine = 0;
|
|
143
|
+
let currentFinalLine = 0;
|
|
144
|
+
|
|
145
|
+
// Cache for commit info to avoid redundant parsing
|
|
146
|
+
const commitInfo: Map<string, { author: string; authorTime: string; summary: string }> = new Map();
|
|
147
|
+
|
|
148
|
+
for (let i = 0; i < outputLines.length; i++) {
|
|
149
|
+
const line = outputLines[i];
|
|
150
|
+
|
|
151
|
+
// Check for commit line: <hash> <orig-line> <final-line> [num-lines]
|
|
152
|
+
const commitMatch = line.match(/^([a-f0-9]{40}) (\d+) (\d+)/);
|
|
153
|
+
if (commitMatch) {
|
|
154
|
+
currentHash = commitMatch[1];
|
|
155
|
+
currentOrigLine = parseInt(commitMatch[2], 10);
|
|
156
|
+
currentFinalLine = parseInt(commitMatch[3], 10);
|
|
157
|
+
|
|
158
|
+
// Check cache for this commit's info
|
|
159
|
+
const cached = commitInfo.get(currentHash);
|
|
160
|
+
if (cached) {
|
|
161
|
+
currentAuthor = cached.author;
|
|
162
|
+
currentAuthorTime = cached.authorTime;
|
|
163
|
+
currentSummary = cached.summary;
|
|
164
|
+
}
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Parse header fields
|
|
169
|
+
if (line.startsWith("author ")) {
|
|
170
|
+
currentAuthor = line.slice(7);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (line.startsWith("author-time ")) {
|
|
174
|
+
currentAuthorTime = line.slice(12);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (line.startsWith("summary ")) {
|
|
178
|
+
currentSummary = line.slice(8);
|
|
179
|
+
// Cache this commit's info
|
|
180
|
+
commitInfo.set(currentHash, {
|
|
181
|
+
author: currentAuthor,
|
|
182
|
+
authorTime: currentAuthorTime,
|
|
183
|
+
summary: currentSummary,
|
|
184
|
+
});
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Content line (starts with tab)
|
|
189
|
+
if (line.startsWith("\t")) {
|
|
190
|
+
const content = line.slice(1);
|
|
191
|
+
|
|
192
|
+
// Calculate relative date from author-time
|
|
193
|
+
const relativeDate = formatRelativeDate(parseInt(currentAuthorTime, 10));
|
|
194
|
+
|
|
195
|
+
lines.push({
|
|
196
|
+
hash: currentHash,
|
|
197
|
+
shortHash: currentHash.slice(0, 7),
|
|
198
|
+
author: currentAuthor,
|
|
199
|
+
authorTime: currentAuthorTime,
|
|
200
|
+
relativeDate: relativeDate,
|
|
201
|
+
summary: currentSummary,
|
|
202
|
+
lineNumber: currentOrigLine,
|
|
203
|
+
finalLineNumber: currentFinalLine,
|
|
204
|
+
content: content,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return lines;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Format a unix timestamp as a relative date string
|
|
214
|
+
*/
|
|
215
|
+
function formatRelativeDate(timestamp: number): string {
|
|
216
|
+
const now = Math.floor(Date.now() / 1000);
|
|
217
|
+
const diff = now - timestamp;
|
|
218
|
+
|
|
219
|
+
if (diff < 60) {
|
|
220
|
+
return editor.t("time.just_now");
|
|
221
|
+
} else if (diff < 3600) {
|
|
222
|
+
const count = Math.floor(diff / 60);
|
|
223
|
+
return editor.t(count > 1 ? "time.minutes_ago_plural" : "time.minutes_ago", { count: String(count) });
|
|
224
|
+
} else if (diff < 86400) {
|
|
225
|
+
const count = Math.floor(diff / 3600);
|
|
226
|
+
return editor.t(count > 1 ? "time.hours_ago_plural" : "time.hours_ago", { count: String(count) });
|
|
227
|
+
} else if (diff < 604800) {
|
|
228
|
+
const count = Math.floor(diff / 86400);
|
|
229
|
+
return editor.t(count > 1 ? "time.days_ago_plural" : "time.days_ago", { count: String(count) });
|
|
230
|
+
} else if (diff < 2592000) {
|
|
231
|
+
const count = Math.floor(diff / 604800);
|
|
232
|
+
return editor.t(count > 1 ? "time.weeks_ago_plural" : "time.weeks_ago", { count: String(count) });
|
|
233
|
+
} else if (diff < 31536000) {
|
|
234
|
+
const count = Math.floor(diff / 2592000);
|
|
235
|
+
return editor.t(count > 1 ? "time.months_ago_plural" : "time.months_ago", { count: String(count) });
|
|
236
|
+
} else {
|
|
237
|
+
const count = Math.floor(diff / 31536000);
|
|
238
|
+
return editor.t(count > 1 ? "time.years_ago_plural" : "time.years_ago", { count: String(count) });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Fetch file content at a specific commit (or HEAD)
|
|
244
|
+
*/
|
|
245
|
+
async function fetchFileContent(filePath: string, commit: string | null): Promise<string> {
|
|
246
|
+
if (commit) {
|
|
247
|
+
// Get historical file content
|
|
248
|
+
const result = await editor.spawnProcess("git", ["show", `${commit}:${filePath}`]);
|
|
249
|
+
if (result.exit_code === 0) {
|
|
250
|
+
return result.stdout;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Get current file content using editor API (cross-platform)
|
|
255
|
+
try {
|
|
256
|
+
return await editor.readFile(filePath);
|
|
257
|
+
} catch {
|
|
258
|
+
return "";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Build line byte offset lookup table
|
|
264
|
+
*/
|
|
265
|
+
function buildLineByteOffsets(content: string): number[] {
|
|
266
|
+
const offsets: number[] = [0]; // Line 1 starts at byte 0
|
|
267
|
+
let byteOffset = 0;
|
|
268
|
+
|
|
269
|
+
for (const char of content) {
|
|
270
|
+
byteOffset += char.length; // In JS strings, each char is at least 1
|
|
271
|
+
if (char === '\n') {
|
|
272
|
+
offsets.push(byteOffset);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return offsets;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Get byte offset for a given line number (1-indexed)
|
|
281
|
+
*/
|
|
282
|
+
function getLineByteOffset(lineNum: number): number {
|
|
283
|
+
if (lineNum <= 0) return 0;
|
|
284
|
+
const idx = lineNum - 1;
|
|
285
|
+
if (idx < blameState.lineByteOffsets.length) {
|
|
286
|
+
return blameState.lineByteOffsets[idx];
|
|
287
|
+
}
|
|
288
|
+
// Return end of file if line number is out of range
|
|
289
|
+
return blameState.fileContent.length;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Group blame lines into blocks by commit, with byte offset information
|
|
294
|
+
*/
|
|
295
|
+
function groupIntoBlocks(lines: BlameLine[]): BlameBlock[] {
|
|
296
|
+
const blocks: BlameBlock[] = [];
|
|
297
|
+
let currentBlock: BlameBlock | null = null;
|
|
298
|
+
|
|
299
|
+
for (const line of lines) {
|
|
300
|
+
// Check if we need to start a new block
|
|
301
|
+
if (!currentBlock || currentBlock.hash !== line.hash) {
|
|
302
|
+
// Save previous block
|
|
303
|
+
if (currentBlock && currentBlock.lines.length > 0) {
|
|
304
|
+
currentBlock.endByte = getLineByteOffset(currentBlock.endLine + 1);
|
|
305
|
+
blocks.push(currentBlock);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Start new block
|
|
309
|
+
currentBlock = {
|
|
310
|
+
hash: line.hash,
|
|
311
|
+
shortHash: line.shortHash,
|
|
312
|
+
author: line.author,
|
|
313
|
+
relativeDate: line.relativeDate,
|
|
314
|
+
summary: line.summary,
|
|
315
|
+
lines: [],
|
|
316
|
+
startLine: line.finalLineNumber,
|
|
317
|
+
endLine: line.finalLineNumber,
|
|
318
|
+
startByte: getLineByteOffset(line.finalLineNumber),
|
|
319
|
+
endByte: 0, // Will be set when block is complete
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
currentBlock.lines.push(line);
|
|
324
|
+
currentBlock.endLine = line.finalLineNumber;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Don't forget the last block
|
|
328
|
+
if (currentBlock && currentBlock.lines.length > 0) {
|
|
329
|
+
currentBlock.endByte = getLineByteOffset(currentBlock.endLine + 1);
|
|
330
|
+
blocks.push(currentBlock);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return blocks;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// =============================================================================
|
|
337
|
+
// Virtual Lines (Emacs-like persistent state model)
|
|
338
|
+
// =============================================================================
|
|
339
|
+
|
|
340
|
+
const BLAME_NAMESPACE = "git-blame";
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Format a header line for a blame block
|
|
344
|
+
*/
|
|
345
|
+
function formatBlockHeader(block: BlameBlock): string {
|
|
346
|
+
// Truncate summary if too long
|
|
347
|
+
const maxSummaryLen = 50;
|
|
348
|
+
const summary = block.summary.length > maxSummaryLen
|
|
349
|
+
? block.summary.slice(0, maxSummaryLen - 3) + "..."
|
|
350
|
+
: block.summary;
|
|
351
|
+
|
|
352
|
+
return `── ${block.shortHash} (${block.author}, ${block.relativeDate}) "${summary}" ──`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Find which block (if any) starts at or before the given byte offset
|
|
357
|
+
*/
|
|
358
|
+
function findBlockForByteOffset(byteOffset: number): BlameBlock | null {
|
|
359
|
+
for (const block of blameState.blocks) {
|
|
360
|
+
if (byteOffset >= block.startByte && byteOffset < block.endByte) {
|
|
361
|
+
return block;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Add virtual lines for all blame block headers
|
|
369
|
+
* Called when blame data is loaded or updated
|
|
370
|
+
*/
|
|
371
|
+
function addBlameHeaders(): void {
|
|
372
|
+
if (blameState.bufferId === null) return;
|
|
373
|
+
|
|
374
|
+
// Clear existing headers first
|
|
375
|
+
editor.clearVirtualTextNamespace(blameState.bufferId, BLAME_NAMESPACE);
|
|
376
|
+
|
|
377
|
+
// Add a virtual line above each block
|
|
378
|
+
for (const block of blameState.blocks) {
|
|
379
|
+
const headerText = formatBlockHeader(block);
|
|
380
|
+
|
|
381
|
+
editor.addVirtualLine(
|
|
382
|
+
blameState.bufferId,
|
|
383
|
+
block.startByte, // anchor position
|
|
384
|
+
headerText, // text content
|
|
385
|
+
colors.headerFg[0], // fg_r
|
|
386
|
+
colors.headerFg[1], // fg_g
|
|
387
|
+
colors.headerFg[2], // fg_b
|
|
388
|
+
colors.headerBg[0], // bg_r
|
|
389
|
+
colors.headerBg[1], // bg_g
|
|
390
|
+
colors.headerBg[2], // bg_b
|
|
391
|
+
true, // above (LineAbove)
|
|
392
|
+
BLAME_NAMESPACE, // namespace for bulk removal
|
|
393
|
+
0 // priority
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
editor.debug(`Added ${blameState.blocks.length} blame header virtual lines`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// =============================================================================
|
|
401
|
+
// Public Commands
|
|
402
|
+
// =============================================================================
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Show git blame for the current file
|
|
406
|
+
*/
|
|
407
|
+
globalThis.show_git_blame = async function(): Promise<void> {
|
|
408
|
+
if (blameState.isOpen) {
|
|
409
|
+
editor.setStatus(editor.t("status.already_open"));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Get current file path
|
|
414
|
+
const activeBufferId = editor.getActiveBufferId();
|
|
415
|
+
const filePath = editor.getBufferPath(activeBufferId);
|
|
416
|
+
if (!filePath || filePath === "") {
|
|
417
|
+
editor.setStatus(editor.t("status.no_file"));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
editor.setStatus(editor.t("status.loading"));
|
|
422
|
+
|
|
423
|
+
// Store state before opening blame
|
|
424
|
+
blameState.splitId = editor.getActiveSplitId();
|
|
425
|
+
blameState.sourceBufferId = activeBufferId;
|
|
426
|
+
blameState.sourceFilePath = filePath;
|
|
427
|
+
blameState.currentCommit = null;
|
|
428
|
+
blameState.commitStack = [];
|
|
429
|
+
|
|
430
|
+
// Fetch file content and blame data in parallel
|
|
431
|
+
const [fileContent, blameLines] = await Promise.all([
|
|
432
|
+
fetchFileContent(filePath, null),
|
|
433
|
+
fetchGitBlame(filePath, null),
|
|
434
|
+
]);
|
|
435
|
+
|
|
436
|
+
if (blameLines.length === 0) {
|
|
437
|
+
editor.setStatus(editor.t("status.no_blame_info"));
|
|
438
|
+
resetState();
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Store file content and build line offset table
|
|
443
|
+
blameState.fileContent = fileContent;
|
|
444
|
+
blameState.lineByteOffsets = buildLineByteOffsets(fileContent);
|
|
445
|
+
|
|
446
|
+
// Group into blocks with byte offsets
|
|
447
|
+
blameState.blocks = groupIntoBlocks(blameLines);
|
|
448
|
+
|
|
449
|
+
// Get file extension for language detection
|
|
450
|
+
const ext = filePath.includes('.') ? filePath.split('.').pop() : '';
|
|
451
|
+
const bufferName = `*blame:${editor.pathBasename(filePath)}*`;
|
|
452
|
+
|
|
453
|
+
// Create virtual buffer with PURE file content (for syntax highlighting)
|
|
454
|
+
// Virtual lines will be added after buffer creation
|
|
455
|
+
const entries: TextPropertyEntry[] = [];
|
|
456
|
+
|
|
457
|
+
// We need to track which line belongs to which block for text properties
|
|
458
|
+
let lineNum = 1;
|
|
459
|
+
const contentLines = fileContent.split('\n');
|
|
460
|
+
let byteOffset = 0;
|
|
461
|
+
|
|
462
|
+
for (const line of contentLines) {
|
|
463
|
+
// Find the block for this line
|
|
464
|
+
const block = findBlockForByteOffset(byteOffset);
|
|
465
|
+
|
|
466
|
+
entries.push({
|
|
467
|
+
text: line + (lineNum < contentLines.length || fileContent.endsWith('\n') ? '\n' : ''),
|
|
468
|
+
properties: {
|
|
469
|
+
type: "content",
|
|
470
|
+
hash: block?.hash ?? null,
|
|
471
|
+
shortHash: block?.shortHash ?? null,
|
|
472
|
+
lineNumber: lineNum,
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
byteOffset += line.length + 1; // +1 for newline
|
|
477
|
+
lineNum++;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Create virtual buffer with the file content
|
|
481
|
+
const bufferId = await editor.createVirtualBufferInExistingSplit({
|
|
482
|
+
name: bufferName,
|
|
483
|
+
mode: "git-blame",
|
|
484
|
+
read_only: true,
|
|
485
|
+
entries: entries,
|
|
486
|
+
split_id: blameState.splitId!,
|
|
487
|
+
show_line_numbers: true, // We DO want line numbers (headers won't have them due to source_offset: null)
|
|
488
|
+
show_cursors: true,
|
|
489
|
+
editing_disabled: true,
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
if (bufferId !== null) {
|
|
493
|
+
blameState.isOpen = true;
|
|
494
|
+
blameState.bufferId = bufferId;
|
|
495
|
+
|
|
496
|
+
// Add virtual lines for blame headers (persistent state model)
|
|
497
|
+
addBlameHeaders();
|
|
498
|
+
|
|
499
|
+
editor.setStatus(editor.t("status.blame_ready", { count: String(blameState.blocks.length) }));
|
|
500
|
+
editor.debug("Git blame panel opened with virtual lines architecture");
|
|
501
|
+
} else {
|
|
502
|
+
resetState();
|
|
503
|
+
editor.setStatus(editor.t("status.failed_open"));
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Reset blame state
|
|
509
|
+
*/
|
|
510
|
+
function resetState(): void {
|
|
511
|
+
blameState.splitId = null;
|
|
512
|
+
blameState.sourceBufferId = null;
|
|
513
|
+
blameState.sourceFilePath = null;
|
|
514
|
+
blameState.currentCommit = null;
|
|
515
|
+
blameState.commitStack = [];
|
|
516
|
+
blameState.blocks = [];
|
|
517
|
+
blameState.fileContent = "";
|
|
518
|
+
blameState.lineByteOffsets = [];
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Close the git blame view
|
|
523
|
+
*/
|
|
524
|
+
globalThis.git_blame_close = function(): void {
|
|
525
|
+
if (!blameState.isOpen) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Restore the original buffer in the split
|
|
530
|
+
if (blameState.splitId !== null && blameState.sourceBufferId !== null) {
|
|
531
|
+
editor.setSplitBuffer(blameState.splitId, blameState.sourceBufferId);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Close the blame buffer
|
|
535
|
+
if (blameState.bufferId !== null) {
|
|
536
|
+
editor.closeBuffer(blameState.bufferId);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
blameState.isOpen = false;
|
|
540
|
+
blameState.bufferId = null;
|
|
541
|
+
resetState();
|
|
542
|
+
|
|
543
|
+
editor.setStatus(editor.t("status.closed"));
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Get the commit hash at the current cursor position
|
|
548
|
+
*/
|
|
549
|
+
function getCommitAtCursor(): string | null {
|
|
550
|
+
if (blameState.bufferId === null) return null;
|
|
551
|
+
|
|
552
|
+
const props = editor.getTextPropertiesAtCursor(blameState.bufferId);
|
|
553
|
+
|
|
554
|
+
if (props.length > 0) {
|
|
555
|
+
const hash = props[0].hash as string | undefined;
|
|
556
|
+
if (hash) {
|
|
557
|
+
return hash;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Navigate to blame at the parent commit of the current line's commit
|
|
566
|
+
*/
|
|
567
|
+
globalThis.git_blame_go_back = async function(): Promise<void> {
|
|
568
|
+
if (!blameState.isOpen || !blameState.sourceFilePath) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const currentHash = getCommitAtCursor();
|
|
573
|
+
if (!currentHash) {
|
|
574
|
+
editor.setStatus(editor.t("status.move_to_line"));
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Skip if this is the "not committed yet" hash (all zeros)
|
|
579
|
+
if (currentHash === "0000000000000000000000000000000000000000") {
|
|
580
|
+
editor.setStatus(editor.t("status.not_committed"));
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
editor.setStatus(editor.t("status.loading_parent", { hash: currentHash.slice(0, 7) }));
|
|
585
|
+
|
|
586
|
+
// Get the parent commit
|
|
587
|
+
const parentCommit = `${currentHash}^`;
|
|
588
|
+
|
|
589
|
+
// Push current state to stack for potential future navigation
|
|
590
|
+
if (blameState.currentCommit) {
|
|
591
|
+
blameState.commitStack.push(blameState.currentCommit);
|
|
592
|
+
} else {
|
|
593
|
+
blameState.commitStack.push("HEAD");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Fetch file content and blame at parent commit
|
|
597
|
+
const [fileContent, blameLines] = await Promise.all([
|
|
598
|
+
fetchFileContent(blameState.sourceFilePath, parentCommit),
|
|
599
|
+
fetchGitBlame(blameState.sourceFilePath, parentCommit),
|
|
600
|
+
]);
|
|
601
|
+
|
|
602
|
+
if (blameLines.length === 0) {
|
|
603
|
+
// Pop the stack since we couldn't navigate
|
|
604
|
+
blameState.commitStack.pop();
|
|
605
|
+
editor.setStatus(editor.t("status.cannot_go_back", { hash: currentHash.slice(0, 7) }));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Update state
|
|
610
|
+
blameState.currentCommit = parentCommit;
|
|
611
|
+
blameState.fileContent = fileContent;
|
|
612
|
+
blameState.lineByteOffsets = buildLineByteOffsets(fileContent);
|
|
613
|
+
blameState.blocks = groupIntoBlocks(blameLines);
|
|
614
|
+
|
|
615
|
+
// Update virtual buffer content
|
|
616
|
+
if (blameState.bufferId !== null) {
|
|
617
|
+
const entries: TextPropertyEntry[] = [];
|
|
618
|
+
let lineNum = 1;
|
|
619
|
+
const contentLines = fileContent.split('\n');
|
|
620
|
+
let byteOffset = 0;
|
|
621
|
+
|
|
622
|
+
for (const line of contentLines) {
|
|
623
|
+
const block = findBlockForByteOffset(byteOffset);
|
|
624
|
+
|
|
625
|
+
entries.push({
|
|
626
|
+
text: line + (lineNum < contentLines.length || fileContent.endsWith('\n') ? '\n' : ''),
|
|
627
|
+
properties: {
|
|
628
|
+
type: "content",
|
|
629
|
+
hash: block?.hash ?? null,
|
|
630
|
+
shortHash: block?.shortHash ?? null,
|
|
631
|
+
lineNumber: lineNum,
|
|
632
|
+
},
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
byteOffset += line.length + 1;
|
|
636
|
+
lineNum++;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
editor.setVirtualBufferContent(blameState.bufferId, entries);
|
|
640
|
+
|
|
641
|
+
// Re-add virtual lines for the new blame data
|
|
642
|
+
addBlameHeaders();
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const depth = blameState.commitStack.length;
|
|
646
|
+
editor.setStatus(editor.t("status.blame_at_parent", { hash: currentHash.slice(0, 7), depth: String(depth) }));
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Copy the commit hash at cursor to clipboard
|
|
651
|
+
*/
|
|
652
|
+
globalThis.git_blame_copy_hash = function(): void {
|
|
653
|
+
if (!blameState.isOpen) return;
|
|
654
|
+
|
|
655
|
+
const hash = getCommitAtCursor();
|
|
656
|
+
if (!hash) {
|
|
657
|
+
editor.setStatus(editor.t("status.move_to_line"));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Skip if this is the "not committed yet" hash
|
|
662
|
+
if (hash === "0000000000000000000000000000000000000000") {
|
|
663
|
+
editor.setStatus(editor.t("status.not_committed"));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Use spawn to copy to clipboard
|
|
668
|
+
editor.spawnProcess("sh", ["-c", `echo -n "${hash}" | xclip -selection clipboard 2>/dev/null || echo -n "${hash}" | pbcopy 2>/dev/null || echo -n "${hash}" | xsel --clipboard 2>/dev/null`])
|
|
669
|
+
.then(() => {
|
|
670
|
+
editor.setStatus(editor.t("status.hash_copied", { short: hash.slice(0, 7), full: hash }));
|
|
671
|
+
})
|
|
672
|
+
.catch(() => {
|
|
673
|
+
editor.setStatus(editor.t("status.hash_display", { hash }));
|
|
674
|
+
});
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// =============================================================================
|
|
678
|
+
// Command Registration
|
|
679
|
+
// =============================================================================
|
|
680
|
+
|
|
681
|
+
editor.registerCommand(
|
|
682
|
+
"%cmd.git_blame",
|
|
683
|
+
"%cmd.git_blame_desc",
|
|
684
|
+
"show_git_blame",
|
|
685
|
+
"normal"
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
editor.registerCommand(
|
|
689
|
+
"%cmd.git_blame_close",
|
|
690
|
+
"%cmd.git_blame_close_desc",
|
|
691
|
+
"git_blame_close",
|
|
692
|
+
"normal"
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
editor.registerCommand(
|
|
696
|
+
"%cmd.git_blame_go_back",
|
|
697
|
+
"%cmd.git_blame_go_back_desc",
|
|
698
|
+
"git_blame_go_back",
|
|
699
|
+
"normal"
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
// =============================================================================
|
|
703
|
+
// Plugin Initialization
|
|
704
|
+
// =============================================================================
|
|
705
|
+
|
|
706
|
+
editor.setStatus(editor.t("status.ready"));
|
|
707
|
+
editor.debug("Git Blame plugin initialized - Use 'Git Blame' command to open");
|