diffstalker 0.2.2 → 0.2.4

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 (47) hide show
  1. package/.dependency-cruiser.cjs +2 -2
  2. package/dist/App.js +299 -664
  3. package/dist/KeyBindings.js +125 -39
  4. package/dist/ModalController.js +166 -0
  5. package/dist/MouseHandlers.js +43 -25
  6. package/dist/NavigationController.js +290 -0
  7. package/dist/StagingOperations.js +199 -0
  8. package/dist/config.js +39 -0
  9. package/dist/core/CompareManager.js +134 -0
  10. package/dist/core/ExplorerStateManager.js +27 -40
  11. package/dist/core/GitStateManager.js +28 -630
  12. package/dist/core/HistoryManager.js +72 -0
  13. package/dist/core/RemoteOperationManager.js +109 -0
  14. package/dist/core/WorkingTreeManager.js +412 -0
  15. package/dist/git/status.js +95 -0
  16. package/dist/index.js +59 -54
  17. package/dist/state/FocusRing.js +40 -0
  18. package/dist/state/UIState.js +82 -48
  19. package/dist/types/remote.js +5 -0
  20. package/dist/ui/PaneRenderers.js +11 -4
  21. package/dist/ui/modals/BaseBranchPicker.js +4 -7
  22. package/dist/ui/modals/CommitActionConfirm.js +66 -0
  23. package/dist/ui/modals/DiscardConfirm.js +4 -7
  24. package/dist/ui/modals/FileFinder.js +33 -27
  25. package/dist/ui/modals/HotkeysModal.js +32 -13
  26. package/dist/ui/modals/Modal.js +1 -0
  27. package/dist/ui/modals/RepoPicker.js +109 -0
  28. package/dist/ui/modals/ThemePicker.js +4 -7
  29. package/dist/ui/widgets/CommitPanel.js +52 -14
  30. package/dist/ui/widgets/CompareListView.js +1 -11
  31. package/dist/ui/widgets/DiffView.js +2 -27
  32. package/dist/ui/widgets/ExplorerContent.js +1 -4
  33. package/dist/ui/widgets/ExplorerView.js +1 -11
  34. package/dist/ui/widgets/FileList.js +2 -8
  35. package/dist/ui/widgets/Footer.js +1 -0
  36. package/dist/ui/widgets/Header.js +37 -3
  37. package/dist/utils/ansi.js +38 -0
  38. package/dist/utils/ansiTruncate.js +1 -5
  39. package/dist/utils/displayRows.js +72 -59
  40. package/dist/utils/fileCategories.js +7 -0
  41. package/dist/utils/fileResolution.js +23 -0
  42. package/dist/utils/languageDetection.js +3 -2
  43. package/dist/utils/logger.js +32 -0
  44. package/metrics/v0.2.3.json +243 -0
  45. package/metrics/v0.2.4.json +236 -0
  46. package/package.json +5 -2
  47. package/dist/utils/layoutCalculations.js +0 -100
