diffstalker 0.1.6 → 0.2.0

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 (79) hide show
  1. package/.github/workflows/release.yml +5 -3
  2. package/CHANGELOG.md +36 -0
  3. package/bun.lock +378 -0
  4. package/dist/App.js +1162 -1
  5. package/dist/config.js +83 -2
  6. package/dist/core/ExplorerStateManager.js +266 -0
  7. package/dist/core/FilePathWatcher.js +133 -0
  8. package/dist/core/GitOperationQueue.js +109 -1
  9. package/dist/core/GitStateManager.js +525 -1
  10. package/dist/git/diff.js +471 -10
  11. package/dist/git/ignoreUtils.js +30 -0
  12. package/dist/git/status.js +237 -5
  13. package/dist/index.js +70 -16
  14. package/dist/ipc/CommandClient.js +165 -0
  15. package/dist/ipc/CommandServer.js +152 -0
  16. package/dist/services/commitService.js +22 -1
  17. package/dist/state/CommitFlowState.js +86 -0
  18. package/dist/state/UIState.js +182 -0
  19. package/dist/themes.js +127 -1
  20. package/dist/types/tabs.js +4 -0
  21. package/dist/ui/Layout.js +252 -0
  22. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  23. package/dist/ui/modals/DiscardConfirm.js +77 -0
  24. package/dist/ui/modals/HotkeysModal.js +209 -0
  25. package/dist/ui/modals/ThemePicker.js +107 -0
  26. package/dist/ui/widgets/CommitPanel.js +58 -0
  27. package/dist/ui/widgets/CompareListView.js +216 -0
  28. package/dist/ui/widgets/DiffView.js +279 -0
  29. package/dist/ui/widgets/ExplorerContent.js +102 -0
  30. package/dist/ui/widgets/ExplorerView.js +95 -0
  31. package/dist/ui/widgets/FileList.js +185 -0
  32. package/dist/ui/widgets/Footer.js +46 -0
  33. package/dist/ui/widgets/Header.js +111 -0
  34. package/dist/ui/widgets/HistoryView.js +69 -0
  35. package/dist/utils/ansiToBlessed.js +125 -0
  36. package/dist/utils/ansiTruncate.js +108 -0
  37. package/dist/utils/baseBranchCache.js +44 -2
  38. package/dist/utils/commitFormat.js +38 -1
  39. package/dist/utils/diffFilters.js +21 -1
  40. package/dist/utils/diffRowCalculations.js +113 -1
  41. package/dist/utils/displayRows.js +351 -2
  42. package/dist/utils/explorerDisplayRows.js +169 -0
  43. package/dist/utils/fileCategories.js +26 -1
  44. package/dist/utils/formatDate.js +39 -1
  45. package/dist/utils/formatPath.js +58 -1
  46. package/dist/utils/languageDetection.js +236 -0
  47. package/dist/utils/layoutCalculations.js +98 -1
  48. package/dist/utils/lineBreaking.js +88 -5
  49. package/dist/utils/mouseCoordinates.js +165 -1
  50. package/dist/utils/pathUtils.js +27 -0
  51. package/dist/utils/rowCalculations.js +246 -4
  52. package/dist/utils/wordDiff.js +50 -0
  53. package/package.json +15 -19
  54. package/dist/components/BaseBranchPicker.js +0 -1
  55. package/dist/components/BottomPane.js +0 -1
  56. package/dist/components/CommitPanel.js +0 -1
  57. package/dist/components/CompareListView.js +0 -1
  58. package/dist/components/ExplorerContentView.js +0 -3
  59. package/dist/components/ExplorerView.js +0 -1
  60. package/dist/components/FileList.js +0 -1
  61. package/dist/components/Footer.js +0 -1
  62. package/dist/components/Header.js +0 -1
  63. package/dist/components/HistoryView.js +0 -1
  64. package/dist/components/HotkeysModal.js +0 -1
  65. package/dist/components/Modal.js +0 -1
  66. package/dist/components/ScrollableList.js +0 -1
  67. package/dist/components/ThemePicker.js +0 -1
  68. package/dist/components/TopPane.js +0 -1
  69. package/dist/components/UnifiedDiffView.js +0 -1
  70. package/dist/hooks/useCommitFlow.js +0 -1
  71. package/dist/hooks/useCompareState.js +0 -1
  72. package/dist/hooks/useExplorerState.js +0 -9
  73. package/dist/hooks/useGit.js +0 -1
  74. package/dist/hooks/useHistoryState.js +0 -1
  75. package/dist/hooks/useKeymap.js +0 -1
  76. package/dist/hooks/useLayout.js +0 -1
  77. package/dist/hooks/useMouse.js +0 -1
  78. package/dist/hooks/useTerminalSize.js +0 -1
  79. package/dist/hooks/useWatcher.js +0 -11
