@fresh-editor/fresh-editor 0.1.67 → 0.1.70

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 (64) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +2 -0
  3. package/package.json +1 -1
  4. package/plugins/audit_mode.i18n.json +380 -0
  5. package/plugins/audit_mode.ts +836 -68
  6. package/plugins/buffer_modified.i18n.json +32 -0
  7. package/plugins/buffer_modified.ts +5 -3
  8. package/plugins/calculator.i18n.json +44 -0
  9. package/plugins/calculator.ts +6 -4
  10. package/plugins/clangd-lsp.ts +2 -0
  11. package/plugins/clangd_support.i18n.json +104 -0
  12. package/plugins/clangd_support.ts +18 -16
  13. package/plugins/color_highlighter.i18n.json +68 -0
  14. package/plugins/color_highlighter.ts +12 -10
  15. package/plugins/config-schema.json +28 -145
  16. package/plugins/csharp-lsp.ts +2 -0
  17. package/plugins/csharp_support.i18n.json +38 -0
  18. package/plugins/csharp_support.ts +6 -4
  19. package/plugins/css-lsp.ts +2 -0
  20. package/plugins/diagnostics_panel.i18n.json +110 -0
  21. package/plugins/diagnostics_panel.ts +19 -17
  22. package/plugins/find_references.i18n.json +128 -0
  23. package/plugins/find_references.ts +22 -20
  24. package/plugins/git_blame.i18n.json +230 -0
  25. package/plugins/git_blame.ts +39 -37
  26. package/plugins/git_find_file.i18n.json +146 -0
  27. package/plugins/git_find_file.ts +24 -22
  28. package/plugins/git_grep.i18n.json +80 -0
  29. package/plugins/git_grep.ts +15 -13
  30. package/plugins/git_gutter.i18n.json +44 -0
  31. package/plugins/git_gutter.ts +7 -5
  32. package/plugins/git_log.i18n.json +224 -0
  33. package/plugins/git_log.ts +41 -39
  34. package/plugins/go-lsp.ts +2 -0
  35. package/plugins/html-lsp.ts +2 -0
  36. package/plugins/json-lsp.ts +2 -0
  37. package/plugins/lib/fresh.d.ts +53 -13
  38. package/plugins/lib/index.ts +1 -1
  39. package/plugins/lib/navigation-controller.ts +3 -3
  40. package/plugins/lib/panel-manager.ts +15 -13
  41. package/plugins/lib/virtual-buffer-factory.ts +84 -112
  42. package/plugins/live_grep.i18n.json +80 -0
  43. package/plugins/live_grep.ts +15 -13
  44. package/plugins/markdown_compose.i18n.json +104 -0
  45. package/plugins/markdown_compose.ts +17 -15
  46. package/plugins/merge_conflict.i18n.json +380 -0
  47. package/plugins/merge_conflict.ts +72 -73
  48. package/plugins/path_complete.i18n.json +38 -0
  49. package/plugins/path_complete.ts +6 -4
  50. package/plugins/python-lsp.ts +2 -0
  51. package/plugins/rust-lsp.ts +2 -0
  52. package/plugins/search_replace.i18n.json +188 -0
  53. package/plugins/search_replace.ts +31 -29
  54. package/plugins/test_i18n.i18n.json +12 -0
  55. package/plugins/test_i18n.ts +18 -0
  56. package/plugins/theme_editor.i18n.json +1417 -0
  57. package/plugins/theme_editor.ts +73 -69
  58. package/plugins/todo_highlighter.i18n.json +86 -0
  59. package/plugins/todo_highlighter.ts +15 -13
  60. package/plugins/typescript-lsp.ts +2 -0
  61. package/plugins/vi_mode.i18n.json +716 -0
  62. package/plugins/vi_mode.ts +1195 -78
  63. package/plugins/welcome.i18n.json +110 -0
  64. package/plugins/welcome.ts +18 -16
@@ -1,11 +1,13 @@
1
1
  // Review Diff Plugin
2
2
  // Provides a unified workflow for reviewing code changes (diffs, conflicts, AI outputs).
3
+ const editor = getEditor();
3
4
 
4
5
  /// <reference path="./lib/fresh.d.ts" />
5
6
  /// <reference path="./lib/types.ts" />
6
7
  /// <reference path="./lib/virtual-buffer-factory.ts" />
7
8
 
8
- import { VirtualBufferFactory } from "./lib/virtual-buffer-factory.ts";
9
+ import { createVirtualBufferFactory } from "./lib/virtual-buffer-factory.ts";
10
+ const VirtualBufferFactory = createVirtualBufferFactory(editor);
9
11
 