@@ -0,0 +1,109 @@
1
+ import blessed from 'neo-blessed';
2
+ import { abbreviateHomePath } from '../../config.js';
3
+ /**
4
+ * RepoPicker modal for switching between recently-visited repositories.
5
+ */
6
+ export class RepoPicker {
7
+ box;
8
+ screen;
9
+ repos;
10
+ selectedIndex;
11
+ currentRepo;
12
+ onSelect;
13
+ onCancel;
14
+ constructor(screen, repos, currentRepo, onSelect, onCancel) {
15
+ this.screen = screen;
16
+ this.repos = repos;
17
+ this.currentRepo = currentRepo;
18
+ this.onSelect = onSelect;
19
+ this.onCancel = onCancel;
20
+ // Find current repo index
21
+ this.selectedIndex = repos.indexOf(currentRepo);
22
+ if (this.selectedIndex < 0)
23
+ this.selectedIndex = 0;
24
+ // Create modal box
25
+ const screenWidth = screen.width;
26
+ const width = Math.min(70, screenWidth - 4);
27
+ const maxVisibleRepos = Math.min(repos.length, 15);
28
+ const height = maxVisibleRepos + 6; // repos + header + footer + borders + padding
29
+ this.box = blessed.box({
30
+ parent: screen,
31
+ top: 'center',
32
+ left: 'center',
33
+ width,
34
+ height,
35
+ border: {
36
+ type: 'line',
37
+ },
38
+ style: {
39
+ border: {
40
+ fg: 'cyan',
41
+ },
42
+ },
43
+ tags: true,
44
+ keys: true,
45
+ scrollable: true,
46
+ alwaysScroll: true,
47
+ });
48
+ // Setup key handlers
49
+ this.setupKeyHandlers();
50
+ // Initial render
51
+ this.render();
52
+ }
53
+ setupKeyHandlers() {
54
+ this.box.key(['escape'], () => {
55
+ this.destroy();
56
+ this.onCancel();
57
+ });
58
+ this.box.key(['enter', 'space'], () => {
59
+ const selected = this.repos[this.selectedIndex];
60
+ if (selected) {
61
+ this.destroy();
62
+ this.onSelect(selected);
63
+ }
64
+ });
65
+ this.box.key(['up', 'k'], () => {
66
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
67
+ this.render();
68
+ });
69
+ this.box.key(['down', 'j'], () => {
70
+ this.selectedIndex = Math.min(this.repos.length - 1, this.selectedIndex + 1);
71
+ this.render();
72
+ });
73
+ }
74
+ render() {
75
+ const lines = [];
76
+ // Header
77
+ lines.push('{bold}{cyan-fg} Recent Repositories{/cyan-fg}{/bold}');
78
+ lines.push('');
79
+ if (this.repos.length === 0) {
80
+ lines.push('{gray-fg}No recent repositories{/gray-fg}');
81
+ }
82
+ else {
83
+ // Repo list
84
+ for (let i = 0; i < this.repos.length; i++) {
85
+ const repo = this.repos[i];
86
+ const isSelected = i === this.selectedIndex;
87
+ const isCurrent = repo === this.currentRepo;
88
+ let line = isSelected ? '{cyan-fg}{bold}> ' : ' ';
89
+ line += abbreviateHomePath(repo);
90
+ if (isSelected)
91
+ line += '{/bold}{/cyan-fg}';
92
+ if (isCurrent)
93
+ line += ' {gray-fg}(current){/gray-fg}';
94
+ lines.push(line);
95
+ }
96
+ }
97
+ // Footer
98
+ lines.push('');
99
+ lines.push('{gray-fg}j/k: navigate | Enter: select | Esc: cancel{/gray-fg}');
100
+ this.box.setContent(lines.join('\n'));
101
+ this.screen.render();
102
+ }
103
+ destroy() {
104
+ this.box.destroy();
105
+ }
106
+ focus() {
107
+ this.box.focus();
108
+ }
109
+ }
@@ -45,13 +45,13 @@ export class ThemePicker {
45
45
  this.render();
46
46
  }
