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
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Calculate visible length by stripping blessed tags.
3
+ */
4
+ function calculateVisibleLength(content) {
5
+ return content.replace(/\{[^}]+\}/g, '').length;
6
+ }
7
+ /**
8
+ * Format footer content as blessed-compatible tagged string.
9
+ */
10
+ export function formatFooter(activeTab, mouseEnabled, autoTabEnabled, wrapMode, showMiddleDots, width) {
11
+ // Left side: indicators
12
+ let leftContent = '{gray-fg}?{/gray-fg} ';
13
+ leftContent += mouseEnabled
14
+ ? '{yellow-fg}[scroll]{/yellow-fg}'
15
+ : '{yellow-fg}m:[select]{/yellow-fg}';
16
+ leftContent += ' ';
17
+ leftContent += autoTabEnabled ? '{blue-fg}[auto]{/blue-fg}' : '{gray-fg}[auto]{/gray-fg}';
18
+ leftContent += ' ';
19
+ leftContent += wrapMode ? '{blue-fg}[wrap]{/blue-fg}' : '{gray-fg}[wrap]{/gray-fg}';
20
+ if (activeTab === 'explorer') {
21
+ leftContent += ' ';
22
+ leftContent += showMiddleDots ? '{blue-fg}[dots]{/blue-fg}' : '{gray-fg}[dots]{/gray-fg}';
23
+ }
24
+ // Right side: tabs
25
+ const tabs = [
26
+ { key: '1', label: 'Diff', tab: 'diff' },
27
+ { key: '2', label: 'Commit', tab: 'commit' },
28
+ { key: '3', label: 'History', tab: 'history' },
29
+ { key: '4', label: 'Compare', tab: 'compare' },
30
+ { key: '5', label: 'Explorer', tab: 'explorer' },
31
+ ];
32
+ const rightContent = tabs
33
+ .map(({ key, label, tab }) => {
34
+ const isActive = activeTab === tab;
35
+ if (isActive) {
36
+ return `{bold}{cyan-fg}[${key}]${label}{/cyan-fg}{/bold}`;
37
+ }
38
+ return `[${key}]${label}`;
39
+ })
40
+ .join(' ');
41
+ // Calculate padding for right alignment
42
+ const leftLen = calculateVisibleLength(leftContent);
43
+ const rightLen = calculateVisibleLength(rightContent);
44
+ const padding = Math.max(1, width - leftLen - rightLen);
45
+ return leftContent + ' '.repeat(padding) + rightContent;
46
+ }
@@ -0,0 +1,111 @@
1
+ import { abbreviateHomePath } from '../../config.js';
2
+ /**
3
+ * Calculate header height based on content.
4
+ */
5
+ export function getHeaderHeight(repoPath, branch, watcherState, width, error = null, isLoading = false) {
6
+ if (!repoPath)
7
+ return 1;
8
+ const displayPath = abbreviateHomePath(repoPath);
9
+ const isNotGitRepo = error === 'Not a git repository';
10
+ // Calculate branch width
11
+ let branchWidth = 0;
12
+ if (branch) {
13
+ branchWidth = branch.current.length;
14
+ if (branch.tracking)
15
+ branchWidth += 3 + branch.tracking.length;
16
+ if (branch.ahead > 0)
17
+ branchWidth += 3 + String(branch.ahead).length;
18
+ if (branch.behind > 0)
19
+ branchWidth += 3 + String(branch.behind).length;
20
+ }
21
+ // Calculate left side width
22
+ let leftWidth = displayPath.length;
23
+ if (isLoading)
24
+ leftWidth += 2;
25
+ if (isNotGitRepo)
26
+ leftWidth += 24;
27
+ if (error && !isNotGitRepo)
28
+ leftWidth += error.length + 3;
29
+ // Check if follow indicator causes wrap
30
+ if (watcherState?.enabled && watcherState.sourceFile) {
31
+ const followPath = abbreviateHomePath(watcherState.sourceFile);
32
+ const fullFollow = ` (follow: ${followPath})`;
33
+ const availableOneLine = width - leftWidth - branchWidth - 4;
34
+ if (fullFollow.length > availableOneLine) {
35
+ const availableWithWrap = width - leftWidth - 2;
36
+ if (fullFollow.length <= availableWithWrap) {
37
+ return 2;
38
+ }
39
+ }
40
+ }
41
+ return 1;
42
+ }
43
+ /**
44
+ * Format branch info as blessed-compatible tagged string.
45
+ */
46
+ function formatBranch(branch) {
47
+ let result = `{bold}{green-fg}${branch.current}{/green-fg}{/bold}`;
48
+ if (branch.tracking) {
49
+ result += ` {gray-fg}\u2192{/gray-fg} {blue-fg}${branch.tracking}{/blue-fg}`;
50
+ }
51
+ if (branch.ahead > 0) {
52
+ result += ` {green-fg}\u2191${branch.ahead}{/green-fg}`;
53
+ }
54
+ if (branch.behind > 0) {
55
+ result += ` {red-fg}\u2193${branch.behind}{/red-fg}`;
56
+ }
57
+ return result;
58
+ }
59
+ /**
60
+ * Format header content as blessed-compatible tagged string.
61
+ */
62
+ export function formatHeader(repoPath, branch, isLoading, error, watcherState, width) {
63
+ if (!repoPath) {
64
+ return '{gray-fg}Waiting for target path...{/gray-fg}';
65
+ }
66
+ const displayPath = abbreviateHomePath(repoPath);
67
+ const isNotGitRepo = error === 'Not a git repository';
68
+ // Build left side content
69
+ let leftContent = `{bold}{cyan-fg}${displayPath}{/cyan-fg}{/bold}`;
70
+ if (isLoading) {
71
+ leftContent += ' {yellow-fg}\u27f3{/yellow-fg}';
72
+ }
73
+ if (isNotGitRepo) {
74
+ leftContent += ' {yellow-fg}(not a git repository){/yellow-fg}';
75
+ }
76
+ else if (error) {
77
+ leftContent += ` {red-fg}(${error}){/red-fg}`;
78
+ }
79
+ // Add follow indicator if enabled
80
+ if (watcherState?.enabled && watcherState.sourceFile) {
81
+ const followPath = abbreviateHomePath(watcherState.sourceFile);
82
+ leftContent += ` {gray-fg}(follow: ${followPath}){/gray-fg}`;
83
+ }
84
+ // Build right side content (branch info)
85
+ const rightContent = branch ? formatBranch(branch) : '';
86
+ if (rightContent) {
87
+ // Calculate visible text length for left side (excluding ANSI/tags)
88
+ let leftLen = displayPath.length;
89
+ if (isLoading)
90
+ leftLen += 2; // " ⟳"
91
+ if (isNotGitRepo) {
92
+ leftLen += 24; // " (not a git repository)"
93
+ }
94
+ else if (error) {
95
+ leftLen += error.length + 3; // " (error)"
96
+ }
97
+ if (watcherState?.enabled && watcherState.sourceFile) {
98
+ const followPath = abbreviateHomePath(watcherState.sourceFile);
99
+ leftLen += 10 + followPath.length; // " (follow: path)"
100
+ }
101
+ const rightLen = branch
102
+ ? branch.current.length +
103
+ (branch.tracking ? 3 + branch.tracking.length : 0) +
104
+ (branch.ahead > 0 ? 3 + String(branch.ahead).length : 0) +
105
+ (branch.behind > 0 ? 3 + String(branch.behind).length : 0)
106
+ : 0;
107
+ const padding = Math.max(1, width - leftLen - rightLen - 2);
108
+ return leftContent + ' '.repeat(padding) + rightContent;
109
+ }
110
+ return leftContent;
111
+ }
@@ -0,0 +1,69 @@
1
+ import { formatDate } from '../../utils/formatDate.js';
2
+ import { formatCommitDisplay } from '../../utils/commitFormat.js';
3
+ /**
4
+ * Format the history view as blessed-compatible tagged string.
5
+ */
6
+ export function formatHistoryView(commits, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight) {
7
+ if (commits.length === 0) {
8
+ return '{gray-fg}No commits yet{/gray-fg}';
9
+ }
10
+ // Apply scroll offset and max height
11
+ const visibleCommits = maxHeight
12
+ ? commits.slice(scrollOffset, scrollOffset + maxHeight)
13
+ : commits.slice(scrollOffset);
14
+ const lines = [];
15
+ for (let i = 0; i < visibleCommits.length; i++) {
16
+ const commit = visibleCommits[i];
17
+ const actualIndex = scrollOffset + i;
18
+ const isSelected = actualIndex === selectedIndex;
19
+ const isHighlighted = isSelected && isFocused;
20
+ const dateStr = formatDate(commit.date);
21
+ // Fixed parts: hash(7) + spaces(4) + date + parens(2) + selection indicator(2)
22
+ const baseWidth = 7 + 4 + dateStr.length + 2 + 2;
23
+ const remainingWidth = Math.max(10, width - baseWidth);
24
+ const { displayMessage, displayRefs } = formatCommitDisplay(commit.message, commit.refs, remainingWidth);
25
+ let line = '';
26
+ // Selection indicator
27
+ if (isHighlighted) {
28
+ line += '{cyan-fg}{bold}▸ {/bold}{/cyan-fg}';
29
+ }
30
+ else {
31
+ line += ' ';
32
+ }
33
+ // Short hash
34
+ line += `{yellow-fg}${commit.shortHash}{/yellow-fg} `;
35
+ // Message (with highlighting)
36
+ if (isHighlighted) {
37
+ line += `{cyan-fg}{inverse}${escapeContent(displayMessage)}{/inverse}{/cyan-fg}`;
38
+ }
39
+ else {
40
+ line += escapeContent(displayMessage);
41
+ }
42
+ // Date
43
+ line += ` {gray-fg}(${dateStr}){/gray-fg}`;
44
+ // Refs (branch names, tags)
45
+ if (displayRefs) {
46
+ line += ` {green-fg}${escapeContent(displayRefs)}{/green-fg}`;
47
+ }
48
+ lines.push(line);
49
+ }
50
+ return lines.join('\n');
51
+ }
52
+ /**
53
+ * Escape blessed tags in content.
54
+ */
55
+ function escapeContent(content) {
56
+ return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
57
+ }
58
+ /**
59
+ * Get the total number of rows in the history view (for scroll calculation).
60
+ */
61
+ export function getHistoryTotalRows(commits) {
62
+ return commits.length;
63
+ }
64
+ /**
65
+ * Get the commit at a specific index.
66
+ */
67
+ export function getCommitAtIndex(commits, index) {
68
+ return commits[index] ?? null;
69
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Convert ANSI escape codes to blessed tags.
3
+ * Supports basic foreground colors and styles.
4
+ */
5
+ // ANSI color code to blessed color name mapping
6
+ const ANSI_FG_COLORS = {
7
+ 30: 'black',
8
+ 31: 'red',
9
+ 32: 'green',
10
+ 33: 'yellow',
11
+ 34: 'blue',
12
+ 35: 'magenta',
13
+ 36: 'cyan',
14
+ 37: 'white',
15
+ 90: 'gray',
16
+ 91: 'red',
17
+ 92: 'green',
18
+ 93: 'yellow',
19
+ 94: 'blue',
20
+ 95: 'magenta',
21
+ 96: 'cyan',
22
+ 97: 'white',
23
+ };
24
+ /**
25
+ * Escape blessed tags in plain text.
26
+ */
27
+ function escapeBlessed(text) {
28
+ return text.replace(/\{/g, '{{').replace(/\}/g, '}}');
29
+ }
30
+ /**
31
+ * Convert ANSI escape sequences to blessed tags.
32
+ *
33
+ * @param input - String containing ANSI escape codes
34
+ * @returns String with blessed tags
35
+ */
36
+ export function ansiToBlessed(input) {
37
+ if (!input)
38
+ return '';
39
+ // Track current styles
40
+ const activeStyles = [];
41
+ let result = '';
42
+ let i = 0;
43
+ while (i < input.length) {
44
+ // Check for ANSI escape sequence
45
+ if (input[i] === '\x1b' && input[i + 1] === '[') {
46
+ // Find the end of the sequence (look for 'm')
47
+ let j = i + 2;
48
+ while (j < input.length && input[j] !== 'm') {
49
+ j++;
50
+ }
51
+ if (input[j] === 'm') {
52
+ // Parse the codes
53
+ const codes = input
54
+ .slice(i + 2, j)
55
+ .split(';')
56
+ .map(Number);
57
+ for (const code of codes) {
58
+ if (code === 0) {
59
+ // Reset - close all active styles
60
+ while (activeStyles.length > 0) {
61
+ const style = activeStyles.pop();
62
+ if (style) {
63
+ result += `{/${style}}`;
64
+ }
65
+ }
66
+ }
67
+ else if (code === 1) {
68
+ // Bold
69
+ activeStyles.push('bold');
70
+ result += '{bold}';
71
+ }
72
+ else if (code === 2) {
73
+ // Dim/faint - blessed doesn't have direct support, use gray
74
+ activeStyles.push('gray-fg');
75
+ result += '{gray-fg}';
76
+ }
77
+ else if (code === 3) {
78
+ // Italic - not well supported in terminals, skip
79
+ }
80
+ else if (code === 4) {
81
+ // Underline
82
+ activeStyles.push('underline');
83
+ result += '{underline}';
84
+ }
85
+ else if (code >= 30 && code <= 37) {
86
+ // Standard foreground colors
87
+ const color = ANSI_FG_COLORS[code];
88
+ if (color) {
89
+ activeStyles.push(`${color}-fg`);
90
+ result += `{${color}-fg}`;
91
+ }
92
+ }
93
+ else if (code >= 90 && code <= 97) {
94
+ // Bright foreground colors
95
+ const color = ANSI_FG_COLORS[code];
96
+ if (color) {
97
+ activeStyles.push(`${color}-fg`);
98
+ result += `{${color}-fg}`;
99
+ }
100
+ }
101
+ // Note: We ignore background colors (40-47, 100-107) for simplicity
102
+ }
103
+ i = j + 1;
104
+ continue;
105
+ }
106
+ }
107
+ // Regular character - escape if needed and append
108
+ const char = input[i];
109
+ if (char === '{' || char === '}') {
110
+ result += char + char;
111
+ }
112
+ else {
113
+ result += char;
114
+ }
115
+ i++;
116
+ }
117
+ // Close any remaining active styles
118
+ while (activeStyles.length > 0) {
119
+ const style = activeStyles.pop();
120
+ if (style) {
121
+ result += `{/${style}}`;
122
+ }
123
+ }
124
+ return result;
125
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * ANSI-aware string truncation utility.
3
+ *
4
+ * Truncates strings containing ANSI escape codes at a visual character limit
5
+ * while preserving formatting up to the truncation point.
6
+ */
7
+ // ANSI escape sequence pattern: ESC [ ... m
8
+ // Matches sequences like \x1b[32m, \x1b[0m, \x1b[1;34m, etc.
9
+ const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
10
+ // ANSI reset sequence to clear all formatting
11
+ const ANSI_RESET = '\x1b[0m';
12
+ /**
13
+ * Calculate the visual length of a string (excluding ANSI codes).
14
+ */
15
+ export function visualLength(str) {
16
+ return str.replace(ANSI_PATTERN, '').length;
17
+ }
18
+ /**
19
+ * Truncate a string with ANSI codes at a visual character limit.
20
+ *
21
+ * @param str - String potentially containing ANSI escape codes
22
+ * @param maxVisualLength - Maximum visual characters (not counting ANSI codes)
23
+ * @param suffix - Suffix to append when truncated (default: '…')
24
+ * @returns Truncated string with ANSI reset if needed
25
+ */
26
+ export function truncateAnsi(str, maxVisualLength, suffix = '…') {
27
+ if (maxVisualLength <= 0) {
28
+ return suffix;
29
+ }
30
+ // Quick check: if no ANSI codes and short enough, return as-is
31
+ if (!str.includes('\x1b') && str.length <= maxVisualLength) {
32
+ return str;
33
+ }
34
+ // If no ANSI codes, simple truncation
35
+ if (!str.includes('\x1b')) {
36
+ if (str.length <= maxVisualLength) {
37
+ return str;
38
+ }
39
+ return str.slice(0, maxVisualLength - suffix.length) + suffix;
40
+ }
41
+ // Parse string into segments: either ANSI codes or visible text
42
+ const segments = [];
43
+ let lastIndex = 0;
44
+ // Reset the regex state
45
+ ANSI_PATTERN.lastIndex = 0;
46
+ let match;
47
+ while ((match = ANSI_PATTERN.exec(str)) !== null) {
48
+ // Add text before this ANSI code
49
+ if (match.index > lastIndex) {
50
+ segments.push({ type: 'text', content: str.slice(lastIndex, match.index) });
51
+ }
52
+ // Add the ANSI code
53
+ segments.push({ type: 'ansi', content: match[0] });
54
+ lastIndex = match.index + match[0].length;
55
+ }
56
+ // Add remaining text after last ANSI code
57
+ if (lastIndex < str.length) {
58
+ segments.push({ type: 'text', content: str.slice(lastIndex) });
59
+ }
60
+ // Build result, tracking visual length
61
+ let result = '';
62
+ let currentVisualLength = 0;
63
+ const targetLength = maxVisualLength - suffix.length;
64
+ let hasAnsiCodes = false;
65
+ let truncated = false;
66
+ for (const segment of segments) {
67
+ if (segment.type === 'ansi') {
68
+ // Always include ANSI codes (they don't take visual space)
69
+ result += segment.content;
70
+ hasAnsiCodes = true;
71
+ }
72
+ else {
73
+ // Text segment - check if it fits
74
+ const remainingSpace = targetLength - currentVisualLength;
75
+ if (remainingSpace <= 0) {
76
+ // No more space
77
+ truncated = true;
78
+ break;
79
+ }
80
+ if (segment.content.length <= remainingSpace) {
81
+ // Entire segment fits
82
+ result += segment.content;
83
+ currentVisualLength += segment.content.length;
84
+ }
85
+ else {
86
+ // Partial fit - truncate this segment
87
+ result += segment.content.slice(0, remainingSpace);
88
+ currentVisualLength += remainingSpace;
89
+ truncated = true;
90
+ break;
91
+ }
92
+ }
93
+ }
94
+ if (truncated) {
95
+ // Reset formatting before suffix to ensure clean state
96
+ if (hasAnsiCodes) {
97
+ result += ANSI_RESET;
98
+ }
99
+ result += suffix;
100
+ }
101
+ return result;
102
+ }
103
+ /**
104
+ * Check if a string needs truncation at the given visual length.
105
+ */
106
+ export function needsTruncation(str, maxVisualLength) {
107
+ return visualLength(str) > maxVisualLength;
108
+ }
@@ -1,2 +1,44 @@
1
- import*as n from"node:fs";import*as c from"node:path";import*as i from"node:os";const t=c.join(i.homedir(),".cache","diffstalker","base-branches.json");function h(){const e=c.dirname(t);n.existsSync(e)||n.mkdirSync(e,{recursive:!0})}function s(){try{if(n.existsSync(t))return JSON.parse(n.readFileSync(t,"utf-8"))}catch{}return{}}function f(e){h(),n.writeFileSync(t,JSON.stringify(e,null,2)+`
2
- `)}export function getCachedBaseBranch(e){const a=s(),r=c.resolve(e);return a[r]}export function setCachedBaseBranch(e,a){const r=s(),o=c.resolve(e);r[o]=a,f(r)}
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ const CACHE_PATH = path.join(os.homedir(), '.cache', 'diffstalker', 'base-branches.json');
5
+ function ensureCacheDir() {
6
+ const dir = path.dirname(CACHE_PATH);
7
+ if (!fs.existsSync(dir)) {
8
+ fs.mkdirSync(dir, { recursive: true });
9
+ }
10
+ }
11
+ function loadCache() {
12
+ try {
13
+ if (fs.existsSync(CACHE_PATH)) {
14
+ return JSON.parse(fs.readFileSync(CACHE_PATH, 'utf-8'));
15
+ }
16
+ }
17
+ catch {
18
+ // Ignore read errors, return empty cache
19
+ }
20
+ return {};
21
+ }
22
+ function saveCache(cache) {
23
+ ensureCacheDir();
24
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2) + '\n');
25
+ }
26
+ /**
27
+ * Get the cached base branch for a repository.
28
+ * Returns undefined if no cached value exists.
29
+ */
30
+ export function getCachedBaseBranch(repoPath) {
31
+ const cache = loadCache();
32
+ // Normalize path for consistent lookup
33
+ const normalizedPath = path.resolve(repoPath);
34
+ return cache[normalizedPath];
35
+ }
36
+ /**
37
+ * Save the selected base branch for a repository to the cache.
38
+ */
39
+ export function setCachedBaseBranch(repoPath, baseBranch) {
40
+ const cache = loadCache();
41
+ const normalizedPath = path.resolve(repoPath);
42
+ cache[normalizedPath] = baseBranch;
43
+ saveCache(cache);
44
+ }
@@ -1 +1,38 @@
1
- export function truncateWithEllipsis(t,s){return t.length<=s?t:s<=3?t.slice(0,s):t.slice(0,s-3)+"..."}export function formatCommitDisplay(t,s,n,l=20){const r=s||"",i=Math.max(0,n-l-1);let e=r;e.length>i&&i>3?e=e.slice(0,i-3)+"...":e.length>i&&(e="");const c=e?e.length+1:0,f=Math.max(l,n-c);return{displayMessage:truncateWithEllipsis(t,f),displayRefs:e}}
1
+ /**
2
+ * Truncate a string with ellipsis if it exceeds maxLength.
3
+ */
4
+ export function truncateWithEllipsis(str, maxLength) {
5
+ if (str.length <= maxLength)
6
+ return str;
7
+ if (maxLength <= 3)
8
+ return str.slice(0, maxLength);
9
+ return str.slice(0, maxLength - 3) + '...';
10
+ }
11
+ /**
12
+ * Format commit message and refs for display within available width.
13
+ * Prioritizes message over refs, truncating refs first if needed.
14
+ *
15
+ * @param message - The commit message
16
+ * @param refs - The commit refs (branch names, tags)
17
+ * @param availableWidth - Total width available for message + refs
18
+ * @param minMessageWidth - Minimum width to reserve for message (default: 20)
19
+ */
20
+ export function formatCommitDisplay(message, refs, availableWidth, minMessageWidth = 20) {
21
+ const refsStr = refs || '';
22
+ // Calculate max space for refs (leave at least minMessageWidth for message + 1 for space)
23
+ const maxRefsWidth = Math.max(0, availableWidth - minMessageWidth - 1);
24
+ // Truncate refs if needed
25
+ let displayRefs = refsStr;
26
+ if (displayRefs.length > maxRefsWidth && maxRefsWidth > 3) {
27
+ displayRefs = displayRefs.slice(0, maxRefsWidth - 3) + '...';
28
+ }
29
+ else if (displayRefs.length > maxRefsWidth) {
30
+ displayRefs = ''; // Not enough space for refs
31
+ }
32
+ // Calculate message width (remaining space after refs)
33
+ const refsWidth = displayRefs ? displayRefs.length + 1 : 0; // +1 for space before refs
34
+ const messageWidth = Math.max(minMessageWidth, availableWidth - refsWidth);
35
+ // Truncate message if needed
36
+ const displayMessage = truncateWithEllipsis(message, messageWidth);
37
+ return { displayMessage, displayRefs };
38
+ }
@@ -1 +1,21 @@
1
- export function isDisplayableDiffHeader(i){return!(i.startsWith("index ")||i.startsWith("--- ")||i.startsWith("+++ ")||i.startsWith("similarity index"))}export function isDisplayableDiffLine(i){return i.type!=="header"?!0:isDisplayableDiffHeader(i.content)}
1
+ /**
2
+ * Check if a diff header line should be displayed.
3
+ * Filters out redundant headers like index, ---, +++, and similarity index.
4
+ * This is used consistently across DiffView, CompareView, and HistoryDiffView.
5
+ */
6
+ export function isDisplayableDiffHeader(content) {
7
+ return !(content.startsWith('index ') ||
8
+ content.startsWith('--- ') ||
9
+ content.startsWith('+++ ') ||
10
+ content.startsWith('similarity index'));
11
+ }
12
+ /**
13
+ * Check if a diff line should be displayed.
14
+ * Non-header lines are always displayed.
15
+ * Header lines are filtered using isDisplayableDiffHeader.
16
+ */
17
+ export function isDisplayableDiffLine(line) {
18
+ if (line.type !== 'header')
19
+ return true;
20
+ return isDisplayableDiffHeader(line.content);
21
+ }