10
12
  /**
11
13
  * Hunk status for staging
@@ -210,6 +212,7 @@ interface HighlightTask {
210
212
  bg?: [number, number, number];
211
213
  bold?: boolean;
212
214
  italic?: boolean;
215
+ extend_to_line_end?: boolean;
213
216
  }
214
217
 
215
218
  /**
@@ -222,31 +225,31 @@ async function renderReviewStream(): Promise<{ entries: TextPropertyEntry[], hig
222
225
  let currentByte = 0;
223
226
 
224
227
  // Add help header with keybindings at the TOP
225
- const helpHeader = "╔══════════════════════════════════════════════════════════════════════════╗\n";
228
+ const helpHeader = "╔" + "═".repeat(74) + "╗\n";
226
229
  const helpLen0 = getByteLength(helpHeader);
227
230
  entries.push({ text: helpHeader, properties: { type: "help" } });
228
231
  highlights.push({ range: [currentByte, currentByte + helpLen0], fg: STYLE_COMMENT_BORDER });
229
232
  currentByte += helpLen0;
230
233
 
231
- const helpLine1 = "║ REVIEW: [c]omment [a]pprove [x]reject [!]changes [?]question [u]ndo ║\n";
234
+ const helpLine1 = "║ " + editor.t("panel.help_review").padEnd(72) + " ║\n";
232
235
  const helpLen1 = getByteLength(helpLine1);
233
236
  entries.push({ text: helpLine1, properties: { type: "help" } });
234
237
  highlights.push({ range: [currentByte, currentByte + helpLen1], fg: STYLE_COMMENT });
235
238
  currentByte += helpLen1;
236
239
 
237
- const helpLine2 = "║ STAGE: [s]tage [d]iscard | NAV: [n]ext [p]rev [Enter]drill [q]uit ║\n";
240
+ const helpLine2 = "║ " + editor.t("panel.help_stage").padEnd(72) + " ║\n";
238
241
  const helpLen2 = getByteLength(helpLine2);
239
242
  entries.push({ text: helpLine2, properties: { type: "help" } });
240
243
  highlights.push({ range: [currentByte, currentByte + helpLen2], fg: STYLE_COMMENT });
241
244
  currentByte += helpLen2;
242
245
 
243
- const helpLine3 = "║ EXPORT: [E] .review/session.md | [O]verall feedback | [r]efresh ║\n";
246
+ const helpLine3 = "║ " + editor.t("panel.help_export").padEnd(72) + " ║\n";
244
247
  const helpLen3 = getByteLength(helpLine3);
245
248
  entries.push({ text: helpLine3, properties: { type: "help" } });
246
249
  highlights.push({ range: [currentByte, currentByte + helpLen3], fg: STYLE_COMMENT });
247
250
  currentByte += helpLen3;
248
251
 
249
- const helpFooter = "╚══════════════════════════════════════════════════════════════════════════╝\n\n";
252
+ const helpFooter = "╚" + "═".repeat(74) + "╝\n\n";
250
253
  const helpLen4 = getByteLength(helpFooter);
251
254
  entries.push({ text: helpFooter, properties: { type: "help" } });
252
255
  highlights.push({ range: [currentByte, currentByte + helpLen4], fg: STYLE_COMMENT_BORDER });
@@ -509,7 +512,7 @@ async function renderReviewStream(): Promise<{ entries: TextPropertyEntry[], hig
509
512
  }
510
513
 
511
514
  if (entries.length === 0) {
512
- entries.push({ text: "No changes to review.\n", properties: {} });
515
+ entries.push({ text: editor.t("panel.no_changes") + "\n", properties: {} });
513
516
  } else {
514
517
  // Add help footer with keybindings
515
518
  const helpSeparator = "\n" + "─".repeat(70) + "\n";
@@ -518,19 +521,19 @@ async function renderReviewStream(): Promise<{ entries: TextPropertyEntry[], hig
518
521
  highlights.push({ range: [currentByte, currentByte + helpLen1], fg: STYLE_BORDER });
519
522
  currentByte += helpLen1;
520
523
 
521
- const helpLine1 = "REVIEW: [c]omment [a]pprove [x]reject [!]needs-changes [?]question [u]ndo\n";
524
+ const helpLine1 = editor.t("panel.help_review_footer") + "\n";
522
525
  const helpLen2 = getByteLength(helpLine1);
523
526
  entries.push({ text: helpLine1, properties: { type: "help" } });
524
527
  highlights.push({ range: [currentByte, currentByte + helpLen2], fg: STYLE_COMMENT });
525
528
  currentByte += helpLen2;
526
529
 
527
- const helpLine2 = "STAGE: [s]tage [d]iscard | NAV: [n]ext [p]rev [Enter]drill-down [q]uit\n";
530
+ const helpLine2 = editor.t("panel.help_stage_footer") + "\n";
528
531
  const helpLen3 = getByteLength(helpLine2);
529
532
  entries.push({ text: helpLine2, properties: { type: "help" } });
530
533
  highlights.push({ range: [currentByte, currentByte + helpLen3], fg: STYLE_COMMENT });
531
534
  currentByte += helpLen3;
532
535
 
533
- const helpLine3 = "EXPORT: [E]xport to .review/session.md | [O]verall feedback [r]efresh\n";
536
+ const helpLine3 = editor.t("panel.help_export_footer") + "\n";
534
537
  const helpLen4 = getByteLength(helpLine3);
535
538
  entries.push({ text: helpLine3, properties: { type: "help" } });
536
539
  highlights.push({ range: [currentByte, currentByte + helpLen4], fg: STYLE_COMMENT });
@@ -572,13 +575,13 @@ async function updateReviewUI() {
572
575
  async function refreshReviewData() {
573
576
  if (isUpdating) return;
574
577
  isUpdating = true;
575
- editor.setStatus("Refreshing review diff...");
578
+ editor.setStatus(editor.t("status.refreshing"));
576
579
  try {
577
580
  const newHunks = await getGitDiff();
578
581
  newHunks.forEach(h => h.status = state.hunkStatus[h.id] || 'pending');
579
582
  state.hunks = newHunks;
580
583
  await updateReviewUI();
581
- editor.setStatus(`Review diff updated. Found ${state.hunks.length} hunks.`);
584
+ editor.setStatus(editor.t("status.updated", { count: String(state.hunks.length) }));
582
585
  } catch (e) {
583
586
  editor.debug(`ReviewDiff Error: ${e}`);
584
587
  } finally {
@@ -641,12 +644,371 @@ globalThis.review_refresh = () => { refreshReviewData(); };
641
644
 
642
645
  let activeDiffViewState: { lSplit: number, rSplit: number } | null = null;
643
646
 
647
+ /**
648
+ * Find line number for a given byte offset using binary search
649
+ */
650
+ function findLineForByte(lineByteOffsets: number[], topByte: number): number {
651
+ let low = 0;
652
+ let high = lineByteOffsets.length - 1;
653
+ while (low < high) {
654
+ const mid = Math.floor((low + high + 1) / 2);
655
+ if (lineByteOffsets[mid] <= topByte) {
656
+ low = mid;
657
+ } else {
658
+ high = mid - 1;
659
+ }
660
+ }
661
+ return low;
662
+ }
663
+
644
664
  globalThis.on_viewport_changed = (data: any) => {
645
- if (!activeDiffViewState) return;
646
- if (data.split_id === activeDiffViewState.lSplit) (editor as any).setSplitScroll(activeDiffViewState.rSplit, data.top_byte);
647
- else if (data.split_id === activeDiffViewState.rSplit) (editor as any).setSplitScroll(activeDiffViewState.lSplit, data.top_byte);
665
+ // This handler is now a no-op - scroll sync is handled by the core
666
+ // using the anchor-based ScrollSyncGroup system.
667
+ // Keeping the handler for backward compatibility if core sync fails.
668
+ if (!activeDiffViewState || !activeSideBySideState) return;
669
+
670
+ // Skip if core scroll sync is active (we have a scrollSyncGroupId)
671
+ if (activeSideBySideState.scrollSyncGroupId !== null) return;
672
+
673
+ const { oldSplitId, newSplitId, oldLineByteOffsets, newLineByteOffsets } = activeSideBySideState;
674
+
675
+ if (data.split_id === oldSplitId && newLineByteOffsets.length > 0) {
676
+ // OLD pane scrolled - find which line it's on and sync NEW pane to same line
677
+ const lineNum = findLineForByte(oldLineByteOffsets, data.top_byte);
678
+ const targetByte = newLineByteOffsets[Math.min(lineNum, newLineByteOffsets.length - 1)];
679
+ (editor as any).setSplitScroll(newSplitId, targetByte);
680
+ } else if (data.split_id === newSplitId && oldLineByteOffsets.length > 0) {
681
+ // NEW pane scrolled - find which line it's on and sync OLD pane to same line
682
+ const lineNum = findLineForByte(newLineByteOffsets, data.top_byte);
683
+ const targetByte = oldLineByteOffsets[Math.min(lineNum, oldLineByteOffsets.length - 1)];
684
+ (editor as any).setSplitScroll(oldSplitId, targetByte);
685
+ }
648
686
  };
649
687
 
