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.
- package/.github/workflows/release.yml +5 -3
- package/CHANGELOG.md +36 -0
- package/bun.lock +378 -0
- package/dist/App.js +1162 -1
- package/dist/config.js +83 -2
- package/dist/core/ExplorerStateManager.js +266 -0
- package/dist/core/FilePathWatcher.js +133 -0
- package/dist/core/GitOperationQueue.js +109 -1
- package/dist/core/GitStateManager.js +525 -1
- package/dist/git/diff.js +471 -10
- package/dist/git/ignoreUtils.js +30 -0
- package/dist/git/status.js +237 -5
- package/dist/index.js +70 -16
- package/dist/ipc/CommandClient.js +165 -0
- package/dist/ipc/CommandServer.js +152 -0
- package/dist/services/commitService.js +22 -1
- package/dist/state/CommitFlowState.js +86 -0
- package/dist/state/UIState.js +182 -0
- package/dist/themes.js +127 -1
- package/dist/types/tabs.js +4 -0
- package/dist/ui/Layout.js +252 -0
- package/dist/ui/modals/BaseBranchPicker.js +110 -0
- package/dist/ui/modals/DiscardConfirm.js +77 -0
- package/dist/ui/modals/HotkeysModal.js +209 -0
- package/dist/ui/modals/ThemePicker.js +107 -0
- package/dist/ui/widgets/CommitPanel.js +58 -0
- package/dist/ui/widgets/CompareListView.js +216 -0
- package/dist/ui/widgets/DiffView.js +279 -0
- package/dist/ui/widgets/ExplorerContent.js +102 -0
- package/dist/ui/widgets/ExplorerView.js +95 -0
- package/dist/ui/widgets/FileList.js +185 -0
- package/dist/ui/widgets/Footer.js +46 -0
- package/dist/ui/widgets/Header.js +111 -0
- package/dist/ui/widgets/HistoryView.js +69 -0
- package/dist/utils/ansiToBlessed.js +125 -0
- package/dist/utils/ansiTruncate.js +108 -0
- package/dist/utils/baseBranchCache.js +44 -2
- package/dist/utils/commitFormat.js +38 -1
- package/dist/utils/diffFilters.js +21 -1
- package/dist/utils/diffRowCalculations.js +113 -1
- package/dist/utils/displayRows.js +351 -2
- package/dist/utils/explorerDisplayRows.js +169 -0
- package/dist/utils/fileCategories.js +26 -1
- package/dist/utils/formatDate.js +39 -1
- package/dist/utils/formatPath.js +58 -1
- package/dist/utils/languageDetection.js +236 -0
- package/dist/utils/layoutCalculations.js +98 -1
- package/dist/utils/lineBreaking.js +88 -5
- package/dist/utils/mouseCoordinates.js +165 -1
- package/dist/utils/pathUtils.js +27 -0
- package/dist/utils/rowCalculations.js +246 -4
- package/dist/utils/wordDiff.js +50 -0
- package/package.json +15 -19
- package/dist/components/BaseBranchPicker.js +0 -1
- package/dist/components/BottomPane.js +0 -1
- package/dist/components/CommitPanel.js +0 -1
- package/dist/components/CompareListView.js +0 -1
- package/dist/components/ExplorerContentView.js +0 -3
- package/dist/components/ExplorerView.js +0 -1
- package/dist/components/FileList.js +0 -1
- package/dist/components/Footer.js +0 -1
- package/dist/components/Header.js +0 -1
- package/dist/components/HistoryView.js +0 -1
- package/dist/components/HotkeysModal.js +0 -1
- package/dist/components/Modal.js +0 -1
- package/dist/components/ScrollableList.js +0 -1
- package/dist/components/ThemePicker.js +0 -1
- package/dist/components/TopPane.js +0 -1
- package/dist/components/UnifiedDiffView.js +0 -1
- package/dist/hooks/useCommitFlow.js +0 -1
- package/dist/hooks/useCompareState.js +0 -1
- package/dist/hooks/useExplorerState.js +0 -9
- package/dist/hooks/useGit.js +0 -1
- package/dist/hooks/useHistoryState.js +0 -1
- package/dist/hooks/useKeymap.js +0 -1
- package/dist/hooks/useLayout.js +0 -1
- package/dist/hooks/useMouse.js +0 -1
- package/dist/hooks/useTerminalSize.js +0 -1
- package/dist/hooks/useWatcher.js +0 -11
|
@@ -1 +1,113 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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
|
+
}
|