47
47
  setupKeyHandlers() {
48
- this.box.key(['escape', 'q'], () => {
49
- this.close();
48
+ this.box.key(['escape'], () => {
49
+ this.destroy();
50
50
  this.onCancel();
51
51
  });
52
52
  this.box.key(['enter', 'space'], () => {
53
53
  const selected = themeOrder[this.selectedIndex];
54
- this.close();
54
+ this.destroy();
55
55
  this.onSelect(selected);
56
56
  });
57
57
  this.box.key(['up', 'k'], () => {
@@ -94,12 +94,9 @@ export class ThemePicker {
94
94
  this.box.setContent(lines.join('\n'));
95
95
  this.screen.render();
96
96
  }
97
- close() {
97
+ destroy() {
98
98
  this.box.destroy();
99
99
  }
100
- /**
101
- * Focus the modal.
102
- */
103
100
  focus() {
104
101
  this.box.focus();
105
102
  }
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Format the commit panel as blessed-compatible tagged string.
2
+ * Build all lines for the commit panel (used for both rendering and totalRows).
3
3
  */
4
- export function formatCommitPanel(state, stagedCount, width) {
4
+ export function buildCommitPanelLines(opts) {
5
+ const { state, stagedCount, width, focusedZone } = opts;
5
6
  const lines = [];
6
7
  // Title
7
8
  let title = '{bold}Commit Message{/bold}';
@@ -10,9 +11,9 @@ export function formatCommitPanel(state, stagedCount, width) {
10
11
  }
11
12
  lines.push(title);
12
13
  lines.push('');
13
- // Message input area
14
- const borderChar = '\u2502';
15
- const borderColor = state.inputFocused ? 'cyan' : 'gray';
14
+ // Message input area - cyan when zone-focused or input-focused
15
+ const messageFocused = state.inputFocused || focusedZone === 'commitMessage';
16
+ const borderColor = messageFocused ? 'cyan' : 'gray';
16
17
  // Top border
17
18
  const innerWidth = Math.max(20, width - 6);
18
19
  lines.push(`{${borderColor}-fg}\u250c${'─'.repeat(innerWidth + 2)}\u2510{/${borderColor}-fg}`);
@@ -24,14 +25,20 @@ export function formatCommitPanel(state, stagedCount, width) {
24
25
  const truncatedMessage = displayMessage.length > innerWidth
25
26
  ? displayMessage.slice(0, innerWidth - 1) + '\u2026'
26
27
  : displayMessage.padEnd(innerWidth);
27
- lines.push(`{${borderColor}-fg}${borderChar}{/${borderColor}-fg} ${messageColor}${truncatedMessage}${messageEnd} {${borderColor}-fg}${borderChar}{/${borderColor}-fg}`);
28
+ lines.push(`{${borderColor}-fg}\u2502{/${borderColor}-fg} ${messageColor}${truncatedMessage}${messageEnd} {${borderColor}-fg}\u2502{/${borderColor}-fg}`);
28
29
  // Bottom border
29
30
  lines.push(`{${borderColor}-fg}\u2514${'─'.repeat(innerWidth + 2)}\u2518{/${borderColor}-fg}`);
30
31
  lines.push('');
31
- // Amend checkbox
32
+ // Amend checkbox - cyan marker when zone-focused
33
+ const amendFocused = focusedZone === 'commitAmend';
32
34
  const checkbox = state.amend ? '[x]' : '[ ]';
33
- const checkboxColor = state.amend ? 'green' : 'gray';
34
- lines.push(`{${checkboxColor}-fg}${checkbox}{/${checkboxColor}-fg} Amend {gray-fg}(a){/gray-fg}`);
35
+ let checkboxColor = 'gray';
36
+ if (amendFocused)
37
+ checkboxColor = 'cyan';
38
+ else if (state.amend)
39
+ checkboxColor = 'green';
40
+ const amendPrefix = amendFocused ? '{cyan-fg}\u25b8 {/cyan-fg}' : ' ';
41
+ lines.push(`${amendPrefix}{${checkboxColor}-fg}${checkbox}{/${checkboxColor}-fg} Amend {gray-fg}(a){/gray-fg}`);
35
42
  // Error message
36
43
  if (state.error) {
37
44
  lines.push('');
@@ -43,12 +50,43 @@ export function formatCommitPanel(state, stagedCount, width) {
43
50
  lines.push('{yellow-fg}Committing...{/yellow-fg}');
44
51
  }
45
52
  lines.push('');
46
- // Help text
47
- const helpText = state.inputFocused
48
- ? 'Enter: commit | Esc: unfocus'
49
- : 'i/Enter: edit | Esc: cancel | a: toggle amend';
53
+ // Help text - context-sensitive based on focused zone
54
+ let helpText;
55
+ if (state.inputFocused) {
56
+ helpText = 'Enter: commit | Ctrl+a: amend | Esc: unfocus';
57
+ }
58
+ else if (focusedZone === 'commitMessage') {
59
+ helpText = 'Tab: next | Space: edit | a: amend';
60
+ }
61
+ else if (focusedZone === 'commitAmend') {
62
+ helpText = 'Tab: next | Space: toggle | Esc: back';
63
+ }
64
+ else {
65
+ helpText = 'i/Enter: edit | a: amend | Esc: back';
66
+ }
50
67
  lines.push(`{gray-fg}Staged: ${stagedCount} file(s) | ${helpText}{/gray-fg}`);
51
- return lines.join('\n');
68
+ return lines;
69
+ }
70
+ /**
71
+ * Get total row count for the commit panel (for scroll calculations).
72
+ */
73
+ export function getCommitPanelTotalRows(opts) {
74
+ return buildCommitPanelLines(opts).length;
75
+ }
76
+ /**
77
+ * Format the commit panel as blessed-compatible tagged string.
78
+ */
79
+ export function formatCommitPanel(state, stagedCount, width, scrollOffset = 0, visibleHeight, focusedZone) {
80
+ const allLines = buildCommitPanelLines({
81
+ state,
82
+ stagedCount,
83
+ width,
84
+ focusedZone,
85
+ });
86
+ if (visibleHeight && allLines.length > visibleHeight) {
87
+ return allLines.slice(scrollOffset, scrollOffset + visibleHeight).join('\n');
88
+ }
89
+ return allLines.join('\n');
52
90
  }
53
91
  /**
54
92
  * Format inactive commit panel.
@@ -1,17 +1,7 @@
1
1
  import { formatDate } from '../../utils/formatDate.js';
2
2
  import { formatCommitDisplay } from '../../utils/commitFormat.js';
3
3
  import { buildFileTree, flattenTree, buildTreePrefix } from '../../utils/fileTree.js';
4
- // ANSI escape codes for raw terminal output (avoids blessed tag escaping issues)
5
- const ANSI_RESET = '\x1b[0m';
6
- const ANSI_BOLD = '\x1b[1m';
7
- const ANSI_GRAY = '\x1b[90m';
8
- const ANSI_CYAN = '\x1b[36m';
9
- const ANSI_YELLOW = '\x1b[33m';
10
- const ANSI_GREEN = '\x1b[32m';
11
- const ANSI_RED = '\x1b[31m';
12
- const ANSI_BLUE = '\x1b[34m';
13
- const ANSI_MAGENTA = '\x1b[35m';
14
- const ANSI_INVERSE = '\x1b[7m';
4
+ import { ANSI_RESET, ANSI_BOLD, ANSI_GRAY, ANSI_CYAN, ANSI_YELLOW, ANSI_GREEN, ANSI_RED, ANSI_BLUE, ANSI_MAGENTA, ANSI_INVERSE, } from '../../utils/ansi.js';
15
5
  /**
16
6
  * Build the list of row items for the compare list view.
17
7
  */
@@ -1,14 +1,7 @@
1
1
  import { getTheme } from '../../themes.js';
2
2
  import { buildDiffDisplayRows, buildCombinedDiffDisplayRows, buildHistoryDisplayRows, getDisplayRowsLineNumWidth, wrapDisplayRows, getHunkBoundaries, } from '../../utils/displayRows.js';
3
3
  import { truncateAnsi } from '../../utils/ansiTruncate.js';
4
- // ANSI escape codes for raw terminal output (avoids blessed tag escaping issues)
5
- const ANSI_RESET = '\x1b[0m';
6
- const ANSI_BOLD = '\x1b[1m';
7
- const ANSI_GRAY = '\x1b[90m';
8
- const ANSI_CYAN = '\x1b[36m';
9
- const ANSI_GREEN = '\x1b[32m';
10
- const ANSI_YELLOW = '\x1b[33m';
11
- const ANSI_INVERSE = '\x1b[7m';
4
+ import { ANSI_RESET, ANSI_BOLD, ANSI_GRAY, ANSI_CYAN, ANSI_GREEN, ANSI_YELLOW, ANSI_INVERSE, ansiBg, ansiFg, } from '../../utils/ansi.js';
12
5
  /**
13
6
  * Truncate string to fit within maxWidth, adding ellipsis if needed.
14
7
  */
@@ -33,24 +26,6 @@ function formatLineNum(lineNum, width) {
33
26
  function escapeContent(content) {
34
27
  return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
35
28
  }
36
- /**
37
- * Build raw ANSI escape sequence for 24-bit RGB background.
38
- */
39
- function ansiBg(hex) {
40
- const r = parseInt(hex.slice(1, 3), 16);
41
- const g = parseInt(hex.slice(3, 5), 16);
42
- const b = parseInt(hex.slice(5, 7), 16);
43
- return `\x1b[48;2;${r};${g};${b}m`;
44
- }
45
- /**
46
- * Build raw ANSI escape sequence for 24-bit RGB foreground.
47
- */
48
- function ansiFg(hex) {
49
- const r = parseInt(hex.slice(1, 3), 16);
50
- const g = parseInt(hex.slice(3, 5), 16);
51
- const b = parseInt(hex.slice(5, 7), 16);
52
- return `\x1b[38;2;${r};${g};${b}m`;
53
- }
54
29
  /**
55
30
  * Format a diff file header row (e.g. "── path/to/file ──").
56
31
  */
@@ -166,7 +141,7 @@ function formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, w
166
141
  const lineNum = formatLineNum(row.lineNum, lineNumWidth);
167
142
  const prefix = `${lineNum} ${symbol} `;
168
143
  const rawContent = row.content || '';
169
- const prefixAnsi = `\x1b[90m${prefix}\x1b[0m`;
144
+ const prefixAnsi = `${ANSI_GRAY}${prefix}${ANSI_RESET}`;
170
145
  if (row.highlighted && !isCont) {
171
146
  const content = wrapMode ? row.highlighted : truncateAnsi(row.highlighted, contentWidth);
172
147
  return `{escape}${prefixAnsi}${content}${ANSI_RESET}{/escape}`;
@@ -1,9 +1,6 @@
1
1
  import { buildExplorerContentRows, wrapExplorerContentRows, getExplorerContentRowCount, getExplorerContentLineNumWidth, } from '../../utils/explorerDisplayRows.js';
2
2
  import { truncateAnsi } from '../../utils/ansiTruncate.js';
3
- const ANSI_RESET = '\x1b[0m';
4
- const ANSI_GRAY = '\x1b[90m';
5
- const ANSI_CYAN = '\x1b[36m';
6
- const ANSI_YELLOW = '\x1b[33m';
3
+ import { ANSI_RESET, ANSI_GRAY, ANSI_CYAN, ANSI_YELLOW } from '../../utils/ansi.js';
7
4
  /**
8
5
  * Format explorer file content as blessed-compatible tagged string.
9
6
  */
@@ -1,14 +1,4 @@
1
- // ANSI escape codes
2
- const ANSI_RESET = '\x1b[0m';
3
- const ANSI_BOLD = '\x1b[1m';
4
- const ANSI_GRAY = '\x1b[90m';
5
- const ANSI_CYAN = '\x1b[36m';
6
- const ANSI_YELLOW = '\x1b[33m';
7
- const ANSI_GREEN = '\x1b[32m';
8
- const ANSI_RED = '\x1b[31m';
9
- const ANSI_BLUE = '\x1b[34m';
10
- const ANSI_MAGENTA = '\x1b[35m';
11
- const ANSI_INVERSE = '\x1b[7m';
1
+ import { ANSI_RESET, ANSI_BOLD, ANSI_GRAY, ANSI_CYAN, ANSI_YELLOW, ANSI_GREEN, ANSI_RED, ANSI_BLUE, ANSI_MAGENTA, ANSI_INVERSE, } from '../../utils/ansi.js';
12
2
  /**
13
3
  * Build tree prefix characters (│ ├ └).
14
4
  */
@@ -1,4 +1,4 @@
1
- import { categorizeFiles } from '../../utils/fileCategories.js';
1
+ import { categorizeFiles, getFileAtIndex } from '../../utils/fileCategories.js';
2
2
  import { getStatusChar, getStatusColor, formatStats, formatSelectionIndicator, formatFilePath, formatOriginalPath, } from './fileRowFormatters.js';
3
3
  /**
4
4
  * Build the list of row items for the file list.
@@ -119,13 +119,7 @@ export function formatFileList(files, selectedIndex, isFocused, width, scrollOff
119
119
  export function getFileListTotalRows(files) {
120
120
  return buildFileListRows(files).length;
121
121
  }
122
- /**
123
- * Get the file at a specific index (accounting for category ordering).
124
- */
125
- export function getFileAtIndex(files, index) {
126
- const { ordered } = categorizeFiles(files);
127
- return ordered[index] ?? null;
128
- }
122
+ export { getFileAtIndex };
129
123
  /**
130
124
  * Get the file index from a visual row (accounting for headers and spacers).
131
125
  * Returns null if the row is a header or spacer.
@@ -35,6 +35,7 @@ export function formatFooter(activeTab, mouseEnabled, autoTabEnabled, wrapMode,
35
35
  if (activeTab === 'diff' && currentPane === 'diff') {
36
36
  leftContent += ' {gray-fg}n/N:hunk s:toggle{/gray-fg}';
37
37
  }
38
+ leftContent += ' {gray-fg}Tab:next{/gray-fg}';
38
39
  // Right side: tabs
39
40
  const tabs = [
40
41
  { key: '1', label: 'Diff', tab: 'diff' },
@@ -38,7 +38,7 @@ function computeBranchVisibleLength(branch) {
38
38
  /**
39
39
  * Format header content as blessed-compatible tagged string.
40
40
  */
41
- export function formatHeader(repoPath, branch, isLoading, error, width) {
41
+ export function formatHeader(repoPath, branch, isLoading, error, width, remoteState) {
42
42
  if (!repoPath) {
43
43
  return '{gray-fg}Waiting for target path...{/gray-fg}';
44
44
  }
@@ -55,6 +55,39 @@ export function formatHeader(repoPath, branch, isLoading, error, width) {
55
55
  else if (error) {
56
56
  leftContent += ` {red-fg}(${error}){/red-fg}`;
57
57
  }
58
+ // Remote operation status (shown after left content)
59
+ let remoteStatus = '';
60
+ let remoteStatusLen = 0;
61
+ if (remoteState) {
62
+ if (remoteState.inProgress && remoteState.operation) {
63
+ const labels = {
64
+ push: 'pushing...',
65
+ fetch: 'fetching...',
66
+ pull: 'rebasing...',
67
+ stash: 'stashing...',
68
+ stashPop: 'popping stash...',
69
+ branchSwitch: 'switching branch...',
70
+ branchCreate: 'creating branch...',
71
+ softReset: 'resetting...',
72
+ cherryPick: 'cherry-picking...',
73
+ revert: 'reverting...',
74
+ };
75
+ const label = labels[remoteState.operation] ?? '';
76
+ remoteStatus = ` {yellow-fg}${label}{/yellow-fg}`;
77
+ remoteStatusLen = 1 + label.length;
78
+ }
79
+ else if (remoteState.error) {
80
+ const brief = remoteState.error.length > 40
81
+ ? remoteState.error.slice(0, 40) + '\u2026'
82
+ : remoteState.error;
83
+ remoteStatus = ` {red-fg}${brief}{/red-fg}`;
84
+ remoteStatusLen = 1 + brief.length;
85
+ }
86
+ else if (remoteState.lastResult) {
87
+ remoteStatus = ` {green-fg}${remoteState.lastResult}{/green-fg}`;
88
+ remoteStatusLen = 1 + remoteState.lastResult.length;
89
+ }
90
+ }
58
91
  // Build right side content (branch info)
59
92
  const rightContent = branch ? formatBranch(branch) : '';
60
93
  if (rightContent) {
@@ -68,9 +101,10 @@ export function formatHeader(repoPath, branch, isLoading, error, width) {
68
101
  else if (error) {
69
102
  leftLen += error.length + 3; // " (error)"
70
103
  }
104
+ leftLen += remoteStatusLen;
71
105
  const rightLen = branch ? computeBranchVisibleLength(branch) : 0;
72
106
  const padding = Math.max(1, width - leftLen - rightLen - 2);
73
- return leftContent + ' '.repeat(padding) + rightContent;
107
+ return leftContent + remoteStatus + ' '.repeat(padding) + rightContent;
74
108
  }
75
- return leftContent;
109
+ return leftContent + remoteStatus;
76
110
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Centralized ANSI escape code constants and helpers.
3
+ *
4
+ * All terminal color/style codes live here to avoid duplication across widgets.
5
+ * Terminal mode sequences (mouse mode, cursor visibility) are NOT included —
6
+ * those are a different concern and remain in index.ts.
7
+ */
8
+ // --- SGR 3/4-bit color and style constants ---
9
+ export const ANSI_RESET = '\x1b[0m';
10
+ export const ANSI_BOLD = '\x1b[1m';
11
+ export const ANSI_INVERSE = '\x1b[7m';
12
+ export const ANSI_RED = '\x1b[31m';
13
+ export const ANSI_GREEN = '\x1b[32m';
14
+ export const ANSI_YELLOW = '\x1b[33m';
15
+ export const ANSI_BLUE = '\x1b[34m';
16
+ export const ANSI_MAGENTA = '\x1b[35m';
17
+ export const ANSI_CYAN = '\x1b[36m';
18
+ export const ANSI_GRAY = '\x1b[90m';
19
+ /** Reset foreground color only (preserves background). */
20
+ export const ANSI_FG_RESET = '\x1b[39m';
21
+ // --- ANSI escape sequence pattern for parsing/stripping ---
22
+ /** Matches SGR sequences like \x1b[32m, \x1b[0m, \x1b[1;34m, \x1b[48;2;30;30;30m */
23
+ export const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
24
+ // --- 24-bit RGB helpers ---
25
+ /** Build ANSI escape for 24-bit RGB background from hex color (e.g. '#1e1e2e'). */
26
+ export function ansiBg(hex) {
27
+ const r = parseInt(hex.slice(1, 3), 16);
28
+ const g = parseInt(hex.slice(3, 5), 16);
29
+ const b = parseInt(hex.slice(5, 7), 16);
30
+ return `\x1b[48;2;${r};${g};${b}m`;
31
+ }
32
+ /** Build ANSI escape for 24-bit RGB foreground from hex color (e.g. '#e0e0e0'). */
33
+ export function ansiFg(hex) {
34
+ const r = parseInt(hex.slice(1, 3), 16);
35
+ const g = parseInt(hex.slice(3, 5), 16);
36
+ const b = parseInt(hex.slice(5, 7), 16);
37
+ return `\x1b[38;2;${r};${g};${b}m`;
38
+ }
@@ -4,11 +4,7 @@
4
4
  * Truncates strings containing ANSI escape codes at a visual character limit
5
5
  * while preserving formatting up to the truncation point.
6
6
  */
7
- // ANSI escape sequence pattern: ESC [ ... m
8
- // Matches sequences like \x1b[32m, \x1b[0m, \x1b[1;34m, etc.
9
- const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
10
- // ANSI reset sequence to clear all formatting
11
- const ANSI_RESET = '\x1b[0m';
7
+ import { ANSI_PATTERN, ANSI_RESET } from './ansi.js';
12
8
  /**
13
9
  * Calculate the visual length of a string (excluding ANSI codes).
14
10
  */
@@ -5,21 +5,12 @@ import { isDisplayableDiffLine } from './diffFilters.js';
5
5
  import { breakLine, getLineRowCount } from './lineBreaking.js';
6
6
  import { computeWordDiff, areSimilarEnough } from './wordDiff.js';
7
7
  import { getLanguageFromPath, highlightBlockPreserveBg } from './languageDetection.js';
8
+ import { getLineContent as extractLineContent } from './diffRowCalculations.js';
8
9
  /**
9
10
  * Get the text content from a diff line (strip leading +/-/space and control chars)
10
11
  */
11
12
  function getLineContent(line) {
12
- let content;
13
- if (line.type === 'addition' || line.type === 'deletion') {
14
- content = line.content.slice(1);
15
- }
16
- else if (line.type === 'context') {
17
- // Context lines start with space
18
- content = line.content.startsWith(' ') ? line.content.slice(1) : line.content;
19
- }
20
- else {
21
- content = line.content;
22
- }
13
+ const content = extractLineContent(line);
23
14
  // Strip control characters that cause rendering artifacts
24
15
  // and convert tabs to spaces for consistent width calculation
25
16
  return content.replace(/[\x00-\x08\x0a-\x1f\x7f]/g, '').replace(/\t/g, ' ');
@@ -60,20 +51,38 @@ function extractFilePathFromHeader(content) {
60
51
  const match = content.match(/^diff --git a\/.+ b\/(.+)$/);
61
52
  return match ? match[1] : null;
62
53
  }
54
+ /** Type guard for rows that can receive syntax highlighting. */
55
+ function isHighlightable(row) {
56
+ return row.type === 'diff-add' || row.type === 'diff-del' || row.type === 'diff-context';
57
+ }
63
58
  /**
64
- * Build display rows from a DiffResult.
65
- * Filters out non-displayable lines (index, ---, +++ headers).
66
- * Pairs consecutive deletions/additions within hunks and computes word-level diffs.
67
- * Applies block-based syntax highlighting to properly handle multi-line constructs.
59
+ * Pair consecutive deletions with additions and compute word-level diffs.
60
+ * Returns maps from pair index to word diff segments for each side.
68
61
  */
69
- export function buildDiffDisplayRows(diff) {
70
- if (!diff)
71
- return [];
72
- const filteredLines = diff.lines.filter(isDisplayableDiffLine);
62
+ function pairDeletionsAndAdditions(deletions, additions) {
63
+ const delSegmentsMap = new Map();
64
+ const addSegmentsMap = new Map();
65
+ const pairCount = Math.min(deletions.length, additions.length);
66
+ for (let j = 0; j < pairCount; j++) {
67
+ const delContent = getLineContent(deletions[j]);
68
+ const addContent = getLineContent(additions[j]);
69
+ if (areSimilarEnough(delContent, addContent)) {
70
+ const { oldSegments, newSegments } = computeWordDiff(delContent, addContent);
71
+ delSegmentsMap.set(j, oldSegments);
72
+ addSegmentsMap.set(j, newSegments);
73
+ }
74
+ }
75
+ return { delSegmentsMap, addSegmentsMap };
76
+ }
77
+ /**
78
+ * Build display rows from filtered diff lines in a single pass.
79
+ * Collects content streams per file section for later syntax highlighting.
80
+ * Pairs consecutive del/add lines for word-level diff computation.
81
+ */
82
+ function buildRawDiffRows(filteredLines) {
73
83
  const rows = [];
74
84
  const fileSections = [];
75
85
  let currentSection = null;
76
- // Phase 1: Build display rows and collect content streams per file section
77
86
  let i = 0;
78
87
  while (i < filteredLines.length) {
79
88
  const line = filteredLines[i];
@@ -134,18 +143,7 @@ export function buildDiffDisplayRows(diff) {
134
143
  i++;
135
144
  }
136
145
  // Pair deletions with additions for word-level diff
137
- const delSegmentsMap = new Map();
138
- const addSegmentsMap = new Map();
139
- const pairCount = Math.min(deletions.length, additions.length);
140
- for (let j = 0; j < pairCount; j++) {
141
- const delContent = getLineContent(deletions[j]);
142
- const addContent = getLineContent(additions[j]);
143
- if (areSimilarEnough(delContent, addContent)) {
144
- const { oldSegments, newSegments } = computeWordDiff(delContent, addContent);
145
- delSegmentsMap.set(j, oldSegments);
146
- addSegmentsMap.set(j, newSegments);
147
- }
148
- }
146
+ const { delSegmentsMap, addSegmentsMap } = pairDeletionsAndAdditions(deletions, additions);
149
147
  for (let j = 0; j < deletions.length; j++) {
150
148
  const delLine = deletions[j];
151
149
  const delContent = getLineContent(delLine);
@@ -182,37 +180,52 @@ export function buildDiffDisplayRows(diff) {
182
180
  if (currentSection) {
183
181
  fileSections.push(currentSection);
184
182
  }
185
- // Phase 2: Apply block highlighting for each file section
183
+ return { rows, fileSections };
184
+ }
185
+ /**
186
+ * Highlight a content stream and map results back to display rows.
187
+ * Only applies highlighting to rows that match the expected types.
188
+ */
189
+ function applyStreamHighlighting(rows, content, rowIndices, language, allowedTypes) {
190
+ if (content.length === 0)
191
+ return;
192
+ const highlighted = highlightBlockPreserveBg(content, language);
193
+ for (let j = 0; j < rowIndices.length; j++) {
194
+ const rowIndex = rowIndices[j];
195
+ const row = rows[rowIndex];
196
+ const hl = highlighted[j];
197
+ if (hl && isHighlightable(row) && allowedTypes.has(row.type) && hl !== row.content) {
198
+ row.highlighted = hl;
199
+ }
200
+ }
201
+ }
202
+ const OLD_STREAM_TYPES = new Set(['diff-del', 'diff-context']);
203
+ const NEW_STREAM_TYPES = new Set(['diff-add', 'diff-context']);
204
+ /**
205
+ * Apply block-based syntax highlighting to display rows.
206
+ * Each file section's old and new content streams are highlighted separately,
207
+ * then mapped back to the corresponding row indices.
208
+ */
209
+ function applySyntaxHighlighting(rows, fileSections) {
186
210
  for (const section of fileSections) {
187
211
  if (!section.language)
188
212
  continue;
189
- if (section.oldContent.length > 0) {
190
- const oldHighlighted = highlightBlockPreserveBg(section.oldContent, section.language);
191
- for (let j = 0; j < section.oldRowIndices.length; j++) {
192
- const rowIndex = section.oldRowIndices[j];
193
- const row = rows[rowIndex];
194
- const highlighted = oldHighlighted[j];
195
- if (highlighted &&
196
- highlighted !== row.content &&
197
- (row.type === 'diff-del' || row.type === 'diff-context')) {
198
- row.highlighted = highlighted;
199
- }
200
- }
201
- }
202
- if (section.newContent.length > 0) {
203
- const newHighlighted = highlightBlockPreserveBg(section.newContent, section.language);
204
- for (let j = 0; j < section.newRowIndices.length; j++) {
205
- const rowIndex = section.newRowIndices[j];
206
- const row = rows[rowIndex];
207
- const highlighted = newHighlighted[j];
208
- if (highlighted &&
209
- highlighted !== row.content &&
210
- (row.type === 'diff-add' || row.type === 'diff-context')) {
211
- row.highlighted = highlighted;
212
- }
213
- }
214
- }
213
+ applyStreamHighlighting(rows, section.oldContent, section.oldRowIndices, section.language, OLD_STREAM_TYPES);
214
+ applyStreamHighlighting(rows, section.newContent, section.newRowIndices, section.language, NEW_STREAM_TYPES);
215
215
  }
216
+ }
217
+ /**
218
+ * Build display rows from a DiffResult.
219
+ * Filters out non-displayable lines (index, ---, +++ headers).
220
+ * Pairs consecutive deletions/additions within hunks and computes word-level diffs.
221
+ * Applies block-based syntax highlighting to properly handle multi-line constructs.
222
+ */
223
+ export function buildDiffDisplayRows(diff) {
224
+ if (!diff)
225
+ return [];
226
+ const filteredLines = diff.lines.filter(isDisplayableDiffLine);
227
+ const { rows, fileSections } = buildRawDiffRows(filteredLines);
228
+ applySyntaxHighlighting(rows, fileSections);
216
229
  return rows;
217
230
  }
218
231
  /**
@@ -61,3 +61,10 @@ export function getIndexForCategoryPosition(files, category, categoryIndex) {
61
61
  };
62
62
  return offsets[category] + clampedIndex;
63
63
  }
64
+ /**
65
+ * Get the file at a specific index (accounting for category ordering).
66
+ */
67
+ export function getFileAtIndex(files, index) {
68
+ const { ordered } = categorizeFiles(files);
69
+ return ordered[index] ?? null;
70
+ }