diffstalker 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.dependency-cruiser.cjs +2 -2
  2. package/.githooks/pre-push +2 -2
  3. package/.github/workflows/release.yml +3 -0
  4. package/CHANGELOG.md +6 -0
  5. package/dist/App.js +278 -758
  6. package/dist/KeyBindings.js +103 -91
  7. package/dist/ModalController.js +166 -0
  8. package/dist/MouseHandlers.js +37 -30
  9. package/dist/NavigationController.js +290 -0
  10. package/dist/StagingOperations.js +199 -0
  11. package/dist/config.js +39 -0
  12. package/dist/core/CompareManager.js +134 -0
  13. package/dist/core/ExplorerStateManager.js +7 -3
  14. package/dist/core/GitStateManager.js +28 -771
  15. package/dist/core/HistoryManager.js +72 -0
  16. package/dist/core/RemoteOperationManager.js +109 -0
  17. package/dist/core/WorkingTreeManager.js +412 -0
  18. package/dist/index.js +57 -57
  19. package/dist/state/FocusRing.js +40 -0
  20. package/dist/state/UIState.js +82 -48
  21. package/dist/ui/PaneRenderers.js +3 -6
  22. package/dist/ui/modals/BaseBranchPicker.js +4 -7
  23. package/dist/ui/modals/CommitActionConfirm.js +4 -4
  24. package/dist/ui/modals/DiscardConfirm.js +4 -7
  25. package/dist/ui/modals/FileFinder.js +3 -6
  26. package/dist/ui/modals/HotkeysModal.js +24 -21
  27. package/dist/ui/modals/Modal.js +1 -0
  28. package/dist/ui/modals/RepoPicker.js +109 -0
  29. package/dist/ui/modals/ThemePicker.js +4 -7
  30. package/dist/ui/widgets/CommitPanel.js +26 -94
  31. package/dist/ui/widgets/CompareListView.js +1 -11
  32. package/dist/ui/widgets/DiffView.js +2 -27
  33. package/dist/ui/widgets/ExplorerContent.js +1 -4
  34. package/dist/ui/widgets/ExplorerView.js +1 -11
  35. package/dist/ui/widgets/FileList.js +2 -8
  36. package/dist/ui/widgets/Footer.js +1 -0
  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.4.json +236 -0
  45. package/metrics/v0.2.5.json +236 -0
  46. package/package.json +1 -1
  47. package/dist/ui/modals/BranchPicker.js +0 -157
  48. package/dist/ui/modals/SoftResetConfirm.js +0 -68
  49. package/dist/ui/modals/StashListModal.js +0 -98
  50. package/dist/utils/layoutCalculations.js +0 -100
@@ -2,7 +2,7 @@
2
2
  * Build all lines for the commit panel (used for both rendering and totalRows).
3
3
  */
