@fresh-editor/fresh-editor 0.1.4 → 0.1.5

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.
@@ -23,7 +23,7 @@
23
23
  "hasInstallScript": true,
24
24
  "license": "GPL-2.0",
25
25
  "name": "@fresh-editor/fresh-editor",
26
- "version": "0.1.4"
26
+ "version": "0.1.5"
27
27
  },
28
28
  "node_modules/@isaacs/balanced-match": {
29
29
  "engines": {
@@ -896,5 +896,5 @@
896
896
  }
897
897
  },
898
898
  "requires": true,
899
- "version": "0.1.4"
899
+ "version": "0.1.5"
900
900
  }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "artifactDownloadUrl": "https://github.com/sinelaw/fresh/releases/download/v0.1.4",
2
+ "artifactDownloadUrl": "https://github.com/sinelaw/fresh/releases/download/v0.1.5",
3
3
  "author": "Noam Lewis",
4
4
  "bin": {
5
5
  "fresh": "run-fresh.js"
@@ -92,7 +92,7 @@
92
92
  "zipExt": ".tar.xz"
93
93
  }
94
94
  },
95
- "version": "0.1.4",
95
+ "version": "0.1.5",
96
96
  "volta": {
97
97
  "node": "18.14.1",
98
98
  "npm": "9.5.0"
@@ -0,0 +1,699 @@
1
+ /// <reference path="../types/fresh.d.ts" />
2
+
3
+ /**
4
+ * Git Blame Plugin - Magit-style Git Blame Interface
5
+ *
6
+ * Provides an interactive git blame view using Virtual Lines (Emacs-like model):
7
+ * - Virtual buffer contains pure file content (for syntax highlighting)
8
+ * - Virtual lines are added above each blame block using addVirtualLine API
9
+ * - Headers have dark gray background and no line numbers
10
+ * - Content lines preserve source line numbers and syntax highlighting
11
+ *
12
+ * This uses the persistent state model where:
13
+ * - Plugin adds virtual lines when blame data loads (async)
14
+ * - Render loop reads virtual lines synchronously from memory
15
+ * - No view transform hooks needed - eliminates frame lag issues
16
+ *
17
+ * Features:
18
+ * - 'b' to go back in history (show blame at parent commit)
19
+ * - 'q' to close the blame view
20
+ * - 'y' to yank (copy) the commit hash at cursor
21
+ *
22
+ * Inspired by magit's git-blame-additions feature.
23
+ */
24
+
25
+ // =============================================================================
26
+ // Types and Interfaces
27
+ // =============================================================================
28
+
29
+ interface BlameLine {
30
+ hash: string;
31
+ shortHash: string;
32
+ author: string;
33
+ authorTime: string; // Unix timestamp
34
+ relativeDate: string;
35
+ summary: string;
36
+ lineNumber: number; // Original line number
37
+ finalLineNumber: number; // Final line number in the file
38
+ content: string;
39
+ }
40
+
41
+ interface BlameBlock {
42
+ hash: string;
43
+ shortHash: string;
44
+ author: string;
45
+ relativeDate: string;
46
+ summary: string;
47
+ lines: BlameLine[];
48
+ startLine: number; // First line number in block (1-indexed)
49
+ endLine: number; // Last line number in block (1-indexed)
50
+ startByte: number; // Start byte offset in the buffer
51
+ endByte: number; // End byte offset in the buffer
52
+ }
53
+
54
+ interface BlameState {
55
+ isOpen: boolean;
56
+ bufferId: number | null;
57
+ splitId: number | null;
58
+ sourceBufferId: number | null; // The buffer that was open before blame
59
+ sourceFilePath: string | null; // Path to the file being blamed
60
+ currentCommit: string | null; // Current commit being viewed (null = HEAD)
61
+ commitStack: string[]; // Stack of commits for navigation
62
+ blocks: BlameBlock[]; // Blame blocks with byte offsets
63
+ fileContent: string; // Pure file content (for virtual buffer)
64
+ lineByteOffsets: number[]; // Byte offset of each line start
65
+ }
66
+
67
+ // =============================================================================
68
+ // State Management
69
+ // =============================================================================
70
+
71
+ const blameState: BlameState = {
72
+ isOpen: false,
73
+ bufferId: null,
74
+ splitId: null,
75
+ sourceBufferId: null,
76
+ sourceFilePath: null,
77
+ currentCommit: null,
78
+ commitStack: [],
79
+ blocks: [],
80
+ fileContent: "",
81
+ lineByteOffsets: [],
82
+ };
83
+
84
+ // =============================================================================
85
+ // Color Definitions for Header Styling
86
+ // =============================================================================
87
+
88
+ const colors = {
89
+ headerFg: [220, 220, 220] as [number, number, number], // Light gray text
90
+ headerBg: [50, 50, 55] as [number, number, number], // Dark gray background
91
+ };
92
+
93
+ // =============================================================================
94
+ // Mode Definition
95
+ // =============================================================================
96
+
97
+ editor.defineMode(
98
+ "git-blame",
99
+ "normal", // inherit from normal mode for cursor movement
100
+ [
101
+ ["b", "git_blame_go_back"],
102
+ ["q", "git_blame_close"],
103
+ ["Escape", "git_blame_close"],
104
+ ["y", "git_blame_copy_hash"],
105
+ ],
106
+ true // read-only
107
+ );
108
+
109
+ // =============================================================================
110
+ // Git Blame Parsing
111
+ // =============================================================================
112
+
113
+ /**
114
+ * Parse git blame --porcelain output
115
+ */
116
+ async function fetchGitBlame(filePath: string, commit: string | null): Promise<BlameLine[]> {
117
+ const args = ["blame", "--porcelain"];
118
+
119
+ if (commit) {
120
+ args.push(commit);
121
+ }
122
+
123
+ args.push("--", filePath);
124
+
125
+ const result = await editor.spawnProcess("git", args);
126
+
127
+ if (result.exit_code !== 0) {
128
+ editor.setStatus(`Git blame error: ${result.stderr}`);
129
+ return [];
130
+ }
131
+
132
+ const lines: BlameLine[] = [];
133
+ const output = result.stdout;
134
+ const outputLines = output.split("\n");
135
+
136
+ let currentHash = "";
137
+ let currentAuthor = "";
138
+ let currentAuthorTime = "";
139
+ let currentSummary = "";
140
+ let currentOrigLine = 0;
141
+ let currentFinalLine = 0;
142
+
143
+ // Cache for commit info to avoid redundant parsing
144
+ const commitInfo: Map<string, { author: string; authorTime: string; summary: string }> = new Map();
145
+
146
+ for (let i = 0; i < outputLines.length; i++) {
147
+ const line = outputLines[i];
148
+
149
+ // Check for commit line: <hash> <orig-line> <final-line> [num-lines]
150
+ const commitMatch = line.match(/^([a-f0-9]{40}) (\d+) (\d+)/);
151
+ if (commitMatch) {
152
+ currentHash = commitMatch[1];
153
+ currentOrigLine = parseInt(commitMatch[2], 10);
154
+ currentFinalLine = parseInt(commitMatch[3], 10);
155
+
156
+ // Check cache for this commit's info
157
+ const cached = commitInfo.get(currentHash);
158
+ if (cached) {
159
+ currentAuthor = cached.author;
160
+ currentAuthorTime = cached.authorTime;
161
+ currentSummary = cached.summary;
162
+ }
163
+ continue;
164
+ }
165
+
166
+ // Parse header fields
167
+ if (line.startsWith("author ")) {
168
+ currentAuthor = line.slice(7);
169
+ continue;
170
+ }
171
+ if (line.startsWith("author-time ")) {
172
+ currentAuthorTime = line.slice(12);
173
+ continue;
174
+ }
175
+ if (line.startsWith("summary ")) {
176
+ currentSummary = line.slice(8);
177
+ // Cache this commit's info
178
+ commitInfo.set(currentHash, {
179
+ author: currentAuthor,
180
+ authorTime: currentAuthorTime,
181
+ summary: currentSummary,
182
+ });
183
+ continue;
184
+ }
185
+
186
+ // Content line (starts with tab)
187
+ if (line.startsWith("\t")) {
188
+ const content = line.slice(1);
189
+
190
+ // Calculate relative date from author-time
191
+ const relativeDate = formatRelativeDate(parseInt(currentAuthorTime, 10));
192
+
193
+ lines.push({
194
+ hash: currentHash,
195
+ shortHash: currentHash.slice(0, 7),
196
+ author: currentAuthor,
197
+ authorTime: currentAuthorTime,
198
+ relativeDate: relativeDate,
199
+ summary: currentSummary,
200
+ lineNumber: currentOrigLine,
201
+ finalLineNumber: currentFinalLine,
202
+ content: content,
203
+ });
204
+ }
205
+ }
206
+
207
+ return lines;
208
+ }
209
+
210
+ /**
211
+ * Format a unix timestamp as a relative date string
212
+ */
213
+ function formatRelativeDate(timestamp: number): string {
214
+ const now = Math.floor(Date.now() / 1000);
215
+ const diff = now - timestamp;
216
+
217
+ if (diff < 60) {
218
+ return "just now";
219
+ } else if (diff < 3600) {
220
+ const mins = Math.floor(diff / 60);
221
+ return `${mins} minute${mins > 1 ? "s" : ""} ago`;
222
+ } else if (diff < 86400) {
223
+ const hours = Math.floor(diff / 3600);
224
+ return `${hours} hour${hours > 1 ? "s" : ""} ago`;
225
+ } else if (diff < 604800) {
226
+ const days = Math.floor(diff / 86400);
227
+ return `${days} day${days > 1 ? "s" : ""} ago`;
228
+ } else if (diff < 2592000) {
229
+ const weeks = Math.floor(diff / 604800);
230
+ return `${weeks} week${weeks > 1 ? "s" : ""} ago`;
231
+ } else if (diff < 31536000) {
232
+ const months = Math.floor(diff / 2592000);
233
+ return `${months} month${months > 1 ? "s" : ""} ago`;
234
+ } else {
235
+ const years = Math.floor(diff / 31536000);
236
+ return `${years} year${years > 1 ? "s" : ""} ago`;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Fetch file content at a specific commit (or HEAD)
242
+ */
243
+ async function fetchFileContent(filePath: string, commit: string | null): Promise<string> {
244
+ if (commit) {
245
+ // Get historical file content
246
+ const result = await editor.spawnProcess("git", ["show", `${commit}:${filePath}`]);
247
+ if (result.exit_code === 0) {
248
+ return result.stdout;
249
+ }
250
+ }
251
+
252
+ // Get current file content
253
+ const result = await editor.spawnProcess("cat", [filePath]);
254
+ return result.exit_code === 0 ? result.stdout : "";
255
+ }
256
+
257
+ /**
258
+ * Build line byte offset lookup table
259
+ */
260
+ function buildLineByteOffsets(content: string): number[] {
261
+ const offsets: number[] = [0]; // Line 1 starts at byte 0
262
+ let byteOffset = 0;
263
+
264
+ for (const char of content) {
265
+ byteOffset += char.length; // In JS strings, each char is at least 1
266
+ if (char === '\n') {
267
+ offsets.push(byteOffset);
268
+ }
269
+ }
270
+
271
+ return offsets;
272
+ }
273
+
274
+ /**
275
+ * Get byte offset for a given line number (1-indexed)
276
+ */
277
+ function getLineByteOffset(lineNum: number): number {
278
+ if (lineNum <= 0) return 0;
279
+ const idx = lineNum - 1;
280
+ if (idx < blameState.lineByteOffsets.length) {
281
+ return blameState.lineByteOffsets[idx];
282
+ }
283
+ // Return end of file if line number is out of range
284
+ return blameState.fileContent.length;
285
+ }
286
+
287
+ /**
288
+ * Group blame lines into blocks by commit, with byte offset information
289
+ */
290
+ function groupIntoBlocks(lines: BlameLine[]): BlameBlock[] {
291
+ const blocks: BlameBlock[] = [];
292
+ let currentBlock: BlameBlock | null = null;
293
+
294
+ for (const line of lines) {
295
+ // Check if we need to start a new block
296
+ if (!currentBlock || currentBlock.hash !== line.hash) {
297
+ // Save previous block
298
+ if (currentBlock && currentBlock.lines.length > 0) {
299
+ currentBlock.endByte = getLineByteOffset(currentBlock.endLine + 1);
300
+ blocks.push(currentBlock);
301
+ }
302
+
303
+ // Start new block
304
+ currentBlock = {
305
+ hash: line.hash,
306
+ shortHash: line.shortHash,
307
+ author: line.author,
308
+ relativeDate: line.relativeDate,
309
+ summary: line.summary,
310
+ lines: [],
311
+ startLine: line.finalLineNumber,
312
+ endLine: line.finalLineNumber,
313
+ startByte: getLineByteOffset(line.finalLineNumber),
314
+ endByte: 0, // Will be set when block is complete
315
+ };
316
+ }
317
+
318
+ currentBlock.lines.push(line);
319
+ currentBlock.endLine = line.finalLineNumber;
320
+ }
321
+
322
+ // Don't forget the last block
323
+ if (currentBlock && currentBlock.lines.length > 0) {
324
+ currentBlock.endByte = getLineByteOffset(currentBlock.endLine + 1);
325
+ blocks.push(currentBlock);
326
+ }
327
+
328
+ return blocks;
329
+ }
330
+
331
+ // =============================================================================
332
+ // Virtual Lines (Emacs-like persistent state model)
333
+ // =============================================================================
334
+
335
+ const BLAME_NAMESPACE = "git-blame";
336
+
337
+ /**
338
+ * Format a header line for a blame block
339
+ */
340
+ function formatBlockHeader(block: BlameBlock): string {
341
+ // Truncate summary if too long
342
+ const maxSummaryLen = 50;
343
+ const summary = block.summary.length > maxSummaryLen
344
+ ? block.summary.slice(0, maxSummaryLen - 3) + "..."
345
+ : block.summary;
346
+
347
+ return `── ${block.shortHash} (${block.author}, ${block.relativeDate}) "${summary}" ──`;
348
+ }
349
+
350
+ /**
351
+ * Find which block (if any) starts at or before the given byte offset
352
+ */
353
+ function findBlockForByteOffset(byteOffset: number): BlameBlock | null {
354
+ for (const block of blameState.blocks) {
355
+ if (byteOffset >= block.startByte && byteOffset < block.endByte) {
356
+ return block;
357
+ }
358
+ }
359
+ return null;
360
+ }
361
+
362
+ /**
363
+ * Add virtual lines for all blame block headers
364
+ * Called when blame data is loaded or updated
365
+ */
366
+ function addBlameHeaders(): void {
367
+ if (blameState.bufferId === null) return;
368
+
369
+ // Clear existing headers first
370
+ editor.clearVirtualTextNamespace(blameState.bufferId, BLAME_NAMESPACE);
371
+
372
+ // Add a virtual line above each block
373
+ for (const block of blameState.blocks) {
374
+ const headerText = formatBlockHeader(block);
375
+
376
+ editor.addVirtualLine(
377
+ blameState.bufferId,
378
+ block.startByte, // anchor position
379
+ headerText, // text content
380
+ colors.headerFg[0], // r
381
+ colors.headerFg[1], // g
382
+ colors.headerFg[2], // b
383
+ true, // above (LineAbove)
384
+ BLAME_NAMESPACE, // namespace for bulk removal
385
+ 0 // priority
386
+ );
387
+ }
388
+
389
+ editor.debug(`Added ${blameState.blocks.length} blame header virtual lines`);
390
+ }
391
+
392
+ // =============================================================================
393
+ // Public Commands
394
+ // =============================================================================
395
+
396
+ /**
397
+ * Show git blame for the current file
398
+ */
399
+ globalThis.show_git_blame = async function(): Promise<void> {
400
+ if (blameState.isOpen) {
401
+ editor.setStatus("Git blame already open");
402
+ return;
403
+ }
404
+
405
+ // Get current file path
406
+ const activeBufferId = editor.getActiveBufferId();
407
+ const filePath = editor.getBufferPath(activeBufferId);
408
+ if (!filePath || filePath === "") {
409
+ editor.setStatus("No file open to blame");
410
+ return;
411
+ }
412
+
413
+ editor.setStatus("Loading git blame...");
414
+
415
+ // Store state before opening blame
416
+ blameState.splitId = editor.getActiveSplitId();
417
+ blameState.sourceBufferId = activeBufferId;
418
+ blameState.sourceFilePath = filePath;
419
+ blameState.currentCommit = null;
420
+ blameState.commitStack = [];
421
+
422
+ // Fetch file content and blame data in parallel
423
+ const [fileContent, blameLines] = await Promise.all([
424
+ fetchFileContent(filePath, null),
425
+ fetchGitBlame(filePath, null),
426
+ ]);
427
+
428
+ if (blameLines.length === 0) {
429
+ editor.setStatus("No blame information available (not a git file or error)");
430
+ resetState();
431
+ return;
432
+ }
433
+
434
+ // Store file content and build line offset table
435
+ blameState.fileContent = fileContent;
436
+ blameState.lineByteOffsets = buildLineByteOffsets(fileContent);
437
+
438
+ // Group into blocks with byte offsets
439
+ blameState.blocks = groupIntoBlocks(blameLines);
440
+
441
+ // Get file extension for language detection
442
+ const ext = filePath.includes('.') ? filePath.split('.').pop() : '';
443
+ const bufferName = `*blame:${editor.pathBasename(filePath)}*`;
444
+
445
+ // Create virtual buffer with PURE file content (for syntax highlighting)
446
+ // Virtual lines will be added after buffer creation
447
+ const entries: TextPropertyEntry[] = [];
448
+
449
+ // We need to track which line belongs to which block for text properties
450
+ let lineNum = 1;
451
+ const contentLines = fileContent.split('\n');
452
+ let byteOffset = 0;
453
+
454
+ for (const line of contentLines) {
455
+ // Find the block for this line
456
+ const block = findBlockForByteOffset(byteOffset);
457
+
458
+ entries.push({
459
+ text: line + (lineNum < contentLines.length || fileContent.endsWith('\n') ? '\n' : ''),
460
+ properties: {
461
+ type: "content",
462
+ hash: block?.hash ?? null,
463
+ shortHash: block?.shortHash ?? null,
464
+ lineNumber: lineNum,
465
+ },
466
+ });
467
+
468
+ byteOffset += line.length + 1; // +1 for newline
469
+ lineNum++;
470
+ }
471
+
472
+ // Create virtual buffer with the file content
473
+ const bufferId = await editor.createVirtualBufferInExistingSplit({
474
+ name: bufferName,
475
+ mode: "git-blame",
476
+ read_only: true,
477
+ entries: entries,
478
+ split_id: blameState.splitId!,
479
+ show_line_numbers: true, // We DO want line numbers (headers won't have them due to source_offset: null)
480
+ show_cursors: true,
481
+ editing_disabled: true,
482
+ });
483
+
484
+ if (bufferId !== null) {
485
+ blameState.isOpen = true;
486
+ blameState.bufferId = bufferId;
487
+
488
+ // Add virtual lines for blame headers (persistent state model)
489
+ addBlameHeaders();
490
+
491
+ editor.setStatus(`Git blame: ${blameState.blocks.length} blocks | b: blame at parent | q: close`);
492
+ editor.debug("Git blame panel opened with virtual lines architecture");
493
+ } else {
494
+ resetState();
495
+ editor.setStatus("Failed to open git blame panel");
496
+ }
497
+ };
498
+
499
+ /**
500
+ * Reset blame state
501
+ */
502
+ function resetState(): void {
503
+ blameState.splitId = null;
504
+ blameState.sourceBufferId = null;
505
+ blameState.sourceFilePath = null;
506
+ blameState.currentCommit = null;
507
+ blameState.commitStack = [];
508
+ blameState.blocks = [];
509
+ blameState.fileContent = "";
510
+ blameState.lineByteOffsets = [];
511
+ }
512
+
513
+ /**
514
+ * Close the git blame view
515
+ */
516
+ globalThis.git_blame_close = function(): void {
517
+ if (!blameState.isOpen) {
518
+ return;
519
+ }
520
+
521
+ // Restore the original buffer in the split
522
+ if (blameState.splitId !== null && blameState.sourceBufferId !== null) {
523
+ editor.setSplitBuffer(blameState.splitId, blameState.sourceBufferId);
524
+ }
525
+
526
+ // Close the blame buffer
527
+ if (blameState.bufferId !== null) {
528
+ editor.closeBuffer(blameState.bufferId);
529
+ }
530
+
531
+ blameState.isOpen = false;
532
+ blameState.bufferId = null;
533
+ resetState();
534
+
535
+ editor.setStatus("Git blame closed");
536
+ };
537
+
538
+ /**
539
+ * Get the commit hash at the current cursor position
540
+ */
541
+ function getCommitAtCursor(): string | null {
542
+ if (blameState.bufferId === null) return null;
543
+
544
+ const props = editor.getTextPropertiesAtCursor(blameState.bufferId);
545
+
546
+ if (props.length > 0) {
547
+ const hash = props[0].hash as string | undefined;
548
+ if (hash) {
549
+ return hash;
550
+ }
551
+ }
552
+
553
+ return null;
554
+ }
555
+
556
+ /**
557
+ * Navigate to blame at the parent commit of the current line's commit
558
+ */
559
+ globalThis.git_blame_go_back = async function(): Promise<void> {
560
+ if (!blameState.isOpen || !blameState.sourceFilePath) {
561
+ return;
562
+ }
563
+
564
+ const currentHash = getCommitAtCursor();
565
+ if (!currentHash) {
566
+ editor.setStatus("Move cursor to a blame line first");
567
+ return;
568
+ }
569
+
570
+ // Skip if this is the "not committed yet" hash (all zeros)
571
+ if (currentHash === "0000000000000000000000000000000000000000") {
572
+ editor.setStatus("This line is not yet committed");
573
+ return;
574
+ }
575
+
576
+ editor.setStatus(`Loading blame at ${currentHash.slice(0, 7)}^...`);
577
+
578
+ // Get the parent commit
579
+ const parentCommit = `${currentHash}^`;
580
+
581
+ // Push current state to stack for potential future navigation
582
+ if (blameState.currentCommit) {
583
+ blameState.commitStack.push(blameState.currentCommit);
584
+ } else {
585
+ blameState.commitStack.push("HEAD");
586
+ }
587
+
588
+ // Fetch file content and blame at parent commit
589
+ const [fileContent, blameLines] = await Promise.all([
590
+ fetchFileContent(blameState.sourceFilePath, parentCommit),
591
+ fetchGitBlame(blameState.sourceFilePath, parentCommit),
592
+ ]);
593
+
594
+ if (blameLines.length === 0) {
595
+ // Pop the stack since we couldn't navigate
596
+ blameState.commitStack.pop();
597
+ editor.setStatus(`Cannot get blame at ${currentHash.slice(0, 7)}^ (may be initial commit or file didn't exist)`);
598
+ return;
599
+ }
600
+
601
+ // Update state
602
+ blameState.currentCommit = parentCommit;
603
+ blameState.fileContent = fileContent;
604
+ blameState.lineByteOffsets = buildLineByteOffsets(fileContent);
605
+ blameState.blocks = groupIntoBlocks(blameLines);
606
+
607
+ // Update virtual buffer content
608
+ if (blameState.bufferId !== null) {
609
+ const entries: TextPropertyEntry[] = [];
610
+ let lineNum = 1;
611
+ const contentLines = fileContent.split('\n');
612
+ let byteOffset = 0;
613
+
614
+ for (const line of contentLines) {
615
+ const block = findBlockForByteOffset(byteOffset);
616
+
617
+ entries.push({
618
+ text: line + (lineNum < contentLines.length || fileContent.endsWith('\n') ? '\n' : ''),
619
+ properties: {
620
+ type: "content",
621
+ hash: block?.hash ?? null,
622
+ shortHash: block?.shortHash ?? null,
623
+ lineNumber: lineNum,
624
+ },
625
+ });
626
+
627
+ byteOffset += line.length + 1;
628
+ lineNum++;
629
+ }
630
+
631
+ editor.setVirtualBufferContent(blameState.bufferId, entries);
632
+
633
+ // Re-add virtual lines for the new blame data
634
+ addBlameHeaders();
635
+ }
636
+
637
+ const depth = blameState.commitStack.length;
638
+ editor.setStatus(`Git blame at ${currentHash.slice(0, 7)}^ | depth: ${depth} | b: go deeper | q: close`);
639
+ };
640
+
641
+ /**
642
+ * Copy the commit hash at cursor to clipboard
643
+ */
644
+ globalThis.git_blame_copy_hash = function(): void {
645
+ if (!blameState.isOpen) return;
646
+
647
+ const hash = getCommitAtCursor();
648
+ if (!hash) {
649
+ editor.setStatus("Move cursor to a blame line first");
650
+ return;
651
+ }
652
+
653
+ // Skip if this is the "not committed yet" hash
654
+ if (hash === "0000000000000000000000000000000000000000") {
655
+ editor.setStatus("This line is not yet committed");
656
+ return;
657
+ }
658
+
659
+ // Use spawn to copy to clipboard
660
+ 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`])
661
+ .then(() => {
662
+ editor.setStatus(`Copied: ${hash.slice(0, 7)} (${hash})`);
663
+ })
664
+ .catch(() => {
665
+ editor.setStatus(`Hash: ${hash}`);
666
+ });
667
+ };
668
+
669
+ // =============================================================================
670
+ // Command Registration
671
+ // =============================================================================
672
+
673
+ editor.registerCommand(
674
+ "Git Blame",
675
+ "Show git blame for current file (magit-style)",
676
+ "show_git_blame",
677
+ "normal"
678
+ );
679
+
680
+ editor.registerCommand(
681
+ "Git Blame: Close",
682
+ "Close the git blame panel",
683
+ "git_blame_close",
684
+ "normal"
685
+ );
686
+
687
+ editor.registerCommand(
688
+ "Git Blame: Go Back",
689
+ "Show blame at parent commit of current line",
690
+ "git_blame_go_back",
691
+ "normal"
692
+ );
693
+
694
+ // =============================================================================
695
+ // Plugin Initialization
696
+ // =============================================================================
697
+
698
+ editor.setStatus("Git Blame plugin loaded (virtual lines architecture)");
699
+ editor.debug("Git Blame plugin initialized - Use 'Git Blame' command to open");
@@ -403,6 +403,20 @@ interface EditorAPI {
403
403
  * @returns true if successful
404
404
  */
405
405
  setLineNumbers(buffer_id: number, enabled: boolean): boolean;
406
+ /**
407
+ * Add a virtual line above or below a source line
408
+ * @param buffer_id - The buffer ID
409
+ * @param position - Byte position to anchor the virtual line to
410
+ * @param text - The text content of the virtual line
411
+ * @param r - Red color component (0-255)
412
+ * @param g - Green color component (0-255)
413
+ * @param b - Blue color component (0-255)
414
+ * @param above - Whether to insert above (true) or below (false) the line
415
+ * @param namespace - Namespace for bulk removal (e.g., "git-blame")
416
+ * @param priority - Priority for ordering multiple lines at same position
417
+ * @returns true if virtual line was added
418
+ */
419
+ addVirtualLine(buffer_id: number, position: number, text: string, r: number, g: number, b: number, above: boolean, namespace: string, priority: number): boolean;
406
420
  /**
407
421
  * Submit a transformed view stream for a viewport
408
422
  * @param buffer_id - Buffer to apply the transform to
@@ -580,6 +594,13 @@ interface EditorAPI {
580
594
  * @returns true if virtual texts were cleared
581
595
  */
582
596
  clearVirtualTexts(buffer_id: number): boolean;
597
+ /**
598
+ * Clear all virtual texts in a namespace
599
+ * @param buffer_id - The buffer ID
600
+ * @param namespace - The namespace to clear (e.g., "git-blame")
601
+ * @returns true if namespace was cleared
602
+ */
603
+ clearVirtualTextNamespace(buffer_id: number, namespace: string): boolean;
583
604
  /**
584
605
  * Force a refresh of line display for a buffer
585
606
  * @param buffer_id - The buffer ID
@@ -0,0 +1,232 @@
1
+ /// <reference path="../types/fresh.d.ts" />
2
+
3
+ /**
4
+ * Test plugin for virtual lines (Emacs-like persistent state model).
5
+ *
6
+ * This plugin demonstrates the virtual lines API by injecting header lines
7
+ * above content. Virtual lines are added to persistent state and rendered
8
+ * synchronously from memory - no view transform hooks needed.
9
+ */
10
+
11
+ let activeBufferId: number | null = null;
12
+ const padLinesByBuffer = new Map<number, number>();
13
+ const interleavedModeByBuffer = new Map<number, boolean>();
14
+
15
+ const TEST_NAMESPACE = "test-view-marker";
16
+
17
+ // Define a simple mode for testing
18
+ editor.defineMode(
19
+ "test-view-marker",
20
+ "normal",
21
+ [
22
+ ["q", "test_view_marker_close"],
23
+ ],
24
+ true // read-only
25
+ );
26
+
27
+ /**
28
+ * Add virtual header lines for a buffer
29
+ */
30
+ function addTestHeaders(bufferId: number): void {
31
+ const padLines = padLinesByBuffer.get(bufferId) ?? 0;
32
+ const interleavedMode = interleavedModeByBuffer.get(bufferId) ?? false;
33
+
34
+ // Clear existing headers first
35
+ editor.clearVirtualTextNamespace(bufferId, TEST_NAMESPACE);
36
+
37
+ if (interleavedMode) {
38
+ // Interleaved mode: add a header above each source line
39
+ const content = "Line 1\nLine 2\nLine 3\n";
40
+ let byteOffset = 0;
41
+ let lineNum = 1;
42
+
43
+ for (const line of content.split('\n')) {
44
+ if (line.length === 0 && byteOffset >= content.length - 1) break;
45
+
46
+ // Add header above this line
47
+ editor.addVirtualLine(
48
+ bufferId,
49
+ byteOffset,
50
+ `── Header before line ${lineNum} ──`,
51
+ 200, 200, 100, // Yellow-ish
52
+ true, // above
53
+ TEST_NAMESPACE,
54
+ 0
55
+ );
56
+
57
+ byteOffset += line.length + 1; // +1 for newline
58
+ lineNum++;
59
+ }
60
+
61
+ // Also add initial header at byte 0
62
+ editor.addVirtualLine(
63
+ bufferId,
64
+ 0,
65
+ "== INTERLEAVED HEADER ==",
66
+ 255, 255, 0, // Yellow
67
+ true, // above
68
+ TEST_NAMESPACE,
69
+ -1 // lower priority to appear first
70
+ );
71
+ } else {
72
+ // Simple mode: just one header at byte 0
73
+ editor.addVirtualLine(
74
+ bufferId,
75
+ 0,
76
+ "== HEADER AT BYTE 0 ==",
77
+ 255, 255, 0, // Yellow
78
+ true, // above
79
+ TEST_NAMESPACE,
80
+ 0
81
+ );
82
+
83
+ // Optionally add many pad lines (for scroll stress tests)
84
+ for (let i = 0; i < padLines; i++) {
85
+ editor.addVirtualLine(
86
+ bufferId,
87
+ 0,
88
+ `Virtual pad ${i + 1}`,
89
+ 180, 180, 180, // Light gray
90
+ true, // above
91
+ TEST_NAMESPACE,
92
+ i + 1 // increasing priority so they appear in order after header
93
+ );
94
+ }
95
+ }
96
+
97
+ editor.debug(`[test_view_marker] added ${interleavedMode ? 'interleaved' : 'simple'} headers with ${padLines} pad lines`);
98
+ }
99
+
100
+ async function open_test_view_marker(padLines: number, name: string): Promise<void> {
101
+ const splitId = editor.getActiveSplitId();
102
+
103
+ editor.debug(
104
+ `[test_view_marker] opening view marker in split ${splitId} with ${padLines} pad lines`
105
+ );
106
+
107
+ // Create virtual buffer with simple hardcoded content
108
+ const entries: TextPropertyEntry[] = [
109
+ { text: "Line 1\n", properties: { type: "content", line: 1 } },
110
+ { text: "Line 2\n", properties: { type: "content", line: 2 } },
111
+ { text: "Line 3\n", properties: { type: "content", line: 3 } },
112
+ ];
113
+
114
+ const bufferId = await editor.createVirtualBufferInExistingSplit({
115
+ name,
116
+ mode: "test-view-marker",
117
+ read_only: true,
118
+ entries,
119
+ split_id: splitId,
120
+ show_line_numbers: true,
121
+ show_cursors: true,
122
+ editing_disabled: true,
123
+ });
124
+
125
+ if (bufferId !== null) {
126
+ activeBufferId = bufferId;
127
+ padLinesByBuffer.set(bufferId, padLines);
128
+ interleavedModeByBuffer.set(bufferId, false);
129
+
130
+ // Add virtual header lines
131
+ addTestHeaders(bufferId);
132
+
133
+ editor.debug(
134
+ `[test_view_marker] buffer created with id ${bufferId}, padLines=${padLines}`
135
+ );
136
+ editor.setStatus("Test view marker active - press q to close");
137
+ } else {
138
+ editor.debug(`[test_view_marker] failed to create buffer`);
139
+ editor.setStatus("Failed to create test view marker buffer");
140
+ }
141
+ }
142
+
143
+ async function open_test_view_marker_interleaved(name: string): Promise<void> {
144
+ const splitId = editor.getActiveSplitId();
145
+
146
+ editor.debug(`[test_view_marker] opening interleaved view marker in split ${splitId}`);
147
+
148
+ // Create virtual buffer with simple hardcoded content
149
+ const entries: TextPropertyEntry[] = [
150
+ { text: "Line 1\n", properties: { type: "content", line: 1 } },
151
+ { text: "Line 2\n", properties: { type: "content", line: 2 } },
152
+ { text: "Line 3\n", properties: { type: "content", line: 3 } },
153
+ ];
154
+
155
+ const bufferId = await editor.createVirtualBufferInExistingSplit({
156
+ name,
157
+ mode: "test-view-marker",
158
+ read_only: true,
159
+ entries,
160
+ split_id: splitId,
161
+ show_line_numbers: true,
162
+ show_cursors: true,
163
+ editing_disabled: true,
164
+ });
165
+
166
+ if (bufferId !== null) {
167
+ activeBufferId = bufferId;
168
+ padLinesByBuffer.set(bufferId, 0);
169
+ interleavedModeByBuffer.set(bufferId, true);
170
+
171
+ // Add virtual header lines in interleaved mode
172
+ addTestHeaders(bufferId);
173
+
174
+ editor.debug(`[test_view_marker] interleaved buffer created with id ${bufferId}`);
175
+ editor.setStatus("Test view marker (interleaved) active - press q to close");
176
+ } else {
177
+ editor.debug(`[test_view_marker] failed to create buffer`);
178
+ editor.setStatus("Failed to create test view marker buffer");
179
+ }
180
+ }
181
+
182
+ globalThis.show_test_view_marker = async function(): Promise<void> {
183
+ await open_test_view_marker(0, "*test-view-marker*");
184
+ };
185
+
186
+ globalThis.show_test_view_marker_many_virtual_lines = async function(): Promise<void> {
187
+ await open_test_view_marker(120, "*test-view-marker-many*");
188
+ };
189
+
190
+ globalThis.show_test_view_marker_interleaved = async function(): Promise<void> {
191
+ await open_test_view_marker_interleaved("*test-view-marker-interleaved*");
192
+ };
193
+
194
+ /**
195
+ * Close the test view marker
196
+ */
197
+ globalThis.test_view_marker_close = function(): void {
198
+ if (activeBufferId !== null) {
199
+ // Clear virtual lines before closing
200
+ editor.clearVirtualTextNamespace(activeBufferId, TEST_NAMESPACE);
201
+
202
+ editor.closeBuffer(activeBufferId);
203
+ padLinesByBuffer.delete(activeBufferId);
204
+ interleavedModeByBuffer.delete(activeBufferId);
205
+ activeBufferId = null;
206
+ editor.setStatus("Test view marker closed");
207
+ }
208
+ };
209
+
210
+ // Register command
211
+ editor.registerCommand(
212
+ "Test View Marker",
213
+ "Test virtual lines with header at byte 0",
214
+ "show_test_view_marker",
215
+ "normal"
216
+ );
217
+
218
+ editor.registerCommand(
219
+ "Test View Marker (Many Virtual Lines)",
220
+ "Test virtual lines with many header lines",
221
+ "show_test_view_marker_many_virtual_lines",
222
+ "normal"
223
+ );
224
+
225
+ editor.registerCommand(
226
+ "Test View Marker (Interleaved)",
227
+ "Test virtual lines with headers between each source line",
228
+ "show_test_view_marker_interleaved",
229
+ "normal"
230
+ );
231
+
232
+ editor.setStatus("Test View Marker plugin loaded (virtual lines)");