@@ -1 +1,113 @@
1
- import{isDisplayableDiffLine as p}from"./diffFilters.js";import{getLineRowCount as m}from"./lineBreaking.js";export function getLineContent(t){return t.type==="addition"||t.type==="deletion"||t.type==="context"&&t.content.startsWith(" ")?t.content.slice(1):t.content}export function getLineNumWidth(t){let e=0;for(const o of t)o.oldLineNum&&o.oldLineNum>e&&(e=o.oldLineNum),o.newLineNum&&o.newLineNum>e&&(e=o.newLineNum);return Math.max(3,String(e).length)}export function getDiffLineWidth(t,e){if(t.type==="header"){if(t.content.startsWith("diff --git")){const n=t.content.match(/diff --git a\/.+ b\/(.+)$/);if(n)return n[1].length+6+2}return t.content.length+2}if(t.type==="hunk"){const n=t.content.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/);if(n){const c=parseInt(n[1],10),i=n[2]?parseInt(n[2],10):1,s=parseInt(n[3],10),f=n[4]?parseInt(n[4],10):1,u=n[5]?.trim()??"",a=c+i-1,d=s+f-1,g=i===1?`${c}`:`${c}-${a}`,h=f===1?`${s}`:`${s}-${d}`;return`Lines ${g} \u2192 ${h}`.length+(u?u.length+1:0)+2}return t.content.length+2}const r=getLineContent(t);return e+1+1+1+r.length+2}export function getDiffLineRowCount(t,e,o){if(o<=0||t.type==="header"||t.type==="hunk")return 1;const r=o-e-5;if(r<=0)return 1;const n=getLineContent(t);return m(n,r)}export function getDiffTotalRows(t,e,o){if(!t||e<=0)return 0;const r=t.lines.filter(p);if(r.length===0)return 0;const n=o??getLineNumWidth(r);let c=0;for(const i of r)c+=getDiffLineRowCount(i,n,e);return c}
1
+ /**
2
+ * Utilities for calculating how diff lines wrap in the terminal.
3
+ * Used for accurate scroll calculations when lines exceed terminal width.
4
+ */
5
+ import { isDisplayableDiffLine } from './diffFilters.js';
6
+ import { getLineRowCount } from './lineBreaking.js';
7
+ /**
8
+ * Get the content of a diff line without the leading +/-/space character.
9
+ */
10
+ export function getLineContent(line) {
11
+ if (line.type === 'addition' || line.type === 'deletion') {
12
+ return line.content.slice(1);
13
+ }
14
+ if (line.type === 'context' && line.content.startsWith(' ')) {
15
+ return line.content.slice(1);
16
+ }
17
+ return line.content;
18
+ }
19
+ /**
20
+ * Calculate the width of the line number column based on the largest line number.
21
+ */
22
+ export function getLineNumWidth(lines) {
23
+ let maxLineNum = 0;
24
+ for (const line of lines) {
25
+ if (line.oldLineNum && line.oldLineNum > maxLineNum)
26
+ maxLineNum = line.oldLineNum;
27
+ if (line.newLineNum && line.newLineNum > maxLineNum)
28
+ maxLineNum = line.newLineNum;
29
+ }
30
+ return Math.max(3, String(maxLineNum).length);
31
+ }
32
+ /**
33
+ * Calculate the rendered width of a diff line in terminal columns.
34
+ *
35
+ * Layout for content lines (addition/deletion/context):
36
+ * paddingX(1) + lineNum + space(1) + symbol(1) + space(1) + content + paddingX(1)
37
+ *
38
+ * Layout for headers:
39
+ * paddingX(1) + "── filename ──" or raw content + paddingX(1)
40
+ *
41
+ * Layout for hunk headers:
42
+ * paddingX(1) + "Lines X-Y → X-Y context" + paddingX(1)
43
+ */
44
+ export function getDiffLineWidth(line, lineNumWidth) {
45
+ const PADDING_X = 2; // 1 on each side
46
+ if (line.type === 'header') {
47
+ if (line.content.startsWith('diff --git')) {
48
+ const match = line.content.match(/diff --git a\/.+ b\/(.+)$/);
49
+ if (match) {
50
+ // "── " + filename + " ──" = 6 chars wrapper
51
+ return match[1].length + 6 + PADDING_X;
52
+ }
53
+ }
54
+ return line.content.length + PADDING_X;
55
+ }
56
+ if (line.type === 'hunk') {
57
+ const match = line.content.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/);
58
+ if (match) {
59
+ const oldStart = parseInt(match[1], 10);
60
+ const oldCount = match[2] ? parseInt(match[2], 10) : 1;
61
+ const newStart = parseInt(match[3], 10);
62
+ const newCount = match[4] ? parseInt(match[4], 10) : 1;
63
+ const context = match[5]?.trim() ?? '';
64
+ const oldEnd = oldStart + oldCount - 1;
65
+ const newEnd = newStart + newCount - 1;
66
+ const oldRange = oldCount === 1 ? `${oldStart}` : `${oldStart}-${oldEnd}`;
67
+ const newRange = newCount === 1 ? `${newStart}` : `${newStart}-${newEnd}`;
68
+ // "Lines X-Y → X-Y" + optional " context"
69
+ const rangeText = `Lines ${oldRange} → ${newRange}`;
70
+ return rangeText.length + (context ? context.length + 1 : 0) + PADDING_X;
71
+ }
72
+ return line.content.length + PADDING_X;
73
+ }
74
+ // Addition/Deletion/Context lines
75
+ // lineNum + space + symbol + space + content
76
+ const content = getLineContent(line);
77
+ return lineNumWidth + 1 + 1 + 1 + content.length + PADDING_X;
78
+ }
79
+ /**
80
+ * Calculate how many terminal rows a diff line will take when rendered.
81
+ * Uses the same line-breaking logic as the actual rendering for accuracy.
82
+ */
83
+ export function getDiffLineRowCount(line, lineNumWidth, terminalWidth) {
84
+ if (terminalWidth <= 0)
85
+ return 1;
86
+ // Headers and hunks are truncated, so they're always 1 row
87
+ if (line.type === 'header' || line.type === 'hunk') {
88
+ return 1;
89
+ }
90
+ // Content lines (addition/deletion/context) use manual line breaking
91
+ // Layout: paddingX(1) + lineNum + space(1) + symbol(1) + space(1) + content + paddingX(1)
92
+ const contentWidth = terminalWidth - lineNumWidth - 5;
93
+ if (contentWidth <= 0)
94
+ return 1;
95
+ const content = getLineContent(line);
96
+ return getLineRowCount(content, contentWidth);
97
+ }
98
+ /**
99
+ * Calculate the total number of terminal rows for a diff.
100
+ */
101
+ export function getDiffTotalRows(diff, terminalWidth, lineNumWidth) {
102
+ if (!diff || terminalWidth <= 0)
103
+ return 0;
104
+ const displayableLines = diff.lines.filter(isDisplayableDiffLine);
105
+ if (displayableLines.length === 0)
106
+ return 0;
107
+ const lnWidth = lineNumWidth ?? getLineNumWidth(displayableLines);
108
+ let totalRows = 0;
109
+ for (const line of displayableLines) {
110
+ totalRows += getDiffLineRowCount(line, lnWidth, terminalWidth);
111
+ }
112
+ return totalRows;
113
+ }
@@ -1,2 +1,351 @@
1
- import{formatDateAbsolute as d}from"./formatDate.js";import{isDisplayableDiffLine as h}from"./diffFilters.js";import{breakLine as m,getLineRowCount as l}from"./lineBreaking.js";function p(t){return t.type==="addition"||t.type==="deletion"||t.type==="context"&&t.content.startsWith(" ")?t.content.slice(1):t.content}function y(t){switch(t.type){case"header":return{type:"diff-header",content:t.content};case"hunk":return{type:"diff-hunk",content:t.content};case"addition":return{type:"diff-add",lineNum:t.newLineNum,content:p(t)};case"deletion":return{type:"diff-del",lineNum:t.oldLineNum,content:p(t)};case"context":return{type:"diff-context",lineNum:t.oldLineNum??t.newLineNum,content:p(t)}}}export function buildDiffDisplayRows(t){return t?t.lines.filter(h).map(y):[]}export function buildHistoryDisplayRows(t,o){const e=[];if(t){e.push({type:"commit-header",content:`commit ${t.hash}`}),e.push({type:"commit-header",content:`Author: ${t.author}`}),e.push({type:"commit-header",content:`Date: ${d(t.date)}`}),e.push({type:"spacer"});for(const f of t.message.split(`
2
- `))e.push({type:"commit-message",content:` ${f}`});e.push({type:"spacer"})}return e.push(...buildDiffDisplayRows(o)),e}export function buildCompareDisplayRows(t){if(!t||t.files.length===0)return[];const o=[];for(const e of t.files)o.push(...buildDiffDisplayRows(e.diff));return o}export function getDisplayRowsLineNumWidth(t){let o=0;for(const e of t)"lineNum"in e&&e.lineNum!==void 0&&(o=Math.max(o,e.lineNum));return Math.max(3,String(o).length)}export function wrapDisplayRows(t,o,e){if(!e)return t;const s=Math.max(10,o),i=[];for(const n of t)if(n.type==="diff-add"||n.type==="diff-del"||n.type==="diff-context"){const r=n.content;if(!r||r.length<=s){i.push(n);continue}const a=m(r,s);for(let u=0;u<a.length;u++){const c=a[u];i.push({...n,content:c.text,lineNum:c.isContinuation?void 0:n.lineNum,isContinuation:c.isContinuation})}}else i.push(n);return i}export function getWrappedRowCount(t,o,e){if(!e)return t.length;const s=Math.max(10,o);let i=0;for(const n of t)if(n.type==="diff-add"||n.type==="diff-del"||n.type==="diff-context"){const r=n.content;!r||r.length<=s?i+=1:i+=l(r,s)}else i+=1;return i}
1
+ // Unified row model for all diff views
2
+ // Every row = exactly 1 terminal row
3
+ import { formatDateAbsolute } from './formatDate.js';
4
+ import { isDisplayableDiffLine } from './diffFilters.js';
5
+ import { breakLine, getLineRowCount } from './lineBreaking.js';
6
+ import { computeWordDiff, areSimilarEnough } from './wordDiff.js';
7
+ import { getLanguageFromPath, highlightBlockPreserveBg, } from './languageDetection.js';
8
+ /**
9
+ * Get the text content from a diff line (strip leading +/-/space and control chars)
10
+ */
11
+ 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
+ }
23
+ // Strip control characters that cause rendering artifacts
24
+ // and convert tabs to spaces for consistent width calculation
25
+ return content.replace(/[\x00-\x08\x0a-\x1f\x7f]/g, '').replace(/\t/g, ' ');
26
+ }
27
+ /**
28
+ * Convert a DiffLine to a DisplayRow
29
+ */
30
+ function convertDiffLineToDisplayRow(line) {
31
+ switch (line.type) {
32
+ case 'header':
33
+ return { type: 'diff-header', content: line.content };
34
+ case 'hunk':
35
+ return { type: 'diff-hunk', content: line.content };
36
+ case 'addition':
37
+ return {
38
+ type: 'diff-add',
39
+ lineNum: line.newLineNum,
40
+ content: getLineContent(line),
41
+ };
42
+ case 'deletion':
43
+ return {
44
+ type: 'diff-del',
45
+ lineNum: line.oldLineNum,
46
+ content: getLineContent(line),
47
+ };
48
+ case 'context':
49
+ return {
50
+ type: 'diff-context',
51
+ lineNum: line.oldLineNum ?? line.newLineNum,
52
+ content: getLineContent(line),
53
+ };
54
+ }
55
+ }
56
+ /**
57
+ * Extract file path from a diff --git header line.
58
+ */
59
+ function extractFilePathFromHeader(content) {
60
+ const match = content.match(/^diff --git a\/.+ b\/(.+)$/);
61
+ return match ? match[1] : null;
62
+ }
63
+ /**
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.
68
+ */
69
+ export function buildDiffDisplayRows(diff) {
70
+ if (!diff)
71
+ return [];
72
+ const filteredLines = diff.lines.filter(isDisplayableDiffLine);
73
+ const rows = [];
74
+ const fileSections = [];
75
+ let currentSection = null;
76
+ // Phase 1: Build display rows WITHOUT highlighting
77
+ // Also collect content for block highlighting
78
+ let i = 0;
79
+ while (i < filteredLines.length) {
80
+ const line = filteredLines[i];
81
+ // Headers - start new file section
82
+ if (line.type === 'header') {
83
+ const filePath = extractFilePathFromHeader(line.content);
84
+ if (filePath) {
85
+ // Save previous section if any
86
+ if (currentSection) {
87
+ fileSections.push(currentSection);
88
+ // Add spacer between files for visual separation
89
+ rows.push({ type: 'spacer' });
90
+ }
91
+ // Start new section
92
+ currentSection = {
93
+ language: getLanguageFromPath(filePath),
94
+ startRowIndex: rows.length,
95
+ oldContent: [],
96
+ oldRowIndices: [],
97
+ newContent: [],
98
+ newRowIndices: [],
99
+ };
100
+ }
101
+ rows.push(convertDiffLineToDisplayRow(line));
102
+ i++;
103
+ continue;
104
+ }
105
+ // Hunks - just convert (don't highlight)
106
+ if (line.type === 'hunk') {
107
+ rows.push(convertDiffLineToDisplayRow(line));
108
+ i++;
109
+ continue;
110
+ }
111
+ // Context lines - add to both streams
112
+ if (line.type === 'context') {
113
+ const content = getLineContent(line);
114
+ const rowIndex = rows.length;
115
+ rows.push({
116
+ type: 'diff-context',
117
+ lineNum: line.oldLineNum ?? line.newLineNum,
118
+ content,
119
+ });
120
+ // Context appears in both old and new streams
121
+ if (currentSection && currentSection.language) {
122
+ currentSection.oldContent.push(content);
123
+ currentSection.oldRowIndices.push(rowIndex);
124
+ currentSection.newContent.push(content);
125
+ currentSection.newRowIndices.push(rowIndex);
126
+ }
127
+ i++;
128
+ continue;
129
+ }
130
+ // Collect consecutive deletions
131
+ const deletions = [];
132
+ while (i < filteredLines.length && filteredLines[i].type === 'deletion') {
133
+ deletions.push(filteredLines[i]);
134
+ i++;
135
+ }
136
+ // Collect consecutive additions (immediately following deletions)
137
+ const additions = [];
138
+ while (i < filteredLines.length && filteredLines[i].type === 'addition') {
139
+ additions.push(filteredLines[i]);
140
+ i++;
141
+ }
142
+ // Build arrays to store word diff segments for paired lines
143
+ const delSegmentsMap = new Map();
144
+ const addSegmentsMap = new Map();
145
+ // Pair deletions with additions for word-level diff (only if similar enough)
146
+ const pairCount = Math.min(deletions.length, additions.length);
147
+ for (let j = 0; j < pairCount; j++) {
148
+ const delContent = getLineContent(deletions[j]);
149
+ const addContent = getLineContent(additions[j]);
150
+ // Only compute word diff if lines are similar enough
151
+ if (areSimilarEnough(delContent, addContent)) {
152
+ const { oldSegments, newSegments } = computeWordDiff(delContent, addContent);
153
+ delSegmentsMap.set(j, oldSegments);
154
+ addSegmentsMap.set(j, newSegments);
155
+ }
156
+ }
157
+ // Output deletions first (preserving original diff order)
158
+ for (let j = 0; j < deletions.length; j++) {
159
+ const delLine = deletions[j];
160
+ const delContent = getLineContent(delLine);
161
+ const segments = delSegmentsMap.get(j);
162
+ const rowIndex = rows.length;
163
+ rows.push({
164
+ type: 'diff-del',
165
+ lineNum: delLine.oldLineNum,
166
+ content: delContent,
167
+ ...(segments && { wordDiffSegments: segments }),
168
+ });
169
+ // Add to old stream (only if no word-diff, as word-diff takes priority)
170
+ if (currentSection && currentSection.language && !segments) {
171
+ currentSection.oldContent.push(delContent);
172
+ currentSection.oldRowIndices.push(rowIndex);
173
+ }
174
+ }
175
+ // Then output additions
176
+ for (let j = 0; j < additions.length; j++) {
177
+ const addLine = additions[j];
178
+ const addContent = getLineContent(addLine);
179
+ const segments = addSegmentsMap.get(j);
180
+ const rowIndex = rows.length;
181
+ rows.push({
182
+ type: 'diff-add',
183
+ lineNum: addLine.newLineNum,
184
+ content: addContent,
185
+ ...(segments && { wordDiffSegments: segments }),
186
+ });
187
+ // Add to new stream (only if no word-diff, as word-diff takes priority)
188
+ if (currentSection && currentSection.language && !segments) {
189
+ currentSection.newContent.push(addContent);
190
+ currentSection.newRowIndices.push(rowIndex);
191
+ }
192
+ }
193
+ }
194
+ // Save final section
195
+ if (currentSection) {
196
+ fileSections.push(currentSection);
197
+ }
198
+ // Phase 2: Apply block highlighting for each file section
199
+ for (const section of fileSections) {
200
+ if (!section.language)
201
+ continue;
202
+ // Highlight old stream (context + deletions)
203
+ if (section.oldContent.length > 0) {
204
+ const oldHighlighted = highlightBlockPreserveBg(section.oldContent, section.language);
205
+ for (let j = 0; j < section.oldRowIndices.length; j++) {
206
+ const rowIndex = section.oldRowIndices[j];
207
+ const row = rows[rowIndex];
208
+ const highlighted = oldHighlighted[j];
209
+ // Only set highlighted if it's different from content
210
+ if (highlighted &&
211
+ highlighted !== row.content &&
212
+ (row.type === 'diff-del' || row.type === 'diff-context')) {
213
+ row.highlighted = highlighted;
214
+ }
215
+ }
216
+ }
217
+ // Highlight new stream (context + additions)
218
+ if (section.newContent.length > 0) {
219
+ const newHighlighted = highlightBlockPreserveBg(section.newContent, section.language);
220
+ for (let j = 0; j < section.newRowIndices.length; j++) {
221
+ const rowIndex = section.newRowIndices[j];
222
+ const row = rows[rowIndex];
223
+ const highlighted = newHighlighted[j];
224
+ // Only set highlighted if it's different from content
225
+ // Note: context lines appear in both streams, but result should be same
226
+ if (highlighted &&
227
+ highlighted !== row.content &&
228
+ (row.type === 'diff-add' || row.type === 'diff-context')) {
229
+ row.highlighted = highlighted;
230
+ }
231
+ }
232
+ }
233
+ }
234
+ return rows;
235
+ }
236
+ /**
237
+ * Build display rows from commit + diff (for History tab).
238
+ * Includes commit metadata, message, then diff lines.
239
+ */
240
+ export function buildHistoryDisplayRows(commit, diff) {
241
+ const rows = [];
242
+ if (commit) {
243
+ rows.push({ type: 'commit-header', content: `commit ${commit.hash}` });
244
+ rows.push({ type: 'commit-header', content: `Author: ${commit.author}` });
245
+ rows.push({ type: 'commit-header', content: `Date: ${formatDateAbsolute(commit.date)}` });
246
+ rows.push({ type: 'spacer' });
247
+ for (const line of commit.message.split('\n')) {
248
+ rows.push({ type: 'commit-message', content: ` ${line}` });
249
+ }
250
+ rows.push({ type: 'spacer' });
251
+ }
252
+ rows.push(...buildDiffDisplayRows(diff));
253
+ return rows;
254
+ }
255
+ /**
256
+ * Build display rows for compare view from CompareDiff.
257
+ * Combines all file diffs into a single DisplayRow array.
258
+ */
259
+ export function buildCompareDisplayRows(compareDiff) {
260
+ if (!compareDiff || compareDiff.files.length === 0) {
261
+ return [];
262
+ }
263
+ const rows = [];
264
+ for (const file of compareDiff.files) {
265
+ rows.push(...buildDiffDisplayRows(file.diff));
266
+ }
267
+ return rows;
268
+ }
269
+ /**
270
+ * Get the maximum line number width needed for alignment.
271
+ * Scans all rows with line numbers and returns the digit count.
272
+ */
273
+ export function getDisplayRowsLineNumWidth(rows) {
274
+ let max = 0;
275
+ for (const row of rows) {
276
+ if ('lineNum' in row && row.lineNum !== undefined) {
277
+ max = Math.max(max, row.lineNum);
278
+ }
279
+ }
280
+ return Math.max(3, String(max).length);
281
+ }
282
+ /**
283
+ * Expand display rows for wrap mode.
284
+ * Long content lines are broken into multiple rows with continuation markers.
285
+ * Headers, hunks, and metadata rows remain truncated (not wrapped).
286
+ *
287
+ * @param rows - Original display rows
288
+ * @param contentWidth - Available width for content (after line num, symbol, padding)
289
+ * @param wrapEnabled - Whether wrap mode is enabled
290
+ * @returns Array of rows, potentially expanded with continuations
291
+ */
292
+ export function wrapDisplayRows(rows, contentWidth, wrapEnabled) {
293
+ if (!wrapEnabled)
294
+ return rows;
295
+ // Minimum content width to prevent excessive segments
296
+ const minWidth = 10;
297
+ const effectiveWidth = Math.max(minWidth, contentWidth);
298
+ const result = [];
299
+ for (const row of rows) {
300
+ // Only wrap diff content lines (add, del, context)
301
+ if (row.type === 'diff-add' || row.type === 'diff-del' || row.type === 'diff-context') {
302
+ const content = row.content;
303
+ // Skip wrapping for empty or short content
304
+ if (!content || content.length <= effectiveWidth) {
305
+ result.push(row);
306
+ continue;
307
+ }
308
+ const segments = breakLine(content, effectiveWidth);
309
+ for (let i = 0; i < segments.length; i++) {
310
+ const segment = segments[i];
311
+ result.push({
312
+ ...row,
313
+ content: segment.text,
314
+ lineNum: segment.isContinuation ? undefined : row.lineNum,
315
+ isContinuation: segment.isContinuation,
316
+ });
317
+ }
318
+ }
319
+ else {
320
+ // Headers, hunks, commit metadata - don't wrap
321
+ result.push(row);
322
+ }
323
+ }
324
+ return result;
325
+ }
326
+ /**
327
+ * Calculate the total row count after wrapping.
328
+ * More efficient than wrapDisplayRows().length when you only need the count.
329
+ */
330
+ export function getWrappedRowCount(rows, contentWidth, wrapEnabled) {
331
+ if (!wrapEnabled)
332
+ return rows.length;
333
+ const minWidth = 10;
334
+ const effectiveWidth = Math.max(minWidth, contentWidth);
335
+ let count = 0;
336
+ for (const row of rows) {
337
+ if (row.type === 'diff-add' || row.type === 'diff-del' || row.type === 'diff-context') {
338
+ const content = row.content;
339
+ if (!content || content.length <= effectiveWidth) {
340
+ count += 1;
341
+ }
342
+ else {
343
+ count += getLineRowCount(content, effectiveWidth);
344
+ }
345
+ }
346
+ else {
347
+ count += 1;
348
+ }
349
+ }
350
+ return count;
351
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Row model for explorer file content view.
3
+ * Follows the same pattern as displayRows.ts - single source of truth
4
+ * for both rendering and scroll calculations.
5
+ */
6
+ import { breakLine, getLineRowCount } from './lineBreaking.js';
7
+ import { getLanguageFromPath, highlightLine } from './languageDetection.js';
8
+ /**
9
+ * Build display rows from file content with optional syntax highlighting.
10
+ * Each line becomes one row with line number and content.
11
+ *
12
+ * @param content - File content string
13
+ * @param filePath - File path for language detection
14
+ * @param truncated - Whether the file was truncated
15
+ * @returns Array of explorer content rows
16
+ */
17
+ export function buildExplorerContentRows(content, filePath, truncated) {
18
+ if (!content)
19
+ return [];
20
+ const lines = content.split('\n');
21
+ const rows = [];
22
+ // Detect language for highlighting
23
+ const language = filePath ? getLanguageFromPath(filePath) : null;
24
+ for (let i = 0; i < lines.length; i++) {
25
+ const line = lines[i];
26
+ const highlighted = language ? highlightLine(line, language) : undefined;
27
+ rows.push({
28
+ type: 'code',
29
+ lineNum: i + 1,
30
+ content: line,
31
+ highlighted,
32
+ });
33
+ }
34
+ // Add truncation indicator if needed
35
+ if (truncated) {
36
+ rows.push({
37
+ type: 'truncation',
38
+ content: '(file truncated)',
39
+ });
40
+ }
41
+ return rows;
42
+ }
43
+ /**
44
+ * Apply line wrapping to explorer content rows.
45
+ * Long lines are broken into multiple rows with continuation markers.
46
+ *
47
+ * @param rows - Original explorer content rows
48
+ * @param contentWidth - Available width for content (after line num and padding)
49
+ * @param wrapEnabled - Whether wrap mode is enabled
50
+ * @returns Array of rows, potentially expanded with continuations
51
+ */
52
+ export function wrapExplorerContentRows(rows, contentWidth, wrapEnabled) {
53
+ if (!wrapEnabled)
54
+ return rows;
55
+ const minWidth = 10;
56
+ const effectiveWidth = Math.max(minWidth, contentWidth);
57
+ const result = [];
58
+ for (const row of rows) {
59
+ if (row.type === 'code') {
60
+ const content = row.content;
61
+ // Skip wrapping for empty or short content
62
+ if (!content || content.length <= effectiveWidth) {
63
+ result.push(row);
64
+ continue;
65
+ }
66
+ // Break into segments
67
+ // Note: We don't wrap highlighted content because ANSI codes
68
+ // would be split, so we wrap the raw content and apply highlighting
69
+ // to each segment if available
70
+ const segments = breakLine(content, effectiveWidth);
71
+ for (let i = 0; i < segments.length; i++) {
72
+ const segment = segments[i];
73
+ result.push({
74
+ type: 'code',
75
+ lineNum: segment.isContinuation ? 0 : row.lineNum,
76
+ content: segment.text,
77
+ // Don't apply per-segment highlighting as it would be incorrect
78
+ // The renderer should handle continuation display
79
+ highlighted: undefined,
80
+ isContinuation: segment.isContinuation,
81
+ });
82
+ }
83
+ }
84
+ else {
85
+ // Truncation rows - don't wrap
86
+ result.push(row);
87
+ }
88
+ }
89
+ return result;
90
+ }
91
+ /**
92
+ * Calculate total row count after wrapping.
93
+ * More efficient than wrapExplorerContentRows().length when only count is needed.
94
+ *
95
+ * @param rows - Original explorer content rows
96
+ * @param contentWidth - Available width for content
97
+ * @param wrapEnabled - Whether wrap mode is enabled
98
+ * @returns Total number of rows after wrapping
99
+ */
100
+ export function getExplorerContentRowCount(rows, contentWidth, wrapEnabled) {
101
+ if (!wrapEnabled)
102
+ return rows.length;
103
+ const minWidth = 10;
104
+ const effectiveWidth = Math.max(minWidth, contentWidth);
105
+ let count = 0;
106
+ for (const row of rows) {
107
+ if (row.type === 'code') {
108
+ const content = row.content;
109
+ if (!content || content.length <= effectiveWidth) {
110
+ count += 1;
111
+ }
112
+ else {
113
+ count += getLineRowCount(content, effectiveWidth);
114
+ }
115
+ }
116
+ else {
117
+ count += 1;
118
+ }
119
+ }
120
+ return count;
121
+ }
122
+ /**
123
+ * Get the line number column width needed for alignment.
124
+ * Returns minimum width of 3 (for lines up to 999).
125
+ *
126
+ * @param rows - Explorer content rows
127
+ * @returns Width needed for line number column
128
+ */
129
+ export function getExplorerContentLineNumWidth(rows) {
130
+ let maxLineNum = 0;
131
+ for (const row of rows) {
132
+ if (row.type === 'code' && row.lineNum > maxLineNum) {
133
+ maxLineNum = row.lineNum;
134
+ }
135
+ }
136
+ return Math.max(3, String(maxLineNum).length);
137
+ }
138
+ /**
139
+ * Apply middle-dots visualization to content.
140
+ * Replaces leading spaces with middle-dot character (·) for indentation visibility.
141
+ *
142
+ * @param content - Line content
143
+ * @param enabled - Whether middle-dots mode is enabled
144
+ * @returns Content with leading spaces replaced by middle-dots
145
+ */
146
+ export function applyMiddleDots(content, enabled) {
147
+ if (!enabled || !content)
148
+ return content;
149
+ // Count leading spaces
150
+ let leadingSpaces = 0;
151
+ for (const char of content) {
152
+ if (char === ' ') {
153
+ leadingSpaces++;
154
+ }
155
+ else if (char === '\t') {
156
+ // Convert tab to equivalent spaces (using 2 spaces per tab)
157
+ leadingSpaces += 2;
158
+ }
159
+ else {
160
+ break;
161
+ }
162
+ }
163
+ if (leadingSpaces === 0)
164
+ return content;
165
+ // Replace leading whitespace with dots
166
+ const dots = '·'.repeat(leadingSpaces);
167
+ const rest = content.slice(leadingSpaces);
168
+ return dots + rest;
169
+ }
@@ -1 +1,26 @@
1
- export function categorizeFiles(e){const n=e.filter(t=>!t.staged&&t.status!=="untracked"),d=e.filter(t=>!t.staged&&t.status==="untracked"),o=e.filter(t=>t.staged);return{modified:n,untracked:d,staged:o,ordered:[...n,...d,...o]}}export function getFileListSectionCounts(e){const{modified:n,untracked:d,staged:o}=categorizeFiles(e);return{modifiedCount:n.length,untrackedCount:d.length,stagedCount:o.length}}
1
+ /**
2
+ * Categorize files into the three FileList sections.
3
+ * This is the single source of truth for file categorization.
4
+ */
5
+ export function categorizeFiles(files) {
6
+ const modified = files.filter((f) => !f.staged && f.status !== 'untracked');
7
+ const untracked = files.filter((f) => !f.staged && f.status === 'untracked');
8
+ const staged = files.filter((f) => f.staged);
9
+ return {
10
+ modified,
11
+ untracked,
12
+ staged,
13
+ ordered: [...modified, ...untracked, ...staged],
14
+ };
15
+ }
16
+ /**
17
+ * Get file counts for the 3 FileList sections.
18
+ */
19
+ export function getFileListSectionCounts(files) {
20
+ const { modified, untracked, staged } = categorizeFiles(files);
21
+ return {
22
+ modifiedCount: modified.length,
23
+ untrackedCount: untracked.length,
24
+ stagedCount: staged.length,
25
+ };
26
+ }