4
4
  export function buildCommitPanelLines(opts) {
5
- const { state, stagedCount, width, branch, remoteState, stashList, headCommit } = opts;
5
+ const { state, stagedCount, width, focusedZone } = opts;
6
6
  const lines = [];
7
7
  // Title
8
8
  let title = '{bold}Commit Message{/bold}';
@@ -11,8 +11,9 @@ export function buildCommitPanelLines(opts) {
11
11
  }
12
12
  lines.push(title);
13
13
  lines.push('');
14
- // Message input area
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}`);
@@ -28,10 +29,16 @@ export function buildCommitPanelLines(opts) {
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,93 +50,21 @@ export function buildCommitPanelLines(opts) {
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 | Ctrl+a: amend | Esc: unfocus'
49
- : 'i/Enter: edit | a: amend | Esc: back';
50
- lines.push(`{gray-fg}Staged: ${stagedCount} file(s) | ${helpText}{/gray-fg}`);
51
- // Stash section
52
- const stashEntries = stashList ?? [];
53
- lines.push('');
54
- lines.push(`{gray-fg}${'─'.repeat(3)} Stash (${stashEntries.length}) ${'─'.repeat(3)}{/gray-fg}`);
55
- if (stashEntries.length > 0) {
56
- const maxShow = 5;
57
- for (let i = 0; i < Math.min(stashEntries.length, maxShow); i++) {
58
- const entry = stashEntries[i];
59
- const msg = entry.message.length > width - 10
60
- ? entry.message.slice(0, width - 13) + '\u2026'
61
- : entry.message;
62
- lines.push(`{gray-fg}{${i}}{/gray-fg}: ${msg}`);
63
- }
64
- if (stashEntries.length > maxShow) {
65
- lines.push(`{gray-fg}... ${stashEntries.length - maxShow} more{/gray-fg}`);
66
- }
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';
67
57
  }
68
- else {
69
- lines.push('{gray-fg}(empty){/gray-fg}');
58
+ else if (focusedZone === 'commitMessage') {
59
+ helpText = 'Tab: next | Space: edit | a: amend';
70
60
  }
71
- lines.push('{gray-fg}S: save | o: pop | l: list{/gray-fg}');
72
- // Branch section
73
- if (branch) {
74
- lines.push('');
75
- lines.push(`{gray-fg}${'─'.repeat(3)} Branch ${'─'.repeat(3)}{/gray-fg}`);
76
- let branchLine = `{bold}* ${branch.current}{/bold}`;
77
- if (branch.tracking) {
78
- branchLine += ` {gray-fg}\u2192{/gray-fg} ${branch.tracking}`;
79
- }
80
- lines.push(branchLine);
81
- lines.push('{gray-fg}b: switch/create{/gray-fg}');
61
+ else if (focusedZone === 'commitAmend') {
62
+ helpText = 'Tab: next | Space: toggle | Esc: back';
82
63
  }
83
- // Undo section
84
- lines.push('');
85
- lines.push(`{gray-fg}${'─'.repeat(3)} Undo ${'─'.repeat(3)}{/gray-fg}`);
86
- if (headCommit) {
87
- lines.push(`{gray-fg}HEAD: {yellow-fg}${headCommit.shortHash}{/yellow-fg} ${headCommit.message}{/gray-fg}`);
88
- }
89
- lines.push('{gray-fg}X: soft reset HEAD~1{/gray-fg}');
90
- // Remote section
91
- if (branch) {
92
- lines.push('');
93
- lines.push(`{gray-fg}${'─'.repeat(3)} Remote ${'─'.repeat(3)}{/gray-fg}`);
94
- // Tracking info
95
- if (branch.tracking) {
96
- let tracking = `${branch.current} {gray-fg}\u2192{/gray-fg} ${branch.tracking}`;
97
- if (branch.ahead > 0)
98
- tracking += ` {green-fg}\u2191${branch.ahead}{/green-fg}`;
99
- if (branch.behind > 0)
100
- tracking += ` {red-fg}\u2193${branch.behind}{/red-fg}`;
101
- lines.push(tracking);
102
- }
103
- else {
104
- lines.push(`{gray-fg}${branch.current} (no remote tracking){/gray-fg}`);
105
- }
106
- // Remote status
107
- if (remoteState?.inProgress && remoteState.operation) {
108
- const labels = {
109
- push: 'Pushing...',
110
- fetch: 'Fetching...',
111
- pull: 'Rebasing...',
112
- stash: 'Stashing...',
113
- stashPop: 'Popping stash...',
114
- branchSwitch: 'Switching branch...',
115
- branchCreate: 'Creating branch...',
116
- softReset: 'Resetting...',
117
- cherryPick: 'Cherry-picking...',
118
- revert: 'Reverting...',
119
- };
120
- lines.push(`{yellow-fg}${labels[remoteState.operation] ?? ''}{/yellow-fg}`);
121
- }
122
- else if (remoteState?.error) {
123
- const brief = remoteState.error.length > 50
124
- ? remoteState.error.slice(0, 50) + '\u2026'
125
- : remoteState.error;
126
- lines.push(`{red-fg}${brief}{/red-fg}`);
127
- }
128
- else if (remoteState?.lastResult) {
129
- lines.push(`{green-fg}${remoteState.lastResult}{/green-fg}`);
130
- }
131
- lines.push('{gray-fg}P: push | F: fetch | R: pull --rebase{/gray-fg}');
64
+ else {
65
+ helpText = 'i/Enter: edit | a: amend | Esc: back';
132
66
  }
67
+ lines.push(`{gray-fg}Staged: ${stagedCount} file(s) | ${helpText}{/gray-fg}`);
133
68
  return lines;
134
69
  }
135
70
  /**
@@ -141,15 +76,12 @@ export function getCommitPanelTotalRows(opts) {
141
76
  /**
142
77
  * Format the commit panel as blessed-compatible tagged string.
143
78
  */
144
- export function formatCommitPanel(state, stagedCount, width, branch, remoteState, stashList, headCommit, scrollOffset = 0, visibleHeight) {
79
+ export function formatCommitPanel(state, stagedCount, width, scrollOffset = 0, visibleHeight, focusedZone) {
145
80
  const allLines = buildCommitPanelLines({
146
81
  state,
147
82
  stagedCount,
148
83
  width,
149
- branch,
150
- remoteState,
151
- stashList,
152
- headCommit,
84
+ focusedZone,
153
85
  });
154
86
  if (visibleHeight && allLines.length > visibleHeight) {
155
87
  return allLines.slice(scrollOffset, scrollOffset + visibleHeight).join('\n');
@@ -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' },
@@ -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
+ }
@@ -0,0 +1,23 @@
1
+ import { getFileAtIndex } from './fileCategories.js';
2
+ import { getFlatFileAtIndex } from './flatFileList.js';
3
+ /**
4
+ * Resolve a FileEntry from an index, abstracting over flat vs categorized mode.
5
+ * In flat mode, returns the unstaged entry (preferred) or staged entry.
6
+ * In categorized mode, returns the file at the categorized index.
7
+ */
8
+ export function resolveFileAtIndex(index, flatViewMode, flatFiles, files) {
9
+ if (flatViewMode) {
10
+ const flatEntry = getFlatFileAtIndex(flatFiles, index);
11
+ return flatEntry?.unstagedEntry ?? flatEntry?.stagedEntry ?? null;
12
+ }
13
+ return getFileAtIndex(files, index);
14
+ }
15
+ /**
16
+ * Get the maximum valid file index for the current view mode.
17
+ */
18
+ export function getFileListMaxIndex(flatViewMode, flatFiles, files) {
19
+ if (flatViewMode) {
20
+ return flatFiles.length - 1;
21
+ }
22
+ return files.length - 1;
23
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { createEmphasize } from 'emphasize';
6
6
  import { common } from 'lowlight';
7
+ import { ANSI_FG_RESET } from './ansi.js';
7
8
  // Create emphasize instance with common languages
8
9
  const emphasize = createEmphasize(common);
9
10
  // Map file extensions to highlight.js language names
@@ -182,7 +183,7 @@ export function highlightLinePreserveBg(content, language) {
182
183
  const result = emphasize.highlight(language, content);
183
184
  // Replace full reset (\x1b[0m) with foreground-only reset (\x1b[39m)
184
185
  // This preserves any background color set by the caller
185
- return result.value.replace(/\x1b\[0m/g, '\x1b[39m');
186
+ return result.value.replace(/\x1b\[0m/g, ANSI_FG_RESET);
186
187
  }
187
188
  catch {
188
189
  return content;
@@ -217,7 +218,7 @@ export function highlightBlockPreserveBg(lines, language) {
217
218
  const block = lines.join('\n');
218
219
  const result = emphasize.highlight(language, block);
219
220
  // Replace full resets with foreground-only resets
220
- const highlighted = result.value.replace(/\x1b\[0m/g, '\x1b[39m');
221
+ const highlighted = result.value.replace(/\x1b\[0m/g, ANSI_FG_RESET);
221
222
  return highlighted.split('\n');
222
223
  }
223
224
  catch {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Lightweight structured logger writing to stderr.
3
+ *
4
+ * - `debug()` is gated by `setDebug(true)` (set from the --debug flag)
5
+ * - `warn()` and `error()` always write to stderr
6
+ */
7
+ let debugEnabled = false;
8
+ export function setDebug(enabled) {
9
+ debugEnabled = enabled;
10
+ }
11
+ function timestamp() {
12
+ return new Date().toISOString();
13
+ }
14
+ export function debug(message) {
15
+ if (debugEnabled) {
16
+ process.stderr.write(`[diffstalker ${timestamp()}] ${message}\n`);
17
+ }
18
+ }
19
+ export function warn(message) {
20
+ process.stderr.write(`[diffstalker warn] ${message}\n`);
21
+ }
22
+ function formatError(err) {
23
+ if (err instanceof Error)
24
+ return err.message;
25
+ if (err)
26
+ return String(err);
27
+ return '';
28
+ }
29
+ export function error(message, err) {
30
+ const detail = err ? `: ${formatError(err)}` : '';
31
+ process.stderr.write(`[diffstalker error] ${message}${detail}\n`);
32
+ }