@fresh-editor/fresh-editor 0.1.4 → 0.1.6

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.6"
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.6"
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.6",
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.6",
96
96
  "volta": {
97
97
  "node": "18.14.1",
98
98
  "npm": "9.5.0"
@@ -0,0 +1,702 @@
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: [0, 0, 0] as [number, number, number], // Black text
90
+ headerBg: [200, 200, 200] as [number, number, number], // Light 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], // fg_r
381
+ colors.headerFg[1], // fg_g
382
+ colors.headerFg[2], // fg_b
383
+ colors.headerBg[0], // bg_r
384
+ colors.headerBg[1], // bg_g
385
+ colors.headerBg[2], // bg_b
386
+ true, // above (LineAbove)
387
+ BLAME_NAMESPACE, // namespace for bulk removal
388
+ 0 // priority
389
+ );
390
+ }
391
+
392
+ editor.debug(`Added ${blameState.blocks.length} blame header virtual lines`);
393
+ }
394
+
395
+ // =============================================================================
396
+ // Public Commands
397
+ // =============================================================================
398
+
399
+ /**
400
+ * Show git blame for the current file
401
+ */
402
+ globalThis.show_git_blame = async function(): Promise<void> {
403
+ if (blameState.isOpen) {
404
+ editor.setStatus("Git blame already open");
405
+ return;
406
+ }
407
+
408
+ // Get current file path
409
+ const activeBufferId = editor.getActiveBufferId();
410
+ const filePath = editor.getBufferPath(activeBufferId);
411
+ if (!filePath || filePath === "") {
412
+ editor.setStatus("No file open to blame");
413
+ return;
414
+ }
415
+
416
+ editor.setStatus("Loading git blame...");
417
+
418
+ // Store state before opening blame
419
+ blameState.splitId = editor.getActiveSplitId();
420
+ blameState.sourceBufferId = activeBufferId;
421
+ blameState.sourceFilePath = filePath;
422
+ blameState.currentCommit = null;
423
+ blameState.commitStack = [];
424
+
425
+ // Fetch file content and blame data in parallel
426
+ const [fileContent, blameLines] = await Promise.all([
427
+ fetchFileContent(filePath, null),
428
+ fetchGitBlame(filePath, null),
429
+ ]);
430
+
431
+ if (blameLines.length === 0) {
432
+ editor.setStatus("No blame information available (not a git file or error)");
433
+ resetState();
434
+ return;
435
+ }
436
+
437
+ // Store file content and build line offset table
438
+ blameState.fileContent = fileContent;
439
+ blameState.lineByteOffsets = buildLineByteOffsets(fileContent);
440
+
441
+ // Group into blocks with byte offsets
442
+ blameState.blocks = groupIntoBlocks(blameLines);
443
+
444
+ // Get file extension for language detection
445
+ const ext = filePath.includes('.') ? filePath.split('.').pop() : '';
446
+ const bufferName = `*blame:${editor.pathBasename(filePath)}*`;
447
+
448
+ // Create virtual buffer with PURE file content (for syntax highlighting)
449
+ // Virtual lines will be added after buffer creation
450
+ const entries: TextPropertyEntry[] = [];
451
+
452
+ // We need to track which line belongs to which block for text properties
453
+ let lineNum = 1;
454
+ const contentLines = fileContent.split('\n');
455
+ let byteOffset = 0;
456
+
457
+ for (const line of contentLines) {
458
+ // Find the block for this line
459
+ const block = findBlockForByteOffset(byteOffset);
460
+
461
+ entries.push({
462
+ text: line + (lineNum < contentLines.length || fileContent.endsWith('\n') ? '\n' : ''),
463
+ properties: {
464
+ type: "content",
465
+ hash: block?.hash ?? null,
466
+ shortHash: block?.shortHash ?? null,
467
+ lineNumber: lineNum,
468
+ },
469
+ });
470
+
471
+ byteOffset += line.length + 1; // +1 for newline
472
+ lineNum++;
473
+ }
474
+
475
+ // Create virtual buffer with the file content
476
+ const bufferId = await editor.createVirtualBufferInExistingSplit({
477
+ name: bufferName,
478
+ mode: "git-blame",
479
+ read_only: true,
480
+ entries: entries,
481
+ split_id: blameState.splitId!,
482
+ show_line_numbers: true, // We DO want line numbers (headers won't have them due to source_offset: null)
483
+ show_cursors: true,
484
+ editing_disabled: true,
485
+ });
486
+
487
+ if (bufferId !== null) {
488
+ blameState.isOpen = true;
489
+ blameState.bufferId = bufferId;
490
+
491
+ // Add virtual lines for blame headers (persistent state model)
492
+ addBlameHeaders();
493
+
494
+ editor.setStatus(`Git blame: ${blameState.blocks.length} blocks | b: blame at parent | q: close`);
495
+ editor.debug("Git blame panel opened with virtual lines architecture");
496
+ } else {
497
+ resetState();
498
+ editor.setStatus("Failed to open git blame panel");
499
+ }
500
+ };
501
+
502
+ /**
503
+ * Reset blame state
504
+ */
505
+ function resetState(): void {
506
+ blameState.splitId = null;
507
+ blameState.sourceBufferId = null;
508
+ blameState.sourceFilePath = null;
509
+ blameState.currentCommit = null;
510
+ blameState.commitStack = [];
511
+ blameState.blocks = [];
512
+ blameState.fileContent = "";
513
+ blameState.lineByteOffsets = [];
514
+ }
515
+
516
+ /**
517
+ * Close the git blame view
518
+ */
519
+ globalThis.git_blame_close = function(): void {
520
+ if (!blameState.isOpen) {
521
+ return;
522
+ }
523
+
524
+ // Restore the original buffer in the split
525
+ if (blameState.splitId !== null && blameState.sourceBufferId !== null) {
526
+ editor.setSplitBuffer(blameState.splitId, blameState.sourceBufferId);
527
+ }
528
+
529
+ // Close the blame buffer
530
+ if (blameState.bufferId !== null) {
531
+ editor.closeBuffer(blameState.bufferId);
532
+ }
533
+
534
+ blameState.isOpen = false;
535
+ blameState.bufferId = null;
536
+ resetState();
537
+
538
+ editor.setStatus("Git blame closed");
539
+ };
540
+
541
+ /**
542
+ * Get the commit hash at the current cursor position
543
+ */
544
+ function getCommitAtCursor(): string | null {
545
+ if (blameState.bufferId === null) return null;
546
+
547
+ const props = editor.getTextPropertiesAtCursor(blameState.bufferId);
548
+
549
+ if (props.length > 0) {
550
+ const hash = props[0].hash as string | undefined;
551
+ if (hash) {
552
+ return hash;
553
+ }
554
+ }
555
+
556
+ return null;
557
+ }
558
+
559
+ /**
560
+ * Navigate to blame at the parent commit of the current line's commit
561
+ */
562
+ globalThis.git_blame_go_back = async function(): Promise<void> {
563
+ if (!blameState.isOpen || !blameState.sourceFilePath) {
564
+ return;
565
+ }
566
+
567
+ const currentHash = getCommitAtCursor();
568
+ if (!currentHash) {
569
+ editor.setStatus("Move cursor to a blame line first");
570
+ return;
571
+ }
572
+
573
+ // Skip if this is the "not committed yet" hash (all zeros)
574
+ if (currentHash === "0000000000000000000000000000000000000000") {
575
+ editor.setStatus("This line is not yet committed");
576
+ return;
577
+ }
578
+
579
+ editor.setStatus(`Loading blame at ${currentHash.slice(0, 7)}^...`);
580
+
581
+ // Get the parent commit
582
+ const parentCommit = `${currentHash}^`;
583
+
584
+ // Push current state to stack for potential future navigation
585
+ if (blameState.currentCommit) {
586
+ blameState.commitStack.push(blameState.currentCommit);
587
+ } else {
588
+ blameState.commitStack.push("HEAD");
589
+ }
590
+
591
+ // Fetch file content and blame at parent commit
592
+ const [fileContent, blameLines] = await Promise.all([
593
+ fetchFileContent(blameState.sourceFilePath, parentCommit),
594
+ fetchGitBlame(blameState.sourceFilePath, parentCommit),
595
+ ]);
596
+
597
+ if (blameLines.length === 0) {
598
+ // Pop the stack since we couldn't navigate
599
+ blameState.commitStack.pop();
600
+ editor.setStatus(`Cannot get blame at ${currentHash.slice(0, 7)}^ (may be initial commit or file didn't exist)`);
601
+ return;
602
+ }
603
+
604
+ // Update state
605
+ blameState.currentCommit = parentCommit;
606
+ blameState.fileContent = fileContent;
607
+ blameState.lineByteOffsets = buildLineByteOffsets(fileContent);
608
+ blameState.blocks = groupIntoBlocks(blameLines);
609
+
610
+ // Update virtual buffer content
611
+ if (blameState.bufferId !== null) {
612
+ const entries: TextPropertyEntry[] = [];
613
+ let lineNum = 1;
614
+ const contentLines = fileContent.split('\n');
615
+ let byteOffset = 0;
616
+
617
+ for (const line of contentLines) {
618
+ const block = findBlockForByteOffset(byteOffset);
619
+
620
+ entries.push({
621
+ text: line + (lineNum < contentLines.length || fileContent.endsWith('\n') ? '\n' : ''),
622
+ properties: {
623
+ type: "content",
624
+ hash: block?.hash ?? null,
625
+ shortHash: block?.shortHash ?? null,
626
+ lineNumber: lineNum,
627
+ },
628
+ });
629
+
630
+ byteOffset += line.length + 1;
631
+ lineNum++;
632
+ }
633
+
634
+ editor.setVirtualBufferContent(blameState.bufferId, entries);
635
+
636
+ // Re-add virtual lines for the new blame data
637
+ addBlameHeaders();
638
+ }
639
+
640
+ const depth = blameState.commitStack.length;
641
+ editor.setStatus(`Git blame at ${currentHash.slice(0, 7)}^ | depth: ${depth} | b: go deeper | q: close`);
642
+ };
643
+
644
+ /**
645
+ * Copy the commit hash at cursor to clipboard
646
+ */
647
+ globalThis.git_blame_copy_hash = function(): void {
648
+ if (!blameState.isOpen) return;
649
+
650
+ const hash = getCommitAtCursor();
651
+ if (!hash) {
652
+ editor.setStatus("Move cursor to a blame line first");
653
+ return;
654
+ }
655
+
656
+ // Skip if this is the "not committed yet" hash
657
+ if (hash === "0000000000000000000000000000000000000000") {
658
+ editor.setStatus("This line is not yet committed");
659
+ return;
660
+ }
661
+
662
+ // Use spawn to copy to clipboard
663
+ 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`])
664
+ .then(() => {
665
+ editor.setStatus(`Copied: ${hash.slice(0, 7)} (${hash})`);
666
+ })
667
+ .catch(() => {
668
+ editor.setStatus(`Hash: ${hash}`);
669
+ });
670
+ };
671
+
672
+ // =============================================================================
673
+ // Command Registration
674
+ // =============================================================================
675
+
676
+ editor.registerCommand(
677
+ "Git Blame",
678
+ "Show git blame for current file (magit-style)",
679
+ "show_git_blame",
680
+ "normal"
681
+ );
682
+
683
+ editor.registerCommand(
684
+ "Git Blame: Close",
685
+ "Close the git blame panel",
686
+ "git_blame_close",
687
+ "normal"
688
+ );
689
+
690
+ editor.registerCommand(
691
+ "Git Blame: Go Back",
692
+ "Show blame at parent commit of current line",
693
+ "git_blame_go_back",
694
+ "normal"
695
+ );
696
+
697
+ // =============================================================================
698
+ // Plugin Initialization
699
+ // =============================================================================
700
+
701
+ editor.setStatus("Git Blame plugin loaded (virtual lines architecture)");
702
+ editor.debug("Git Blame plugin initialized - Use 'Git Blame' command to open");
@@ -403,6 +403,23 @@ 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 fg_r - Foreground red color component (0-255)
412
+ * @param fg_g - Foreground green color component (0-255)
413
+ * @param fg_b - Foreground blue color component (0-255)
414
+ * @param bg_r - Background red color component (0-255), -1 for transparent
415
+ * @param bg_g - Background green color component (0-255), -1 for transparent
416
+ * @param bg_b - Background blue color component (0-255), -1 for transparent
417
+ * @param above - Whether to insert above (true) or below (false) the line
418
+ * @param namespace - Namespace for bulk removal (e.g., "git-blame")
419
+ * @param priority - Priority for ordering multiple lines at same position
420
+ * @returns true if virtual line was added
421
+ */
422
+ addVirtualLine(buffer_id: number, position: number, text: string, fg_r: number, fg_g: number, fg_b: number, bg_r: number, bg_g: number, bg_b: number, above: boolean, namespace: string, priority: number): boolean;
406
423
  /**
407
424
  * Submit a transformed view stream for a viewport
408
425
  * @param buffer_id - Buffer to apply the transform to
@@ -580,6 +597,13 @@ interface EditorAPI {
580
597
  * @returns true if virtual texts were cleared
581
598
  */
582
599
  clearVirtualTexts(buffer_id: number): boolean;
600
+ /**
601
+ * Clear all virtual texts in a namespace
602
+ * @param buffer_id - The buffer ID
603
+ * @param namespace - The namespace to clear (e.g., "git-blame")
604
+ * @returns true if namespace was cleared
605
+ */
606
+ clearVirtualTextNamespace(buffer_id: number, namespace: string): boolean;
583
607
  /**
584
608
  * Force a refresh of line display for a buffer
585
609
  * @param buffer_id - The buffer ID
@@ -0,0 +1,236 @@
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 fg
52
+ -1, -1, -1, // No background
53
+ true, // above
54
+ TEST_NAMESPACE,
55
+ 0
56
+ );
57
+
58
+ byteOffset += line.length + 1; // +1 for newline
59
+ lineNum++;
60
+ }
61
+
62
+ // Also add initial header at byte 0
63
+ editor.addVirtualLine(
64
+ bufferId,
65
+ 0,
66
+ "== INTERLEAVED HEADER ==",
67
+ 255, 255, 0, // Yellow fg
68
+ -1, -1, -1, // No background
69
+ true, // above
70
+ TEST_NAMESPACE,
71
+ -1 // lower priority to appear first
72
+ );
73
+ } else {
74
+ // Simple mode: just one header at byte 0
75
+ editor.addVirtualLine(
76
+ bufferId,
77
+ 0,
78
+ "== HEADER AT BYTE 0 ==",
79
+ 255, 255, 0, // Yellow fg
80
+ -1, -1, -1, // No background
81
+ true, // above
82
+ TEST_NAMESPACE,
83
+ 0
84
+ );
85
+
86
+ // Optionally add many pad lines (for scroll stress tests)
87
+ for (let i = 0; i < padLines; i++) {
88
+ editor.addVirtualLine(
89
+ bufferId,
90
+ 0,
91
+ `Virtual pad ${i + 1}`,
92
+ 180, 180, 180, // Light gray fg
93
+ -1, -1, -1, // No background
94
+ true, // above
95
+ TEST_NAMESPACE,
96
+ i + 1 // increasing priority so they appear in order after header
97
+ );
98
+ }
99
+ }
100
+
101
+ editor.debug(`[test_view_marker] added ${interleavedMode ? 'interleaved' : 'simple'} headers with ${padLines} pad lines`);
102
+ }
103
+
104
+ async function open_test_view_marker(padLines: number, name: string): Promise<void> {
105
+ const splitId = editor.getActiveSplitId();
106
+
107
+ editor.debug(
108
+ `[test_view_marker] opening view marker in split ${splitId} with ${padLines} pad lines`
109
+ );
110
+
111
+ // Create virtual buffer with simple hardcoded content
112
+ const entries: TextPropertyEntry[] = [
113
+ { text: "Line 1\n", properties: { type: "content", line: 1 } },
114
+ { text: "Line 2\n", properties: { type: "content", line: 2 } },
115
+ { text: "Line 3\n", properties: { type: "content", line: 3 } },
116
+ ];
117
+
118
+ const bufferId = await editor.createVirtualBufferInExistingSplit({
119
+ name,
120
+ mode: "test-view-marker",
121
+ read_only: true,
122
+ entries,
123
+ split_id: splitId,
124
+ show_line_numbers: true,
125
+ show_cursors: true,
126
+ editing_disabled: true,
127
+ });
128
+
129
+ if (bufferId !== null) {
130
+ activeBufferId = bufferId;
131
+ padLinesByBuffer.set(bufferId, padLines);
132
+ interleavedModeByBuffer.set(bufferId, false);
133
+
134
+ // Add virtual header lines
135
+ addTestHeaders(bufferId);
136
+
137
+ editor.debug(
138
+ `[test_view_marker] buffer created with id ${bufferId}, padLines=${padLines}`
139
+ );
140
+ editor.setStatus("Test view marker active - press q to close");
141
+ } else {
142
+ editor.debug(`[test_view_marker] failed to create buffer`);
143
+ editor.setStatus("Failed to create test view marker buffer");
144
+ }
145
+ }
146
+
147
+ async function open_test_view_marker_interleaved(name: string): Promise<void> {
148
+ const splitId = editor.getActiveSplitId();
149
+
150
+ editor.debug(`[test_view_marker] opening interleaved view marker in split ${splitId}`);
151
+
152
+ // Create virtual buffer with simple hardcoded content
153
+ const entries: TextPropertyEntry[] = [
154
+ { text: "Line 1\n", properties: { type: "content", line: 1 } },
155
+ { text: "Line 2\n", properties: { type: "content", line: 2 } },
156
+ { text: "Line 3\n", properties: { type: "content", line: 3 } },
157
+ ];
158
+
159
+ const bufferId = await editor.createVirtualBufferInExistingSplit({
160
+ name,
161
+ mode: "test-view-marker",
162
+ read_only: true,
163
+ entries,
164
+ split_id: splitId,
165
+ show_line_numbers: true,
166
+ show_cursors: true,
167
+ editing_disabled: true,
168
+ });
169
+
170
+ if (bufferId !== null) {
171
+ activeBufferId = bufferId;
172
+ padLinesByBuffer.set(bufferId, 0);
173
+ interleavedModeByBuffer.set(bufferId, true);
174
+
175
+ // Add virtual header lines in interleaved mode
176
+ addTestHeaders(bufferId);
177
+
178
+ editor.debug(`[test_view_marker] interleaved buffer created with id ${bufferId}`);
179
+ editor.setStatus("Test view marker (interleaved) active - press q to close");
180
+ } else {
181
+ editor.debug(`[test_view_marker] failed to create buffer`);
182
+ editor.setStatus("Failed to create test view marker buffer");
183
+ }
184
+ }
185
+
186
+ globalThis.show_test_view_marker = async function(): Promise<void> {
187
+ await open_test_view_marker(0, "*test-view-marker*");
188
+ };
189
+
190
+ globalThis.show_test_view_marker_many_virtual_lines = async function(): Promise<void> {
191
+ await open_test_view_marker(120, "*test-view-marker-many*");
192
+ };
193
+
194
+ globalThis.show_test_view_marker_interleaved = async function(): Promise<void> {
195
+ await open_test_view_marker_interleaved("*test-view-marker-interleaved*");
196
+ };
197
+
198
+ /**
199
+ * Close the test view marker
200
+ */
201
+ globalThis.test_view_marker_close = function(): void {
202
+ if (activeBufferId !== null) {
203
+ // Clear virtual lines before closing
204
+ editor.clearVirtualTextNamespace(activeBufferId, TEST_NAMESPACE);
205
+
206
+ editor.closeBuffer(activeBufferId);
207
+ padLinesByBuffer.delete(activeBufferId);
208
+ interleavedModeByBuffer.delete(activeBufferId);
209
+ activeBufferId = null;
210
+ editor.setStatus("Test view marker closed");
211
+ }
212
+ };
213
+
214
+ // Register command
215
+ editor.registerCommand(
216
+ "Test View Marker",
217
+ "Test virtual lines with header at byte 0",
218
+ "show_test_view_marker",
219
+ "normal"
220
+ );
221
+
222
+ editor.registerCommand(
223
+ "Test View Marker (Many Virtual Lines)",
224
+ "Test virtual lines with many header lines",
225
+ "show_test_view_marker_many_virtual_lines",
226
+ "normal"
227
+ );
228
+
229
+ editor.registerCommand(
230
+ "Test View Marker (Interleaved)",
231
+ "Test virtual lines with headers between each source line",
232
+ "show_test_view_marker_interleaved",
233
+ "normal"
234
+ );
235
+
236
+ editor.setStatus("Test View Marker plugin loaded (virtual lines)");