688
+ /**
689
+ * Represents an aligned line pair for side-by-side diff display
690
+ */
691
+ interface AlignedLine {
692
+ oldLine: string | null; // null means filler line
693
+ newLine: string | null; // null means filler line
694
+ oldLineNum: number | null;
695
+ newLineNum: number | null;
696
+ changeType: 'unchanged' | 'added' | 'removed' | 'modified';
697
+ }
698
+
699
+ /**
700
+ * Parse git diff and compute fully aligned line pairs for side-by-side display.
701
+ * Shows the complete files with proper alignment through all hunks.
702
+ */
703
+ function computeFullFileAlignedDiff(oldContent: string, newContent: string, hunks: Hunk[]): AlignedLine[] {
704
+ const oldLines = oldContent.split('\n');
705
+ const newLines = newContent.split('\n');
706
+ const aligned: AlignedLine[] = [];
707
+
708
+ // Build a map of changes from all hunks for this file
709
+ // Key: old line number (1-based), Value: { type, newLineNum, content }
710
+ interface ChangeInfo {
711
+ type: 'removed' | 'added' | 'modified' | 'context';
712
+ oldContent?: string;
713
+ newContent?: string;
714
+ newLineNum?: number;
715
+ }
716
+
717
+ // Parse all hunks for this file
718
+ const allHunkChanges: { oldStart: number, newStart: number, changes: { type: 'add' | 'remove' | 'context', content: string }[] }[] = [];
719
+ for (const hunk of hunks) {
720
+ const changes: { type: 'add' | 'remove' | 'context', content: string }[] = [];
721
+ for (const line of hunk.lines) {
722
+ if (line.startsWith('+')) {
723
+ changes.push({ type: 'add', content: line.substring(1) });
724
+ } else if (line.startsWith('-')) {
725
+ changes.push({ type: 'remove', content: line.substring(1) });
726
+ } else if (line.startsWith(' ')) {
727
+ changes.push({ type: 'context', content: line.substring(1) });
728
+ }
729
+ }
730
+ allHunkChanges.push({
731
+ oldStart: hunk.oldRange.start,
732
+ newStart: hunk.range.start,
733
+ changes
734
+ });
735
+ }
736
+
737
+ // Sort hunks by old line start
738
+ allHunkChanges.sort((a, b) => a.oldStart - b.oldStart);
739
+
740
+ // Process the file line by line
741
+ let oldIdx = 0; // 0-based index into oldLines
742
+ let newIdx = 0; // 0-based index into newLines
743
+ let hunkIdx = 0;
744
+
745
+ while (oldIdx < oldLines.length || newIdx < newLines.length || hunkIdx < allHunkChanges.length) {
746
+ // Check if we're at a hunk boundary
747
+ const currentHunk = hunkIdx < allHunkChanges.length ? allHunkChanges[hunkIdx] : null;
748
+
749
+ if (currentHunk && oldIdx + 1 === currentHunk.oldStart) {
750
+ // Process this hunk
751
+ let changeIdx = 0;
752
+ while (changeIdx < currentHunk.changes.length) {
753
+ const change = currentHunk.changes[changeIdx];
754
+
755
+ if (change.type === 'context') {
756
+ aligned.push({
757
+ oldLine: oldLines[oldIdx],
758
+ newLine: newLines[newIdx],
759
+ oldLineNum: oldIdx + 1,
760
+ newLineNum: newIdx + 1,
761
+ changeType: 'unchanged'
762
+ });
763
+ oldIdx++;
764
+ newIdx++;
765
+ changeIdx++;
766
+ } else if (change.type === 'remove') {
767
+ // Look ahead to see if next is an 'add' (modification)
768
+ if (changeIdx + 1 < currentHunk.changes.length &&
769
+ currentHunk.changes[changeIdx + 1].type === 'add') {
770
+ // Modified line
771
+ aligned.push({
772
+ oldLine: oldLines[oldIdx],
773
+ newLine: newLines[newIdx],
774
+ oldLineNum: oldIdx + 1,
775
+ newLineNum: newIdx + 1,
776
+ changeType: 'modified'
777
+ });
778
+ oldIdx++;
779
+ newIdx++;
780
+ changeIdx += 2;
781
+ } else {
782
+ // Pure removal
783
+ aligned.push({
784
+ oldLine: oldLines[oldIdx],
785
+ newLine: null,
786
+ oldLineNum: oldIdx + 1,
787
+ newLineNum: null,
788
+ changeType: 'removed'
789
+ });
790
+ oldIdx++;
791
+ changeIdx++;
792
+ }
793
+ } else if (change.type === 'add') {
794
+ // Pure addition
795
+ aligned.push({
796
+ oldLine: null,
797
+ newLine: newLines[newIdx],
798
+ oldLineNum: null,
799
+ newLineNum: newIdx + 1,
800
+ changeType: 'added'
801
+ });
802
+ newIdx++;
803
+ changeIdx++;
804
+ }
805
+ }
806
+ hunkIdx++;
807
+ } else if (oldIdx < oldLines.length && newIdx < newLines.length) {
808
+ // Not in a hunk - add unchanged line
809
+ aligned.push({
810
+ oldLine: oldLines[oldIdx],
811
+ newLine: newLines[newIdx],
812
+ oldLineNum: oldIdx + 1,
813
+ newLineNum: newIdx + 1,
814
+ changeType: 'unchanged'
815
+ });
816
+ oldIdx++;
817
+ newIdx++;
818
+ } else if (oldIdx < oldLines.length) {
819
+ // Only old lines left (shouldn't happen normally)
820
+ aligned.push({
821
+ oldLine: oldLines[oldIdx],
822
+ newLine: null,
823
+ oldLineNum: oldIdx + 1,
824
+ newLineNum: null,
825
+ changeType: 'removed'
826
+ });
827
+ oldIdx++;
828
+ } else if (newIdx < newLines.length) {
829
+ // Only new lines left
830
+ aligned.push({
831
+ oldLine: null,
832
+ newLine: newLines[newIdx],
833
+ oldLineNum: null,
834
+ newLineNum: newIdx + 1,
835
+ changeType: 'added'
836
+ });
837
+ newIdx++;
838
+ } else {
839
+ break;
840
+ }
841
+ }
842
+
843
+ return aligned;
844
+ }
845
+
846
+ /**
847
+ * Generate virtual buffer content with diff highlighting for one side.
848
+ * Returns entries, highlight tasks, and line byte offsets for scroll sync.
849
+ */
850
+ function generateDiffPaneContent(
851
+ alignedLines: AlignedLine[],
852
+ side: 'old' | 'new'
853
+ ): { entries: TextPropertyEntry[], highlights: HighlightTask[], lineByteOffsets: number[] } {
854
+ const entries: TextPropertyEntry[] = [];
855
+ const highlights: HighlightTask[] = [];
856
+ const lineByteOffsets: number[] = [];
857
+ let currentByte = 0;
858
+
859
+ for (const line of alignedLines) {
860
+ lineByteOffsets.push(currentByte);
861
+ const content = side === 'old' ? line.oldLine : line.newLine;
862
+ const lineNum = side === 'old' ? line.oldLineNum : line.newLineNum;
863
+ const isFiller = content === null;
864
+
865
+ // Format: "│ NNN │ content" or "│ │ ~~~~~~~~" for filler
866
+ let lineNumStr: string;
867
+ if (lineNum !== null) {
868
+ lineNumStr = lineNum.toString().padStart(4, ' ');
869
+ } else {
870
+ lineNumStr = ' ';
871
+ }
872
+
873
+ // Gutter marker based on change type
874
+ let gutterMarker = ' ';
875
+ if (line.changeType === 'added' && side === 'new') gutterMarker = '+';
876
+ else if (line.changeType === 'removed' && side === 'old') gutterMarker = '-';
877
+ else if (line.changeType === 'modified') gutterMarker = '~';
878
+
879
+ let lineText: string;
880
+ if (isFiller) {
881
+ // Filler line for alignment
882
+ lineText = `│${gutterMarker}${lineNumStr} │ ${"░".repeat(40)}\n`;
883
+ } else {
884
+ lineText = `│${gutterMarker}${lineNumStr} │ ${content}\n`;
885
+ }
886
+
887
+ const lineLen = getByteLength(lineText);
888
+ const prefixLen = getByteLength(`│${gutterMarker}${lineNumStr} │ `);
889
+
890
+ entries.push({
891
+ text: lineText,
892
+ properties: {
893
+ type: 'diff-line',
894
+ changeType: line.changeType,
895
+ lineNum: lineNum,
896
+ side: side
897
+ }
898
+ });
899
+
900
+ // Apply colors based on change type
901
+ // Border color
902
+ highlights.push({ range: [currentByte, currentByte + 1], fg: STYLE_BORDER });
903
+ highlights.push({ range: [currentByte + prefixLen - 3, currentByte + prefixLen - 1], fg: STYLE_BORDER });
904
+
905
+ // Line number color
906
+ highlights.push({
907
+ range: [currentByte + 2, currentByte + 6],
908
+ fg: [120, 120, 120] // Gray line numbers
909
+ });
910
+
911
+ if (isFiller) {
912
+ // Filler styling - extend to full line width
913
+ highlights.push({
914
+ range: [currentByte + prefixLen, currentByte + lineLen - 1],
915
+ fg: [60, 60, 60],
916
+ bg: [30, 30, 30],
917
+ extend_to_line_end: true
918
+ });
919
+ } else if (line.changeType === 'added' && side === 'new') {
920
+ // Added line (green) - extend to full line width
921
+ highlights.push({ range: [currentByte + 1, currentByte + 2], fg: STYLE_ADD_TEXT, bold: true }); // gutter marker
922
+ highlights.push({
923
+ range: [currentByte + prefixLen, currentByte + lineLen - 1],
924
+ fg: STYLE_ADD_TEXT,
925
+ bg: [30, 50, 30],
926
+ extend_to_line_end: true
927
+ });
928
+ } else if (line.changeType === 'removed' && side === 'old') {
929
+ // Removed line (red) - extend to full line width
930
+ highlights.push({ range: [currentByte + 1, currentByte + 2], fg: STYLE_REMOVE_TEXT, bold: true }); // gutter marker
931
+ highlights.push({
932
+ range: [currentByte + prefixLen, currentByte + lineLen - 1],
933
+ fg: STYLE_REMOVE_TEXT,
934
+ bg: [50, 30, 30],
935
+ extend_to_line_end: true
936
+ });
937
+ } else if (line.changeType === 'modified') {
938
+ // Modified line - show word-level diff
939
+ const oldText = line.oldLine || '';
940
+ const newText = line.newLine || '';
941
+ const diffParts = diffStrings(oldText, newText);
942
+
943
+ let offset = currentByte + prefixLen;
944
+ if (side === 'old') {
945
+ highlights.push({ range: [currentByte + 1, currentByte + 2], fg: STYLE_REMOVE_TEXT, bold: true });
946
+ // Highlight removed parts in old line
947
+ for (const part of diffParts) {
948
+ const partLen = getByteLength(part.text);
949
+ if (part.type === 'removed') {
950
+ highlights.push({
951
+ range: [offset, offset + partLen],
952
+ fg: STYLE_REMOVE_TEXT,
953
+ bg: STYLE_REMOVE_BG,
954
+ bold: true
955
+ });
956
+ } else if (part.type === 'unchanged') {
957
+ highlights.push({
958
+ range: [offset, offset + partLen],
959
+ fg: STYLE_REMOVE_TEXT
960
+ });
961
+ }
962
+ if (part.type !== 'added') {
963
+ offset += partLen;
964
+ }
965
+ }
966
+ } else {
967
+ highlights.push({ range: [currentByte + 1, currentByte + 2], fg: STYLE_ADD_TEXT, bold: true });
968
+ // Highlight added parts in new line
969
+ for (const part of diffParts) {
970
+ const partLen = getByteLength(part.text);
971
+ if (part.type === 'added') {
972
+ highlights.push({
973
+ range: [offset, offset + partLen],
974
+ fg: STYLE_ADD_TEXT,
975
+ bg: STYLE_ADD_BG,
976
+ bold: true
977
+ });
978
+ } else if (part.type === 'unchanged') {
979
+ highlights.push({
980
+ range: [offset, offset + partLen],
981
+ fg: STYLE_ADD_TEXT
982
+ });
983
+ }
984
+ if (part.type !== 'removed') {
985
+ offset += partLen;
986
+ }
987
+ }
988
+ }
989
+ }
990
+
991
+ currentByte += lineLen;
992
+ }
993
+
994
+ return { entries, highlights, lineByteOffsets };
995
+ }
996
+
997
+ // State for active side-by-side diff view
998
+ interface SideBySideDiffState {
999
+ oldSplitId: number;
1000
+ newSplitId: number;
1001
+ oldBufferId: number;
1002
+ newBufferId: number;
1003
+ alignedLines: AlignedLine[];
1004
+ oldLineByteOffsets: number[];
1005
+ newLineByteOffsets: number[];
1006
+ scrollSyncGroupId: number | null; // Core scroll sync group ID
1007
+ }
1008
+
1009
+ let activeSideBySideState: SideBySideDiffState | null = null;
1010
+ let nextScrollSyncGroupId = 1;
1011
+
650
1012
  globalThis.review_drill_down = async () => {
651
1013
  const bid = editor.getActiveBufferId();
652
1014
  const props = editor.getTextPropertiesAtCursor(bid);
@@ -654,39 +1016,201 @@ globalThis.review_drill_down = async () => {
654
1016
  const id = props[0].hunkId as string;
655
1017
  const h = state.hunks.find(x => x.id === id);
656
1018
  if (!h) return;
1019
+
1020
+ editor.setStatus(editor.t("status.loading_diff"));
1021
+
1022
+ // Get all hunks for this file
1023
+ const fileHunks = state.hunks.filter(hunk => hunk.file === h.file);
1024
+
1025
+ // Get git root to construct absolute path
1026
+ const gitRootResult = await editor.spawnProcess("git", ["rev-parse", "--show-toplevel"]);
1027
+ if (gitRootResult.exit_code !== 0) {
1028
+ editor.setStatus(editor.t("status.not_git_repo"));
1029
+ return;
1030
+ }
1031
+ const gitRoot = gitRootResult.stdout.trim();
1032
+ const absoluteFilePath = editor.pathJoin(gitRoot, h.file);
1033
+
1034
+ // Get old (HEAD) and new (working) file content
657
1035
  const gitShow = await editor.spawnProcess("git", ["show", `HEAD:${h.file}`]);
658
- if (gitShow.exit_code !== 0) return;
1036
+ if (gitShow.exit_code !== 0) {
1037
+ editor.setStatus(editor.t("status.failed_old_version"));
1038
+ return;
1039
+ }
1040
+ const oldContent = gitShow.stdout;
1041
+
1042
+ // Read new file content (use absolute path for readFile)
1043
+ let newContent: string;
1044
+ try {
1045
+ newContent = await editor.readFile(absoluteFilePath);
1046
+ } catch (e) {
1047
+ editor.setStatus(editor.t("status.failed_new_version"));
1048
+ return;
1049
+ }
1050
+
1051
+ // Close the Review Diff buffer to make room for side-by-side view
1052
+ // Store the review buffer ID so we can restore it later
1053
+ const reviewBufferId = bid;
659
1054
 
660
- // Side-by-side layout: NEW (editable, left) | OLD (read-only, right)
661
- // Note: Ideally OLD should be on left per convention, but API creates splits to the right
1055
+ // Compute aligned diff for the FULL file with all hunks
1056
+ const alignedLines = computeFullFileAlignedDiff(oldContent, newContent, fileHunks);
662
1057
 
663
- // Step 1: Open NEW file in current split (becomes LEFT pane)
664
- editor.openFile(h.file, h.range.start, 0);
665
- const newSplitId = (editor as any).getActiveSplitId();
1058
+ // Generate content for both panes
1059
+ const oldPane = generateDiffPaneContent(alignedLines, 'old');
1060
+ const newPane = generateDiffPaneContent(alignedLines, 'new');
666
1061
 
667
- // Step 2: Create OLD (HEAD) version in new split (becomes RIGHT pane)
668
- // editing_disabled: true prevents text input in read-only buffer
669
- const oldRes = await editor.createVirtualBufferInSplit({
670
- name: `[OLD ◀] ${h.file}`, // Arrow indicates this is the old/reference version
671
- mode: "special",
1062
+ // Close any existing side-by-side views
1063
+ if (activeSideBySideState) {
1064
+ try {
1065
+ // Remove scroll sync group first
1066
+ if (activeSideBySideState.scrollSyncGroupId !== null) {
1067
+ (editor as any).removeScrollSyncGroup(activeSideBySideState.scrollSyncGroupId);
1068
+ }
1069
+ editor.closeBuffer(activeSideBySideState.oldBufferId);
1070
+ editor.closeBuffer(activeSideBySideState.newBufferId);
1071
+ } catch {}
1072
+ activeSideBySideState = null;
1073
+ }
1074
+
1075
+ // Get the current split ID before closing the Review Diff buffer
1076
+ const currentSplitId = (editor as any).getActiveSplitId();
1077
+
1078
+ // Create OLD buffer in the CURRENT split (replaces Review Diff)
1079
+ const oldBufferId = await editor.createVirtualBufferInExistingSplit({
1080
+ name: `[OLD] ${h.file}`,
1081
+ mode: "diff-view",
672
1082
  read_only: true,
673
1083
  editing_disabled: true,
674
- entries: [{ text: gitShow.stdout, properties: {} }],
1084
+ entries: oldPane.entries,
1085
+ split_id: currentSplitId,
1086
+ show_line_numbers: false,
1087
+ line_wrap: false
1088
+ });
1089
+ const oldSplitId = currentSplitId;
1090
+
1091
+ // Close the Review Diff buffer after showing OLD
1092
+ editor.closeBuffer(reviewBufferId);
1093
+
1094
+ // Apply highlights to old pane
1095
+ editor.clearNamespace(oldBufferId, "diff-view");
1096
+ for (const hl of oldPane.highlights) {
1097
+ const bg = hl.bg || [-1, -1, -1];
1098
+ editor.addOverlay(
1099
+ oldBufferId, "diff-view",
1100
+ hl.range[0], hl.range[1],
1101
+ hl.fg[0], hl.fg[1], hl.fg[2],
1102
+ false, hl.bold || false, false,
1103
+ bg[0], bg[1], bg[2],
1104
+ hl.extend_to_line_end || false
1105
+ );
1106
+ }
1107
+
1108
+ // Step 2: Create NEW pane in a vertical split (RIGHT of OLD)
1109
+ const newRes = await editor.createVirtualBufferInSplit({
1110
+ name: `[NEW] ${h.file}`,
1111
+ mode: "diff-view",
1112
+ read_only: true,
1113
+ editing_disabled: true,
1114
+ entries: newPane.entries,
675
1115
  ratio: 0.5,
676
1116
  direction: "vertical",
677
- show_line_numbers: true
1117
+ show_line_numbers: false,
1118
+ line_wrap: false
678
1119
  });
679
- const oldSplitId = oldRes.split_id!;
1120
+ const newBufferId = newRes.buffer_id;
1121
+ const newSplitId = newRes.split_id!;
1122
+
1123
+ // Apply highlights to new pane
1124
+ editor.clearNamespace(newBufferId, "diff-view");
1125
+ for (const hl of newPane.highlights) {
1126
+ const bg = hl.bg || [-1, -1, -1];
1127
+ editor.addOverlay(
1128
+ newBufferId, "diff-view",
1129
+ hl.range[0], hl.range[1],
1130
+ hl.fg[0], hl.fg[1], hl.fg[2],
1131
+ false, hl.bold || false, false,
1132
+ bg[0], bg[1], bg[2],
1133
+ hl.extend_to_line_end || false
1134
+ );
1135
+ }
680
1136
 
681
- // Focus on NEW (left) pane - this is the editable working version
682
- (editor as any).focusSplit(newSplitId);
1137
+ // Focus OLD pane (left) - convention is to start on old side
1138
+ (editor as any).focusSplit(oldSplitId);
1139
+
1140
+ // Set up core-handled scroll sync using the new anchor-based API
1141
+ // This replaces the old viewport_changed hook approach
1142
+ let scrollSyncGroupId: number | null = null;
1143
+ try {
1144
+ // Generate a unique group ID
1145
+ scrollSyncGroupId = nextScrollSyncGroupId++;
1146
+ (editor as any).createScrollSyncGroup(scrollSyncGroupId, oldSplitId, newSplitId);
1147
+
1148
+ // Compute sync anchors from aligned lines
1149
+ // Each aligned line is a sync point - we map line indices to anchors
1150
+ // For the new core sync, we use line numbers (not byte offsets)
1151
+ const anchors: [number, number][] = [];
1152
+ for (let i = 0; i < alignedLines.length; i++) {
1153
+ // Add anchors at meaningful boundaries: start of file, and at change boundaries
1154
+ const line = alignedLines[i];
1155
+ const prevLine = i > 0 ? alignedLines[i - 1] : null;
1156
+
1157
+ // Add anchor at start of file
1158
+ if (i === 0) {
1159
+ anchors.push([0, 0]);
1160
+ }
683
1161
 
684
- // Track splits for synchronized scrolling
685
- activeDiffViewState = { lSplit: newSplitId, rSplit: oldSplitId };
686
- editor.on("viewport_changed", "on_viewport_changed");
1162
+ // Add anchor at change boundaries (when change type changes)
1163
+ if (prevLine && prevLine.changeType !== line.changeType) {
1164
+ anchors.push([i, i]);
1165
+ }
1166
+ }
1167
+
1168
+ // Add anchor at end
1169
+ if (alignedLines.length > 0) {
1170
+ anchors.push([alignedLines.length, alignedLines.length]);
1171
+ }
1172
+
1173
+ (editor as any).setScrollSyncAnchors(scrollSyncGroupId, anchors);
1174
+ } catch (e) {
1175
+ editor.debug(`Failed to create scroll sync group: ${e}`);
1176
+ scrollSyncGroupId = null;
1177
+ }
1178
+
1179
+ // Store state for synchronized scrolling
1180
+ activeSideBySideState = {
1181
+ oldSplitId,
1182
+ newSplitId,
1183
+ oldBufferId,
1184
+ newBufferId,
1185
+ alignedLines,
1186
+ oldLineByteOffsets: oldPane.lineByteOffsets,
1187
+ newLineByteOffsets: newPane.lineByteOffsets,
1188
+ scrollSyncGroupId
1189
+ };
1190
+ activeDiffViewState = { lSplit: oldSplitId, rSplit: newSplitId };
1191
+
1192
+ const addedLines = alignedLines.filter(l => l.changeType === 'added').length;
1193
+ const removedLines = alignedLines.filter(l => l.changeType === 'removed').length;
1194
+ const modifiedLines = alignedLines.filter(l => l.changeType === 'modified').length;
1195
+ editor.setStatus(editor.t("status.diff_summary", { added: String(addedLines), removed: String(removedLines), modified: String(modifiedLines) }));
687
1196
  }
688
1197
  };
689
1198
 
1199
+ // Define the diff-view mode with navigation keys
1200
+ editor.defineMode("diff-view", "special", [
1201
+ ["q", "close_buffer"],
1202
+ ["j", "move_down"],
1203
+ ["k", "move_up"],
1204
+ ["g", "move_document_start"],
1205
+ ["G", "move_document_end"],
1206
+ ["C-d", "move_page_down"],
1207
+ ["C-u", "move_page_up"],
1208
+ ["Down", "move_down"],
1209
+ ["Up", "move_up"],
1210
+ ["PageDown", "move_page_down"],
1211
+ ["PageUp", "move_page_up"],
1212
+ ], true);
1213
+
690
1214
  // --- Review Comment Actions ---
691
1215
 
692
1216
  function getCurrentHunkId(): string | null {
@@ -728,7 +1252,7 @@ let pendingCommentInfo: PendingCommentInfo | null = null;
728
1252
  globalThis.review_add_comment = async () => {
729
1253
  const info = getCurrentLineInfo();
730
1254
  if (!info) {
731
- editor.setStatus("No hunk selected for comment");
1255
+ editor.setStatus(editor.t("status.no_hunk_selected"));
732
1256
  return;
733
1257
  }
734
1258
  pendingCommentInfo = info;
@@ -744,7 +1268,7 @@ globalThis.review_add_comment = async () => {
744
1268
  } else if (info.oldLine) {
745
1269
  lineRef = `L${info.oldLine}`;
746
1270
  }
747
- editor.startPrompt(`Comment on ${lineRef}: `, "review-comment");
1271
+ editor.startPrompt(editor.t("prompt.comment", { line: lineRef }), "review-comment");
748
1272
  };
749
1273
 
750
1274
  // Prompt event handlers
@@ -776,7 +1300,7 @@ globalThis.on_review_prompt_confirm = (args: { prompt_type: string; input: strin
776
1300
  } else if (comment.old_line) {
777
1301
  lineRef = `line ${comment.old_line}`;
778
1302
  }
779
- editor.setStatus(`Comment added to ${lineRef}`);
1303
+ editor.setStatus(editor.t("status.comment_added", { line: lineRef }));
780
1304
  }
781
1305
  pendingCommentInfo = null;
782
1306
  return true;
@@ -785,7 +1309,7 @@ globalThis.on_review_prompt_confirm = (args: { prompt_type: string; input: strin
785
1309
  globalThis.on_review_prompt_cancel = (args: { prompt_type: string }): boolean => {
786
1310
  if (args.prompt_type === "review-comment") {
787
1311
  pendingCommentInfo = null;
788
- editor.setStatus("Comment cancelled");
1312
+ editor.setStatus(editor.t("status.comment_cancelled"));
789
1313
  }
790
1314
  return true;
791
1315
  };
@@ -801,7 +1325,7 @@ globalThis.review_approve_hunk = async () => {
801
1325
  if (h) {
802
1326
  h.reviewStatus = 'approved';
803
1327
  await updateReviewUI();
804
- editor.setStatus(`Hunk approved`);
1328
+ editor.setStatus(editor.t("status.hunk_approved"));
805
1329
  }
806
1330
  };
807
1331
 
@@ -812,7 +1336,7 @@ globalThis.review_reject_hunk = async () => {
812
1336
  if (h) {
813
1337
  h.reviewStatus = 'rejected';
814
1338
  await updateReviewUI();
815
- editor.setStatus(`Hunk rejected`);
1339
+ editor.setStatus(editor.t("status.hunk_rejected"));
816
1340
  }
817
1341
  };
818
1342
 
@@ -823,7 +1347,7 @@ globalThis.review_needs_changes = async () => {
823
1347
  if (h) {
824
1348
  h.reviewStatus = 'needs_changes';
825
1349
  await updateReviewUI();
826
- editor.setStatus(`Hunk marked as needs changes`);
1350
+ editor.setStatus(editor.t("status.hunk_needs_changes"));
827
1351
  }
828
1352
  };
829
1353
 
@@ -834,7 +1358,7 @@ globalThis.review_question_hunk = async () => {
834
1358
  if (h) {
835
1359
  h.reviewStatus = 'question';
836
1360
  await updateReviewUI();
837
- editor.setStatus(`Hunk marked with question`);
1361
+ editor.setStatus(editor.t("status.hunk_question"));
838
1362
  }
839
1363
  };
840
1364
 
@@ -845,15 +1369,15 @@ globalThis.review_clear_status = async () => {
845
1369
  if (h) {
846
1370
  h.reviewStatus = 'pending';
847
1371
  await updateReviewUI();
848
- editor.setStatus(`Hunk review status cleared`);
1372
+ editor.setStatus(editor.t("status.hunk_status_cleared"));
849
1373
  }
850
1374
  };
851
1375
 
852
1376
  globalThis.review_set_overall_feedback = async () => {
853
- const text = await editor.prompt("Overall feedback: ", state.overallFeedback || "");
1377
+ const text = await editor.prompt(editor.t("prompt.overall_feedback"), state.overallFeedback || "");
854
1378
  if (text !== null) {
855
1379
  state.overallFeedback = text.trim();
856
- editor.setStatus(`Overall feedback ${text.trim() ? 'set' : 'cleared'}`);
1380
+ editor.setStatus(text.trim() ? editor.t("status.feedback_set") : editor.t("status.feedback_cleared"));
857
1381
  }
858
1382
  };
859
1383
 
@@ -861,10 +1385,7 @@ globalThis.review_export_session = async () => {
861
1385
  const cwd = editor.getCwd();
862
1386
  const reviewDir = editor.pathJoin(cwd, ".review");
863
1387
 
864
- // Create .review directory if needed
865
- await editor.spawnProcess("mkdir", ["-p", reviewDir]);
866
-
867
- // Generate markdown content
1388
+ // Generate markdown content (writeFile creates parent directories)
868
1389
  let md = `# Code Review Session\n`;
869
1390
  md += `Date: ${new Date().toISOString()}\n\n`;
870
1391
 
@@ -930,13 +1451,13 @@ globalThis.review_export_session = async () => {
930
1451
  // Write file
931
1452
  const filePath = editor.pathJoin(reviewDir, "session.md");
932
1453
  await editor.writeFile(filePath, md);
933
- editor.setStatus(`Review exported to ${filePath}`);
1454
+ editor.setStatus(editor.t("status.exported", { path: filePath }));
934
1455
  };
935
1456
 
936
1457
  globalThis.review_export_json = async () => {
937
1458
  const cwd = editor.getCwd();
938
1459
  const reviewDir = editor.pathJoin(cwd, ".review");
939
- await editor.spawnProcess("mkdir", ["-p", reviewDir]);
1460
+ // writeFile creates parent directories
940
1461
 
941
1462
  const session = {
942
1463
  version: "1.0",
@@ -966,11 +1487,11 @@ globalThis.review_export_json = async () => {
966
1487
 
967
1488
  const filePath = editor.pathJoin(reviewDir, "session.json");
968
1489
  await editor.writeFile(filePath, JSON.stringify(session, null, 2));
969
- editor.setStatus(`Review exported to ${filePath}`);
1490
+ editor.setStatus(editor.t("status.exported", { path: filePath }));
970
1491
  };
971
1492
 
972
1493
  globalThis.start_review_diff = async () => {
973
- editor.setStatus("Generating Review Diff Stream...");
1494
+ editor.setStatus(editor.t("status.generating"));
974
1495
  editor.setContext("review-mode", true);
975
1496
 
976
1497
  // Initial data fetch
@@ -985,7 +1506,7 @@ globalThis.start_review_diff = async () => {
985
1506
  state.reviewBufferId = bufferId;
986
1507
  await updateReviewUI(); // Apply initial highlights
987
1508
 
988
- editor.setStatus(`Review Diff: ${state.hunks.length} hunks | [c]omment [a]pprove [x]reject [!]changes [?]question [E]xport`);
1509
+ editor.setStatus(editor.t("status.review_summary", { count: String(state.hunks.length) }));
989
1510
  editor.on("buffer_activated", "on_review_buffer_activated");
990
1511
  editor.on("buffer_closed", "on_review_buffer_closed");
991
1512
  };
@@ -995,9 +1516,10 @@ globalThis.stop_review_diff = () => {
995
1516
  editor.setContext("review-mode", false);
996
1517
  editor.off("buffer_activated", "on_review_buffer_activated");
997
1518
  editor.off("buffer_closed", "on_review_buffer_closed");
998
- editor.setStatus("Review Diff Mode stopped.");
1519
+ editor.setStatus(editor.t("status.stopped"));
999
1520
  };
1000
1521
 
1522
+
1001
1523
  globalThis.on_review_buffer_activated = (data: any) => {
1002
1524
  if (data.buffer_id === state.reviewBufferId) refreshReviewData();
1003
1525
  };
@@ -1006,21 +1528,267 @@ globalThis.on_review_buffer_closed = (data: any) => {
1006
1528
  if (data.buffer_id === state.reviewBufferId) stop_review_diff();
1007
1529
  };
1008
1530
 
1531
+ // Side-by-side diff for current file (can be called directly without Review Diff mode)
1532
+ globalThis.side_by_side_diff_current_file = async () => {
1533
+ const bid = editor.getActiveBufferId();
1534
+ const absolutePath = editor.getBufferPath(bid);
1535
+
1536
+ if (!absolutePath) {
1537
+ editor.setStatus(editor.t("status.no_file_open"));
1538
+ return;
1539
+ }
1540
+
1541
+ editor.setStatus(editor.t("status.loading_diff"));
1542
+
1543
+ // Get the file's directory and name for running git commands
1544
+ const fileDir = editor.pathDirname(absolutePath);
1545
+ const fileName = editor.pathBasename(absolutePath);
1546
+
1547
+ // Run git commands from the file's directory to avoid path format issues on Windows
1548
+ const gitRootResult = await editor.spawnProcess("git", ["-C", fileDir, "rev-parse", "--show-toplevel"]);
1549
+ if (gitRootResult.exit_code !== 0) {
1550
+ editor.setStatus(editor.t("status.not_git_repo"));
1551
+ return;
1552
+ }
1553
+ const gitRoot = gitRootResult.stdout.trim();
1554
+
1555
+ // Get relative path from git root using git itself (handles Windows paths correctly)
1556
+ const relPathResult = await editor.spawnProcess("git", ["-C", fileDir, "ls-files", "--full-name", fileName]);
1557
+ let filePath: string;
1558
+ if (relPathResult.exit_code === 0 && relPathResult.stdout.trim()) {
1559
+ filePath = relPathResult.stdout.trim();
1560
+ } else {
1561
+ // File might be untracked, compute relative path manually
1562
+ // Normalize paths: replace backslashes with forward slashes for comparison
1563
+ const normAbsPath = absolutePath.replace(/\\/g, '/');
1564
+ const normGitRoot = gitRoot.replace(/\\/g, '/');
1565
+ if (normAbsPath.toLowerCase().startsWith(normGitRoot.toLowerCase())) {
1566
+ filePath = normAbsPath.substring(normGitRoot.length + 1);
1567
+ } else {
1568
+ // Fallback to just the filename
1569
+ filePath = fileName;
1570
+ }
1571
+ }
1572
+
1573
+ // Get hunks for this specific file (use -C gitRoot since filePath is relative to git root)
1574
+ const result = await editor.spawnProcess("git", ["-C", gitRoot, "diff", "HEAD", "--unified=3", "--", filePath]);
1575
+ if (result.exit_code !== 0) {
1576
+ editor.setStatus(editor.t("status.failed_git_diff"));
1577
+ return;
1578
+ }
1579
+
1580
+ // Parse hunks from diff output
1581
+ const lines = result.stdout.split('\n');
1582
+ const fileHunks: Hunk[] = [];
1583
+ let currentHunk: Hunk | null = null;
1584
+
1585
+ for (const line of lines) {
1586
+ if (line.startsWith('@@')) {
1587
+ const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@(.*)/);
1588
+ if (match) {
1589
+ const oldStart = parseInt(match[1]);
1590
+ const newStart = parseInt(match[2]);
1591
+ currentHunk = {
1592
+ id: `${filePath}:${newStart}`,
1593
+ file: filePath,
1594
+ range: { start: newStart, end: newStart },
1595
+ oldRange: { start: oldStart, end: oldStart },
1596
+ type: 'modify',
1597
+ lines: [],
1598
+ status: 'pending',
1599
+ reviewStatus: 'pending',
1600
+ contextHeader: match[3]?.trim() || "",
1601
+ byteOffset: 0
1602
+ };
1603
+ fileHunks.push(currentHunk);
1604
+ }
1605
+ } else if (currentHunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) {
1606
+ if (!line.startsWith('---') && !line.startsWith('+++')) {
1607
+ currentHunk.lines.push(line);
1608
+ }
1609
+ }
1610
+ }
1611
+
1612
+ if (fileHunks.length === 0) {
1613
+ editor.setStatus(editor.t("status.no_changes"));
1614
+ return;
1615
+ }
1616
+
1617
+ // Get old (HEAD) and new (working) file content (use -C gitRoot since filePath is relative to git root)
1618
+ const gitShow = await editor.spawnProcess("git", ["-C", gitRoot, "show", `HEAD:${filePath}`]);
1619
+ if (gitShow.exit_code !== 0) {
1620
+ editor.setStatus(editor.t("status.failed_old_new_file"));
1621
+ return;
1622
+ }
1623
+ const oldContent = gitShow.stdout;
1624
+
1625
+ // Read new file content (use absolute path for readFile)
1626
+ let newContent: string;
1627
+ try {
1628
+ newContent = await editor.readFile(absolutePath);
1629
+ } catch (e) {
1630
+ editor.setStatus(editor.t("status.failed_new_version"));
1631
+ return;
1632
+ }
1633
+
1634
+ // Compute aligned diff for the FULL file
1635
+ const alignedLines = computeFullFileAlignedDiff(oldContent, newContent, fileHunks);
1636
+
1637
+ // Generate content for both panes
1638
+ const oldPane = generateDiffPaneContent(alignedLines, 'old');
1639
+ const newPane = generateDiffPaneContent(alignedLines, 'new');
1640
+
1641
+ // Close any existing side-by-side views
1642
+ if (activeSideBySideState) {
1643
+ try {
1644
+ if (activeSideBySideState.scrollSyncGroupId !== null) {
1645
+ (editor as any).removeScrollSyncGroup(activeSideBySideState.scrollSyncGroupId);
1646
+ }
1647
+ editor.closeBuffer(activeSideBySideState.oldBufferId);
1648
+ editor.closeBuffer(activeSideBySideState.newBufferId);
1649
+ } catch {}
1650
+ activeSideBySideState = null;
1651
+ }
1652
+
1653
+ // Get the current split ID
1654
+ const currentSplitId = (editor as any).getActiveSplitId();
1655
+
1656
+ // Create OLD buffer in the CURRENT split
1657
+ const oldBufferId = await editor.createVirtualBufferInExistingSplit({
1658
+ name: `[OLD] ${filePath}`,
1659
+ mode: "diff-view",
1660
+ read_only: true,
1661
+ editing_disabled: true,
1662
+ entries: oldPane.entries,
1663
+ split_id: currentSplitId,
1664
+ show_line_numbers: false,
1665
+ line_wrap: false
1666
+ });
1667
+ const oldSplitId = currentSplitId;
1668
+
1669
+ // Apply highlights to old pane
1670
+ editor.clearNamespace(oldBufferId, "diff-view");
1671
+ for (const hl of oldPane.highlights) {
1672
+ const bg = hl.bg || [-1, -1, -1];
1673
+ editor.addOverlay(
1674
+ oldBufferId, "diff-view",
1675
+ hl.range[0], hl.range[1],
1676
+ hl.fg[0], hl.fg[1], hl.fg[2],
1677
+ false, hl.bold || false, false,
1678
+ bg[0], bg[1], bg[2],
1679
+ hl.extend_to_line_end || false
1680
+ );
1681
+ }
1682
+
1683
+ // Create NEW pane in a vertical split (RIGHT of OLD)
1684
+ const newRes = await editor.createVirtualBufferInSplit({
1685
+ name: `[NEW] ${filePath}`,
1686
+ mode: "diff-view",
1687
+ read_only: true,
1688
+ editing_disabled: true,
1689
+ entries: newPane.entries,
1690
+ ratio: 0.5,
1691
+ direction: "vertical",
1692
+ show_line_numbers: false,
1693
+ line_wrap: false
1694
+ });
1695
+ const newBufferId = newRes.buffer_id;
1696
+ const newSplitId = newRes.split_id!;
1697
+
1698
+ // Apply highlights to new pane
1699
+ editor.clearNamespace(newBufferId, "diff-view");
1700
+ for (const hl of newPane.highlights) {
1701
+ const bg = hl.bg || [-1, -1, -1];
1702
+ editor.addOverlay(
1703
+ newBufferId, "diff-view",
1704
+ hl.range[0], hl.range[1],
1705
+ hl.fg[0], hl.fg[1], hl.fg[2],
1706
+ false, hl.bold || false, false,
1707
+ bg[0], bg[1], bg[2],
1708
+ hl.extend_to_line_end || false
1709
+ );
1710
+ }
1711
+
1712
+ // Focus OLD pane (left)
1713
+ (editor as any).focusSplit(oldSplitId);
1714
+
1715
+ // Set up scroll sync
1716
+ let scrollSyncGroupId: number | null = null;
1717
+ try {
1718
+ scrollSyncGroupId = nextScrollSyncGroupId++;
1719
+ (editor as any).createScrollSyncGroup(scrollSyncGroupId, oldSplitId, newSplitId);
1720
+
1721
+ const anchors: [number, number][] = [];
1722
+ for (let i = 0; i < alignedLines.length; i++) {
1723
+ const line = alignedLines[i];
1724
+ const prevLine = i > 0 ? alignedLines[i - 1] : null;
1725
+ if (i === 0) anchors.push([0, 0]);
1726
+ if (prevLine && prevLine.changeType !== line.changeType) {
1727
+ anchors.push([i, i]);
1728
+ }
1729
+ }
1730
+ if (alignedLines.length > 0) {
1731
+ anchors.push([alignedLines.length, alignedLines.length]);
1732
+ }
1733
+ (editor as any).setScrollSyncAnchors(scrollSyncGroupId, anchors);
1734
+ } catch (e) {
1735
+ editor.debug(`Failed to create scroll sync group: ${e}`);
1736
+ scrollSyncGroupId = null;
1737
+ }
1738
+
1739
+ // Store state
1740
+ activeSideBySideState = {
1741
+ oldSplitId,
1742
+ newSplitId,
1743
+ oldBufferId,
1744
+ newBufferId,
1745
+ alignedLines,
1746
+ oldLineByteOffsets: oldPane.lineByteOffsets,
1747
+ newLineByteOffsets: newPane.lineByteOffsets,
1748
+ scrollSyncGroupId
1749
+ };
1750
+ activeDiffViewState = { lSplit: oldSplitId, rSplit: newSplitId };
1751
+
1752
+ const addedLines = alignedLines.filter(l => l.changeType === 'added').length;
1753
+ const removedLines = alignedLines.filter(l => l.changeType === 'removed').length;
1754
+ const modifiedLines = alignedLines.filter(l => l.changeType === 'modified').length;
1755
+ editor.setStatus(editor.t("status.diff_summary", { added: String(addedLines), removed: String(removedLines), modified: String(modifiedLines) }));
1756
+ };
1757
+
1009
1758
  // Register Modes and Commands
1010
- editor.registerCommand("Review Diff", "Start code review session", "start_review_diff", "global");
1011
- editor.registerCommand("Stop Review Diff", "Stop the review session", "stop_review_diff", "review-mode");
1012
- editor.registerCommand("Refresh Review Diff", "Refresh the list of changes", "review_refresh", "review-mode");
1759
+ editor.registerCommand("%cmd.review_diff", "%cmd.review_diff_desc", "start_review_diff", "global");
1760
+ editor.registerCommand("%cmd.stop_review_diff", "%cmd.stop_review_diff_desc", "stop_review_diff", "review-mode");
1761
+ editor.registerCommand("%cmd.refresh_review_diff", "%cmd.refresh_review_diff_desc", "review_refresh", "review-mode");
1762
+ editor.registerCommand("%cmd.side_by_side_diff", "%cmd.side_by_side_diff_desc", "side_by_side_diff_current_file", "global");
1013
1763
 
1014
1764
  // Review Comment Commands
1015
- editor.registerCommand("Review: Add Comment", "Add a review comment to the current hunk", "review_add_comment", "review-mode");
1016
- editor.registerCommand("Review: Approve Hunk", "Mark hunk as approved", "review_approve_hunk", "review-mode");
1017
- editor.registerCommand("Review: Reject Hunk", "Mark hunk as rejected", "review_reject_hunk", "review-mode");
1018
- editor.registerCommand("Review: Needs Changes", "Mark hunk as needing changes", "review_needs_changes", "review-mode");
1019
- editor.registerCommand("Review: Question", "Mark hunk with a question", "review_question_hunk", "review-mode");
1020
- editor.registerCommand("Review: Clear Status", "Clear hunk review status", "review_clear_status", "review-mode");
1021
- editor.registerCommand("Review: Overall Feedback", "Set overall review feedback", "review_set_overall_feedback", "review-mode");
1022
- editor.registerCommand("Review: Export to Markdown", "Export review to .review/session.md", "review_export_session", "review-mode");
1023
- editor.registerCommand("Review: Export to JSON", "Export review to .review/session.json", "review_export_json", "review-mode");
1765
+ editor.registerCommand("%cmd.add_comment", "%cmd.add_comment_desc", "review_add_comment", "review-mode");
1766
+ editor.registerCommand("%cmd.approve_hunk", "%cmd.approve_hunk_desc", "review_approve_hunk", "review-mode");
1767
+ editor.registerCommand("%cmd.reject_hunk", "%cmd.reject_hunk_desc", "review_reject_hunk", "review-mode");
1768
+ editor.registerCommand("%cmd.needs_changes", "%cmd.needs_changes_desc", "review_needs_changes", "review-mode");
1769
+ editor.registerCommand("%cmd.question", "%cmd.question_desc", "review_question_hunk", "review-mode");
1770
+ editor.registerCommand("%cmd.clear_status", "%cmd.clear_status_desc", "review_clear_status", "review-mode");
1771
+ editor.registerCommand("%cmd.overall_feedback", "%cmd.overall_feedback_desc", "review_set_overall_feedback", "review-mode");
1772
+ editor.registerCommand("%cmd.export_markdown", "%cmd.export_markdown_desc", "review_export_session", "review-mode");
1773
+ editor.registerCommand("%cmd.export_json", "%cmd.export_json_desc", "review_export_json", "review-mode");
1774
+
1775
+ // Handler for when buffers are closed - cleans up scroll sync groups
1776
+ globalThis.on_buffer_closed = (data: any) => {
1777
+ // If one of the diff view buffers is closed, clean up the scroll sync group
1778
+ if (activeSideBySideState) {
1779
+ if (data.buffer_id === activeSideBySideState.oldBufferId ||
1780
+ data.buffer_id === activeSideBySideState.newBufferId) {
1781
+ // Remove scroll sync group
1782
+ if (activeSideBySideState.scrollSyncGroupId !== null) {
1783
+ try {
1784
+ (editor as any).removeScrollSyncGroup(activeSideBySideState.scrollSyncGroupId);
1785
+ } catch {}
1786
+ }
1787
+ activeSideBySideState = null;
1788
+ activeDiffViewState = null;
1789
+ }
1790
+ }
1791
+ };
1024
1792
 
1025
1793
  editor.on("buffer_closed", "on_buffer_closed");
1026
1794
 
@@ -1042,4 +1810,4 @@ editor.defineMode("review-mode", "normal", [
1042
1810
  ["E", "review_export_session"],
1043
1811
  ], true);
1044
1812
 
1045
- editor.debug("Review Diff plugin loaded with review comments support");
1813
+ editor.debug("Review Diff plugin loaded with review comments support");