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
|
@@ -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
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|