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
package/dist/utils/formatDate.js
CHANGED
|
@@ -1 +1,39 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Format a date relative to now:
|
|
3
|
+
* - Hours for first 48 hours (e.g., "3h ago", "47h ago")
|
|
4
|
+
* - Days for first 14 days (e.g., "3d ago")
|
|
5
|
+
* - Date after that (e.g., "Jan 15")
|
|
6
|
+
*/
|
|
7
|
+
export function formatDate(date) {
|
|
8
|
+
const now = new Date();
|
|
9
|
+
const diff = now.getTime() - date.getTime();
|
|
10
|
+
const hours = Math.floor(diff / (1000 * 60 * 60));
|
|
11
|
+
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
12
|
+
if (hours < 1) {
|
|
13
|
+
const mins = Math.floor(diff / (1000 * 60));
|
|
14
|
+
return `${mins}m ago`;
|
|
15
|
+
}
|
|
16
|
+
else if (hours < 48) {
|
|
17
|
+
return `${hours}h ago`;
|
|
18
|
+
}
|
|
19
|
+
else if (days <= 14) {
|
|
20
|
+
return `${days}d ago`;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Format a date as an absolute date/time string.
|
|
28
|
+
* Used for commit details where exact timestamp is needed.
|
|
29
|
+
* Example: "Jan 15, 2024, 10:30 AM"
|
|
30
|
+
*/
|
|
31
|
+
export function formatDateAbsolute(date) {
|
|
32
|
+
return date.toLocaleString('en-US', {
|
|
33
|
+
year: 'numeric',
|
|
34
|
+
month: 'short',
|
|
35
|
+
day: 'numeric',
|
|
36
|
+
hour: '2-digit',
|
|
37
|
+
minute: '2-digit',
|
|
38
|
+
});
|
|
39
|
+
}
|
package/dist/utils/formatPath.js
CHANGED
|
@@ -1 +1,58 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Shorten a file path to fit within maxLength by putting ellipsis in the middle.
|
|
3
|
+
* Keeps the first directory and filename visible as they're most informative.
|
|
4
|
+
*
|
|
5
|
+
* Example: "src/components/very/long/path/to/Component.tsx" (maxLength: 40)
|
|
6
|
+
* -> "src/components/…/Component.tsx"
|
|
7
|
+
*/
|
|
8
|
+
export function shortenPath(path, maxLength) {
|
|
9
|
+
if (path.length <= maxLength) {
|
|
10
|
+
return path;
|
|
11
|
+
}
|
|
12
|
+
// Minimum length we'll go (don't truncate too aggressively)
|
|
13
|
+
const minLength = 20;
|
|
14
|
+
const effectiveMax = Math.max(maxLength, minLength);
|
|
15
|
+
if (path.length <= effectiveMax) {
|
|
16
|
+
return path;
|
|
17
|
+
}
|
|
18
|
+
const parts = path.split('/');
|
|
19
|
+
// If it's just a filename with no directories, truncate the middle of the filename
|
|
20
|
+
if (parts.length === 1) {
|
|
21
|
+
const half = Math.floor((effectiveMax - 1) / 2);
|
|
22
|
+
return path.slice(0, half) + '…' + path.slice(-(effectiveMax - half - 1));
|
|
23
|
+
}
|
|
24
|
+
const filename = parts[parts.length - 1];
|
|
25
|
+
const firstPart = parts[0];
|
|
26
|
+
// Reserve space for: firstPart + "/…/" + filename
|
|
27
|
+
const ellipsis = '/…/';
|
|
28
|
+
const minRequired = firstPart.length + ellipsis.length + filename.length;
|
|
29
|
+
// If even the minimum doesn't fit, just show ellipsis + filename
|
|
30
|
+
if (minRequired > effectiveMax) {
|
|
31
|
+
const availableForFilename = effectiveMax - 2; // "…/"
|
|
32
|
+
if (filename.length > availableForFilename) {
|
|
33
|
+
// Truncate filename itself
|
|
34
|
+
const half = Math.floor((availableForFilename - 1) / 2);
|
|
35
|
+
return ('…/' + filename.slice(0, half) + '…' + filename.slice(-(availableForFilename - half - 1)));
|
|
36
|
+
}
|
|
37
|
+
return '…/' + filename;
|
|
38
|
+
}
|
|
39
|
+
// Try to include more path parts from the start
|
|
40
|
+
let prefix = firstPart;
|
|
41
|
+
let i = 1;
|
|
42
|
+
while (i < parts.length - 1) {
|
|
43
|
+
const nextPart = parts[i];
|
|
44
|
+
const candidate = prefix + '/' + nextPart;
|
|
45
|
+
if (candidate.length + ellipsis.length + filename.length <= effectiveMax) {
|
|
46
|
+
prefix = candidate;
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// If we included all parts, return original (shouldn't happen given length check)
|
|
54
|
+
if (i === parts.length - 1) {
|
|
55
|
+
return path;
|
|
56
|
+
}
|
|
57
|
+
return prefix + ellipsis + filename;
|
|
58
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language detection and syntax highlighting utilities for file content.
|
|
3
|
+
* Uses the emphasize package for ANSI terminal colors.
|
|
4
|
+
*/
|
|
5
|
+
import { createEmphasize } from 'emphasize';
|
|
6
|
+
import { common } from 'lowlight';
|
|
7
|
+
// Create emphasize instance with common languages
|
|
8
|
+
const emphasize = createEmphasize(common);
|
|
9
|
+
// Map file extensions to highlight.js language names
|
|
10
|
+
const EXTENSION_TO_LANGUAGE = {
|
|
11
|
+
// TypeScript/JavaScript
|
|
12
|
+
ts: 'typescript',
|
|
13
|
+
tsx: 'typescript',
|
|
14
|
+
js: 'javascript',
|
|
15
|
+
jsx: 'javascript',
|
|
16
|
+
mjs: 'javascript',
|
|
17
|
+
cjs: 'javascript',
|
|
18
|
+
// Web
|
|
19
|
+
html: 'xml',
|
|
20
|
+
htm: 'xml',
|
|
21
|
+
xml: 'xml',
|
|
22
|
+
svg: 'xml',
|
|
23
|
+
css: 'css',
|
|
24
|
+
scss: 'scss',
|
|
25
|
+
sass: 'scss',
|
|
26
|
+
less: 'less',
|
|
27
|
+
// Data formats
|
|
28
|
+
json: 'json',
|
|
29
|
+
yaml: 'yaml',
|
|
30
|
+
yml: 'yaml',
|
|
31
|
+
toml: 'ini',
|
|
32
|
+
// Shell/Config
|
|
33
|
+
sh: 'bash',
|
|
34
|
+
bash: 'bash',
|
|
35
|
+
zsh: 'bash',
|
|
36
|
+
fish: 'bash',
|
|
37
|
+
ps1: 'powershell',
|
|
38
|
+
bat: 'dos',
|
|
39
|
+
cmd: 'dos',
|
|
40
|
+
// Systems languages
|
|
41
|
+
c: 'c',
|
|
42
|
+
h: 'c',
|
|
43
|
+
cpp: 'cpp',
|
|
44
|
+
hpp: 'cpp',
|
|
45
|
+
cc: 'cpp',
|
|
46
|
+
cxx: 'cpp',
|
|
47
|
+
rs: 'rust',
|
|
48
|
+
go: 'go',
|
|
49
|
+
zig: 'zig',
|
|
50
|
+
// JVM
|
|
51
|
+
java: 'java',
|
|
52
|
+
kt: 'kotlin',
|
|
53
|
+
kts: 'kotlin',
|
|
54
|
+
scala: 'scala',
|
|
55
|
+
groovy: 'groovy',
|
|
56
|
+
gradle: 'groovy',
|
|
57
|
+
// Scripting
|
|
58
|
+
py: 'python',
|
|
59
|
+
rb: 'ruby',
|
|
60
|
+
pl: 'perl',
|
|
61
|
+
lua: 'lua',
|
|
62
|
+
php: 'php',
|
|
63
|
+
r: 'r',
|
|
64
|
+
// Functional
|
|
65
|
+
hs: 'haskell',
|
|
66
|
+
ml: 'ocaml',
|
|
67
|
+
fs: 'fsharp',
|
|
68
|
+
fsx: 'fsharp',
|
|
69
|
+
ex: 'elixir',
|
|
70
|
+
exs: 'elixir',
|
|
71
|
+
erl: 'erlang',
|
|
72
|
+
clj: 'clojure',
|
|
73
|
+
cljs: 'clojure',
|
|
74
|
+
// .NET
|
|
75
|
+
cs: 'csharp',
|
|
76
|
+
vb: 'vbnet',
|
|
77
|
+
// Documentation
|
|
78
|
+
md: 'markdown',
|
|
79
|
+
markdown: 'markdown',
|
|
80
|
+
rst: 'plaintext',
|
|
81
|
+
txt: 'plaintext',
|
|
82
|
+
// Config/Build
|
|
83
|
+
Makefile: 'makefile',
|
|
84
|
+
Dockerfile: 'dockerfile',
|
|
85
|
+
cmake: 'cmake',
|
|
86
|
+
ini: 'ini',
|
|
87
|
+
conf: 'ini',
|
|
88
|
+
cfg: 'ini',
|
|
89
|
+
// SQL
|
|
90
|
+
sql: 'sql',
|
|
91
|
+
// Other
|
|
92
|
+
vim: 'vim',
|
|
93
|
+
diff: 'diff',
|
|
94
|
+
patch: 'diff',
|
|
95
|
+
};
|
|
96
|
+
// Special filenames that map to languages
|
|
97
|
+
const FILENAME_TO_LANGUAGE = {
|
|
98
|
+
Makefile: 'makefile',
|
|
99
|
+
makefile: 'makefile',
|
|
100
|
+
GNUmakefile: 'makefile',
|
|
101
|
+
Dockerfile: 'dockerfile',
|
|
102
|
+
dockerfile: 'dockerfile',
|
|
103
|
+
Jenkinsfile: 'groovy',
|
|
104
|
+
Vagrantfile: 'ruby',
|
|
105
|
+
Gemfile: 'ruby',
|
|
106
|
+
Rakefile: 'ruby',
|
|
107
|
+
'.gitignore': 'plaintext',
|
|
108
|
+
'.gitattributes': 'plaintext',
|
|
109
|
+
'.editorconfig': 'ini',
|
|
110
|
+
'.prettierrc': 'json',
|
|
111
|
+
'.eslintrc': 'json',
|
|
112
|
+
'tsconfig.json': 'json',
|
|
113
|
+
'package.json': 'json',
|
|
114
|
+
'package-lock.json': 'json',
|
|
115
|
+
'bun.lockb': 'plaintext',
|
|
116
|
+
'yarn.lock': 'yaml',
|
|
117
|
+
'pnpm-lock.yaml': 'yaml',
|
|
118
|
+
'Cargo.toml': 'ini',
|
|
119
|
+
'Cargo.lock': 'ini',
|
|
120
|
+
'go.mod': 'go',
|
|
121
|
+
'go.sum': 'plaintext',
|
|
122
|
+
};
|
|
123
|
+
// Cache of available languages
|
|
124
|
+
let availableLanguages = null;
|
|
125
|
+
function getAvailableLanguages() {
|
|
126
|
+
if (!availableLanguages) {
|
|
127
|
+
availableLanguages = new Set(emphasize.listLanguages());
|
|
128
|
+
}
|
|
129
|
+
return availableLanguages;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Get the highlight.js language name from a file path.
|
|
133
|
+
* Returns null if the language cannot be determined or is not supported.
|
|
134
|
+
*/
|
|
135
|
+
export function getLanguageFromPath(filePath) {
|
|
136
|
+
if (!filePath)
|
|
137
|
+
return null;
|
|
138
|
+
// Check special filenames first
|
|
139
|
+
const filename = filePath.split('/').pop() ?? '';
|
|
140
|
+
if (FILENAME_TO_LANGUAGE[filename]) {
|
|
141
|
+
const lang = FILENAME_TO_LANGUAGE[filename];
|
|
142
|
+
return getAvailableLanguages().has(lang) ? lang : null;
|
|
143
|
+
}
|
|
144
|
+
// Get extension
|
|
145
|
+
const ext = filename.includes('.') ? filename.split('.').pop()?.toLowerCase() : null;
|
|
146
|
+
if (!ext)
|
|
147
|
+
return null;
|
|
148
|
+
const lang = EXTENSION_TO_LANGUAGE[ext];
|
|
149
|
+
if (!lang)
|
|
150
|
+
return null;
|
|
151
|
+
// Verify language is available
|
|
152
|
+
return getAvailableLanguages().has(lang) ? lang : null;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Apply syntax highlighting to a line of code.
|
|
156
|
+
* Returns the highlighted string with ANSI escape codes.
|
|
157
|
+
* If highlighting fails, returns the original content.
|
|
158
|
+
* Skips highlighting for lines that look like comments (heuristic for multi-line context).
|
|
159
|
+
*/
|
|
160
|
+
export function highlightLine(content, language) {
|
|
161
|
+
if (!content || !language)
|
|
162
|
+
return content;
|
|
163
|
+
try {
|
|
164
|
+
const result = emphasize.highlight(language, content);
|
|
165
|
+
return result.value;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// If highlighting fails, return original content
|
|
169
|
+
return content;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Apply syntax highlighting preserving background color.
|
|
174
|
+
* Replaces full ANSI resets with foreground-only resets so that
|
|
175
|
+
* the caller's background color is not cleared.
|
|
176
|
+
* Returns the highlighted string, or original content if highlighting fails.
|
|
177
|
+
*/
|
|
178
|
+
export function highlightLinePreserveBg(content, language) {
|
|
179
|
+
if (!content || !language)
|
|
180
|
+
return content;
|
|
181
|
+
try {
|
|
182
|
+
const result = emphasize.highlight(language, content);
|
|
183
|
+
// Replace full reset (\x1b[0m) with foreground-only reset (\x1b[39m)
|
|
184
|
+
// This preserves any background color set by the caller
|
|
185
|
+
return result.value.replace(/\x1b\[0m/g, '\x1b[39m');
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return content;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Highlight multiple lines as a block, preserving multi-line context
|
|
193
|
+
* (e.g., block comments, multi-line strings).
|
|
194
|
+
* Returns an array of highlighted lines.
|
|
195
|
+
*/
|
|
196
|
+
export function highlightBlock(lines, language) {
|
|
197
|
+
if (!language || lines.length === 0)
|
|
198
|
+
return lines;
|
|
199
|
+
try {
|
|
200
|
+
// Join lines and highlight as one block to preserve state
|
|
201
|
+
const block = lines.join('\n');
|
|
202
|
+
const result = emphasize.highlight(language, block);
|
|
203
|
+
return result.value.split('\n');
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return lines;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Highlight multiple lines as a block, preserving background color.
|
|
211
|
+
* Returns an array of highlighted lines with foreground-only resets.
|
|
212
|
+
*/
|
|
213
|
+
export function highlightBlockPreserveBg(lines, language) {
|
|
214
|
+
if (!language || lines.length === 0)
|
|
215
|
+
return lines;
|
|
216
|
+
try {
|
|
217
|
+
const block = lines.join('\n');
|
|
218
|
+
const result = emphasize.highlight(language, block);
|
|
219
|
+
// Replace full resets with foreground-only resets
|
|
220
|
+
const highlighted = result.value.replace(/\x1b\[0m/g, '\x1b[39m');
|
|
221
|
+
return highlighted.split('\n');
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return lines;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Apply syntax highlighting to multiple lines.
|
|
229
|
+
* More efficient than calling highlightLine for each line
|
|
230
|
+
* as it reuses the language detection.
|
|
231
|
+
*/
|
|
232
|
+
export function highlightLines(lines, language) {
|
|
233
|
+
if (!language || lines.length === 0)
|
|
234
|
+
return lines;
|
|
235
|
+
return lines.map((line) => highlightLine(line, language));
|
|
236
|
+
}
|
|
@@ -1 +1,98 @@
|
|
|
1
|
-
import{getFileListSectionCounts
|
|
1
|
+
import { getFileListSectionCounts } from './fileCategories.js';
|
|
2
|
+
// Re-export for backwards compatibility
|
|
3
|
+
export { getFileListSectionCounts } from './fileCategories.js';
|
|
4
|
+
/**
|
|
5
|
+
* Calculate total rows for the FileList component.
|
|
6
|
+
* Accounts for headers and spacers between sections.
|
|
7
|
+
*/
|
|
8
|
+
export function getFileListTotalRows(files) {
|
|
9
|
+
const { modifiedCount, untrackedCount, stagedCount } = getFileListSectionCounts(files);
|
|
10
|
+
let rows = 0;
|
|
11
|
+
// Modified section
|
|
12
|
+
if (modifiedCount > 0) {
|
|
13
|
+
rows += 1 + modifiedCount; // header + files
|
|
14
|
+
}
|
|
15
|
+
// Untracked section
|
|
16
|
+
if (untrackedCount > 0) {
|
|
17
|
+
if (modifiedCount > 0)
|
|
18
|
+
rows += 1; // spacer
|
|
19
|
+
rows += 1 + untrackedCount; // header + files
|
|
20
|
+
}
|
|
21
|
+
// Staged section
|
|
22
|
+
if (stagedCount > 0) {
|
|
23
|
+
if (modifiedCount > 0 || untrackedCount > 0)
|
|
24
|
+
rows += 1; // spacer
|
|
25
|
+
rows += 1 + stagedCount; // header + files
|
|
26
|
+
}
|
|
27
|
+
return rows;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Calculate the heights of the top (file list) and bottom (diff/commit/etc) panes
|
|
31
|
+
* based on the number of files and available content area.
|
|
32
|
+
*
|
|
33
|
+
* The top pane grows to fit files up to 40% of content height.
|
|
34
|
+
* The bottom pane gets the remaining space.
|
|
35
|
+
*/
|
|
36
|
+
export function calculatePaneHeights(files, contentHeight, maxTopRatio = 0.4) {
|
|
37
|
+
// Calculate content rows needed for staging area
|
|
38
|
+
// Uses getFileListTotalRows for consistency with FileList rendering
|
|
39
|
+
const neededRows = getFileListTotalRows(files);
|
|
40
|
+
// Minimum height of 3 (header + 2 lines for empty state)
|
|
41
|
+
const minHeight = 3;
|
|
42
|
+
// Maximum is maxTopRatio of content area
|
|
43
|
+
const maxHeight = Math.floor(contentHeight * maxTopRatio);
|
|
44
|
+
// Use the smaller of needed or max, but at least min
|
|
45
|
+
const topHeight = Math.max(minHeight, Math.min(neededRows, maxHeight));
|
|
46
|
+
const bottomHeight = contentHeight - topHeight;
|
|
47
|
+
return { topPaneHeight: topHeight, bottomPaneHeight: bottomHeight };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Calculate which row in the file list a file at a given index occupies.
|
|
51
|
+
* This accounts for headers and spacers in the list.
|
|
52
|
+
* File order: Modified → Untracked → Staged (matches FileList.tsx)
|
|
53
|
+
*/
|
|
54
|
+
export function getRowForFileIndex(selectedIndex, modifiedCount, untrackedCount, _stagedCount) {
|
|
55
|
+
let row = 0;
|
|
56
|
+
// Modified section
|
|
57
|
+
if (selectedIndex < modifiedCount) {
|
|
58
|
+
// In modified section: header + file rows
|
|
59
|
+
return 1 + selectedIndex;
|
|
60
|
+
}
|
|
61
|
+
if (modifiedCount > 0) {
|
|
62
|
+
row += 1 + modifiedCount; // header + files
|
|
63
|
+
}
|
|
64
|
+
// Untracked section
|
|
65
|
+
const untrackedStart = modifiedCount;
|
|
66
|
+
if (selectedIndex < untrackedStart + untrackedCount) {
|
|
67
|
+
// In untracked section
|
|
68
|
+
const untrackedIdx = selectedIndex - untrackedStart;
|
|
69
|
+
if (modifiedCount > 0)
|
|
70
|
+
row += 1; // spacer
|
|
71
|
+
return row + 1 + untrackedIdx; // header + file position
|
|
72
|
+
}
|
|
73
|
+
if (untrackedCount > 0) {
|
|
74
|
+
if (modifiedCount > 0)
|
|
75
|
+
row += 1; // spacer
|
|
76
|
+
row += 1 + untrackedCount; // header + files
|
|
77
|
+
}
|
|
78
|
+
// Staged section
|
|
79
|
+
const stagedStart = modifiedCount + untrackedCount;
|
|
80
|
+
const stagedIdx = selectedIndex - stagedStart;
|
|
81
|
+
if (modifiedCount > 0 || untrackedCount > 0)
|
|
82
|
+
row += 1; // spacer
|
|
83
|
+
return row + 1 + stagedIdx; // header + file position
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Calculate the scroll offset needed to keep a selected row visible.
|
|
87
|
+
*/
|
|
88
|
+
export function calculateScrollOffset(selectedRow, currentScrollOffset, visibleHeight) {
|
|
89
|
+
// Scroll up if selected is above visible area
|
|
90
|
+
if (selectedRow < currentScrollOffset) {
|
|
91
|
+
return Math.max(0, selectedRow - 1);
|
|
92
|
+
}
|
|
93
|
+
// Scroll down if selected is below visible area
|
|
94
|
+
else if (selectedRow >= currentScrollOffset + visibleHeight) {
|
|
95
|
+
return selectedRow - visibleHeight + 1;
|
|
96
|
+
}
|
|
97
|
+
return currentScrollOffset;
|
|
98
|
+
}
|
|
@@ -1,5 +1,88 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for manually breaking long lines at exact character boundaries.
|
|
3
|
+
* This gives us full control over line wrapping behavior in the diff view.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Break a string into segments that fit within the given width.
|
|
7
|
+
* Breaks at exact character boundaries for predictable, consistent output.
|
|
8
|
+
*
|
|
9
|
+
* @param content - The string to break
|
|
10
|
+
* @param maxWidth - Maximum width for each segment
|
|
11
|
+
* @param validate - If true, validates the result and throws on errors (default: true)
|
|
12
|
+
* @returns Array of line segments
|
|
13
|
+
*/
|
|
14
|
+
export function breakLine(content, maxWidth, validate = true) {
|
|
15
|
+
if (maxWidth <= 0) {
|
|
16
|
+
return [{ text: content, isContinuation: false }];
|
|
17
|
+
}
|
|
18
|
+
if (content.length <= maxWidth) {
|
|
19
|
+
return [{ text: content, isContinuation: false }];
|
|
20
|
+
}
|
|
21
|
+
const segments = [];
|
|
22
|
+
let remaining = content;
|
|
23
|
+
let isFirst = true;
|
|
24
|
+
while (remaining.length > 0) {
|
|
25
|
+
if (remaining.length <= maxWidth) {
|
|
26
|
+
segments.push({ text: remaining, isContinuation: !isFirst });
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
segments.push({
|
|
30
|
+
text: remaining.slice(0, maxWidth),
|
|
31
|
+
isContinuation: !isFirst,
|
|
32
|
+
});
|
|
33
|
+
remaining = remaining.slice(maxWidth);
|
|
34
|
+
isFirst = false;
|
|
35
|
+
}
|
|
36
|
+
// Validate the result
|
|
37
|
+
if (validate) {
|
|
38
|
+
validateBreakResult(content, maxWidth, segments);
|
|
39
|
+
}
|
|
40
|
+
return segments;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Validate that line breaking produced correct results.
|
|
44
|
+
* Throws an error if validation fails, making issues visible during development.
|
|
45
|
+
*/
|
|
46
|
+
function validateBreakResult(original, maxWidth, segments) {
|
|
47
|
+
// Check 1: Segments should join to equal original
|
|
48
|
+
const joined = segments.map((s) => s.text).join('');
|
|
49
|
+
if (joined !== original) {
|
|
50
|
+
throw new Error(`[LineBreaking] Content was lost during breaking!\n` +
|
|
51
|
+
`Original (${original.length} chars): "${original.slice(0, 50)}${original.length > 50 ? '...' : ''}"\n` +
|
|
52
|
+
`Joined (${joined.length} chars): "${joined.slice(0, 50)}${joined.length > 50 ? '...' : ''}"`);
|
|
53
|
+
}
|
|
54
|
+
// Check 2: No segment should exceed maxWidth (except if maxWidth is too small)
|
|
55
|
+
for (let i = 0; i < segments.length; i++) {
|
|
56
|
+
const segment = segments[i];
|
|
57
|
+
if (segment.text.length > maxWidth && maxWidth >= 1) {
|
|
58
|
+
throw new Error(`[LineBreaking] Segment ${i} exceeds maxWidth!\n` +
|
|
59
|
+
`Segment length: ${segment.text.length}, maxWidth: ${maxWidth}\n` +
|
|
60
|
+
`Segment: "${segment.text.slice(0, 50)}${segment.text.length > 50 ? '...' : ''}"`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Check 3: First segment should not be marked as continuation
|
|
64
|
+
if (segments.length > 0 && segments[0].isContinuation) {
|
|
65
|
+
throw new Error(`[LineBreaking] First segment incorrectly marked as continuation!`);
|
|
66
|
+
}
|
|
67
|
+
// Check 4: Subsequent segments should be marked as continuation
|
|
68
|
+
for (let i = 1; i < segments.length; i++) {
|
|
69
|
+
if (!segments[i].isContinuation) {
|
|
70
|
+
throw new Error(`[LineBreaking] Segment ${i} should be marked as continuation but isn't!`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Calculate how many visual rows a content string will take when broken.
|
|
76
|
+
*
|
|
77
|
+
* @param content - The string content
|
|
78
|
+
* @param maxWidth - Maximum width per row
|
|
79
|
+
* @returns Number of rows needed
|
|
80
|
+
*/
|
|
81
|
+
export function getLineRowCount(content, maxWidth) {
|
|
82
|
+
if (maxWidth <= 0)
|
|
83
|
+
return 1;
|
|
84
|
+
if (content.length <= maxWidth)
|
|
85
|
+
return 1;
|
|
86
|
+
// Simple math since we break at exact boundaries
|
|
87
|
+
return Math.ceil(content.length / maxWidth);
|
|
88
|
+
}
|