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,39 @@
1
- export function formatDate(t){const o=new Date().getTime()-t.getTime(),e=Math.floor(o/(1e3*60*60)),n=Math.floor(o/(1e3*60*60*24));return e<1?`${Math.floor(o/6e4)}m ago`:e<48?`${e}h ago`:n<=14?`${n}d ago`:t.toLocaleDateString("en-US",{month:"short",day:"numeric"})}export function formatDateAbsolute(t){return t.toLocaleString("en-US",{year:"numeric",month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}
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
+ }
@@ -1 +1,58 @@
1
- export function shortenPath(e,f){if(e.length<=f)return e;const i=Math.max(f,20);if(e.length<=i)return e;const t=e.split("/");if(t.length===1){const n=Math.floor((i-1)/2);return e.slice(0,n)+"\u2026"+e.slice(-(i-n-1))}const l=t[t.length-1],h=t[0],o="/\u2026/";if(h.length+o.length+l.length>i){const n=i-2;if(l.length>n){const r=Math.floor((n-1)/2);return"\u2026/"+l.slice(0,r)+"\u2026"+l.slice(-(n-r-1))}return"\u2026/"+l}let c=h,s=1;for(;s<t.length-1;){const n=t[s],r=c+"/"+n;if(r.length+o.length+l.length<=i)c=r,s++;else break}return s===t.length-1?e:c+o+l}
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 as h}from"./fileCategories.js";export{getFileListSectionCounts}from"./fileCategories.js";export function getFileListTotalRows(o){const{modifiedCount:t,untrackedCount:i,stagedCount:n}=h(o);let r=0;return t>0&&(r+=1+t),i>0&&(t>0&&(r+=1),r+=1+i),n>0&&((t>0||i>0)&&(r+=1),r+=1+n),r}export function calculatePaneHeights(o,t,i=.4){const n=getFileListTotalRows(o),r=3,a=Math.floor(t*i),s=Math.max(r,Math.min(n,a)),f=t-s;return{topPaneHeight:s,bottomPaneHeight:f}}export function getRowForFileIndex(o,t,i,n){let r=0;if(o<t)return 1+o;t>0&&(r+=1+t);const a=t;if(o<a+i){const g=o-a;return t>0&&(r+=1),r+1+g}i>0&&(t>0&&(r+=1),r+=1+i);const s=t+i,f=o-s;return(t>0||i>0)&&(r+=1),r+1+f}export function calculateScrollOffset(o,t,i){return o<t?Math.max(0,o-1):o>=t+i?o-i+1:t}
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
- export function breakLine(t,n,i=!0){if(n<=0)return[{text:t,isContinuation:!1}];if(t.length<=n)return[{text:t,isContinuation:!1}];const r=[];let e=t,o=!0;for(;e.length>0;){if(e.length<=n){r.push({text:e,isContinuation:!o});break}r.push({text:e.slice(0,n),isContinuation:!o}),e=e.slice(n),o=!1}return i&&l(t,n,r),r}function l(t,n,i){const r=i.map(e=>e.text).join("");if(r!==t)throw new Error(`[LineBreaking] Content was lost during breaking!
2
- Original (${t.length} chars): "${t.slice(0,50)}${t.length>50?"...":""}"
3
- Joined (${r.length} chars): "${r.slice(0,50)}${r.length>50?"...":""}"`);for(let e=0;e<i.length;e++){const o=i[e];if(o.text.length>n&&n>=1)throw new Error(`[LineBreaking] Segment ${e} exceeds maxWidth!
4
- Segment length: ${o.text.length}, maxWidth: ${n}
5
- Segment: "${o.text.slice(0,50)}${o.text.length>50?"...":""}"`)}if(i.length>0&&i[0].isContinuation)throw new Error("[LineBreaking] First segment incorrectly marked as continuation!");for(let e=1;e<i.length;e++)if(!i[e].isContinuation)throw new Error(`[LineBreaking] Segment ${e} should be marked as continuation but isn't!`)}export function getLineRowCount(t,n){return n<=0||t.length<=n?1:Math.ceil(t.length/n)}
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
+ }