@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.
Files changed (90) hide show
  1. package/bin/CHANGELOG.md +1017 -0
  2. package/bin/LICENSE +117 -0
  3. package/bin/README.md +248 -0
  4. package/bin/fresh.exe +0 -0
  5. package/bin/plugins/README.md +71 -0
  6. package/bin/plugins/audit_mode.i18n.json +821 -0
  7. package/bin/plugins/audit_mode.ts +1810 -0
  8. package/bin/plugins/buffer_modified.i18n.json +67 -0
  9. package/bin/plugins/buffer_modified.ts +281 -0
  10. package/bin/plugins/calculator.i18n.json +93 -0
  11. package/bin/plugins/calculator.ts +770 -0
  12. package/bin/plugins/clangd-lsp.ts +168 -0
  13. package/bin/plugins/clangd_support.i18n.json +223 -0
  14. package/bin/plugins/clangd_support.md +20 -0
  15. package/bin/plugins/clangd_support.ts +325 -0
  16. package/bin/plugins/color_highlighter.i18n.json +145 -0
  17. package/bin/plugins/color_highlighter.ts +304 -0
  18. package/bin/plugins/config-schema.json +768 -0
  19. package/bin/plugins/csharp-lsp.ts +147 -0
  20. package/bin/plugins/csharp_support.i18n.json +80 -0
  21. package/bin/plugins/csharp_support.ts +170 -0
  22. package/bin/plugins/css-lsp.ts +143 -0
  23. package/bin/plugins/diagnostics_panel.i18n.json +236 -0
  24. package/bin/plugins/diagnostics_panel.ts +642 -0
  25. package/bin/plugins/examples/README.md +85 -0
  26. package/bin/plugins/examples/async_demo.ts +165 -0
  27. package/bin/plugins/examples/bookmarks.ts +329 -0
  28. package/bin/plugins/examples/buffer_query_demo.ts +110 -0
  29. package/bin/plugins/examples/git_grep.ts +262 -0
  30. package/bin/plugins/examples/hello_world.ts +93 -0
  31. package/bin/plugins/examples/virtual_buffer_demo.ts +116 -0
  32. package/bin/plugins/find_references.i18n.json +275 -0
  33. package/bin/plugins/find_references.ts +359 -0
  34. package/bin/plugins/git_blame.i18n.json +496 -0
  35. package/bin/plugins/git_blame.ts +707 -0
  36. package/bin/plugins/git_find_file.i18n.json +314 -0
  37. package/bin/plugins/git_find_file.ts +300 -0
  38. package/bin/plugins/git_grep.i18n.json +171 -0
  39. package/bin/plugins/git_grep.ts +191 -0
  40. package/bin/plugins/git_gutter.i18n.json +93 -0
  41. package/bin/plugins/git_gutter.ts +477 -0
  42. package/bin/plugins/git_log.i18n.json +481 -0
  43. package/bin/plugins/git_log.ts +1285 -0
  44. package/bin/plugins/go-lsp.ts +143 -0
  45. package/bin/plugins/html-lsp.ts +145 -0
  46. package/bin/plugins/json-lsp.ts +145 -0
  47. package/bin/plugins/lib/fresh.d.ts +1321 -0
  48. package/bin/plugins/lib/index.ts +24 -0
  49. package/bin/plugins/lib/navigation-controller.ts +214 -0
  50. package/bin/plugins/lib/panel-manager.ts +220 -0
  51. package/bin/plugins/lib/types.ts +72 -0
  52. package/bin/plugins/lib/virtual-buffer-factory.ts +130 -0
  53. package/bin/plugins/live_grep.i18n.json +171 -0
  54. package/bin/plugins/live_grep.ts +422 -0
  55. package/bin/plugins/markdown_compose.i18n.json +223 -0
  56. package/bin/plugins/markdown_compose.ts +630 -0
  57. package/bin/plugins/merge_conflict.i18n.json +821 -0
  58. package/bin/plugins/merge_conflict.ts +1810 -0
  59. package/bin/plugins/path_complete.i18n.json +80 -0
  60. package/bin/plugins/path_complete.ts +165 -0
  61. package/bin/plugins/python-lsp.ts +162 -0
  62. package/bin/plugins/rust-lsp.ts +166 -0
  63. package/bin/plugins/search_replace.i18n.json +405 -0
  64. package/bin/plugins/search_replace.ts +484 -0
  65. package/bin/plugins/test_i18n.i18n.json +67 -0
  66. package/bin/plugins/test_i18n.ts +18 -0
  67. package/bin/plugins/theme_editor.i18n.json +3746 -0
  68. package/bin/plugins/theme_editor.ts +2063 -0
  69. package/bin/plugins/todo_highlighter.i18n.json +184 -0
  70. package/bin/plugins/todo_highlighter.ts +206 -0
  71. package/bin/plugins/typescript-lsp.ts +167 -0
  72. package/bin/plugins/vi_mode.i18n.json +1549 -0
  73. package/bin/plugins/vi_mode.ts +2747 -0
  74. package/bin/plugins/welcome.i18n.json +236 -0
  75. package/bin/plugins/welcome.ts +76 -0
  76. package/bin/themes/dark.json +102 -0
  77. package/bin/themes/dracula.json +62 -0
  78. package/bin/themes/high-contrast.json +102 -0
  79. package/bin/themes/light.json +102 -0
  80. package/bin/themes/nord.json +62 -0
  81. package/bin/themes/nostalgia.json +102 -0
  82. package/bin/themes/solarized-dark.json +62 -0
  83. package/binary-install.js +1 -1
  84. package/dist/bin/fresh.js +9 -0
  85. package/dist/binary-install.js +149 -0
  86. package/dist/binary.js +30 -0
  87. package/dist/fresh-6yhknp07.exe +0 -0
  88. package/dist/install.js +158 -0
  89. package/dist/run-fresh.js +43 -0
  90. 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");