diffstalker 0.1.7 → 0.2.1

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 (74) hide show
  1. package/.github/workflows/release.yml +8 -0
  2. package/CHANGELOG.md +36 -0
  3. package/bun.lock +89 -306
  4. package/dist/App.js +895 -520
  5. package/dist/FollowMode.js +85 -0
  6. package/dist/KeyBindings.js +178 -0
  7. package/dist/MouseHandlers.js +156 -0
  8. package/dist/core/ExplorerStateManager.js +632 -0
  9. package/dist/core/FilePathWatcher.js +133 -0
  10. package/dist/core/GitStateManager.js +221 -86
  11. package/dist/git/diff.js +4 -0
  12. package/dist/git/ignoreUtils.js +30 -0
  13. package/dist/git/status.js +2 -34
  14. package/dist/index.js +68 -53
  15. package/dist/ipc/CommandClient.js +165 -0
  16. package/dist/ipc/CommandServer.js +152 -0
  17. package/dist/state/CommitFlowState.js +86 -0
  18. package/dist/state/UIState.js +195 -0
  19. package/dist/types/tabs.js +4 -0
  20. package/dist/ui/Layout.js +252 -0
  21. package/dist/ui/PaneRenderers.js +56 -0
  22. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  23. package/dist/ui/modals/DiscardConfirm.js +77 -0
  24. package/dist/ui/modals/FileFinder.js +232 -0
  25. package/dist/ui/modals/HotkeysModal.js +209 -0
  26. package/dist/ui/modals/ThemePicker.js +107 -0
  27. package/dist/ui/widgets/CommitPanel.js +58 -0
  28. package/dist/ui/widgets/CompareListView.js +238 -0
  29. package/dist/ui/widgets/DiffView.js +281 -0
  30. package/dist/ui/widgets/ExplorerContent.js +89 -0
  31. package/dist/ui/widgets/ExplorerView.js +204 -0
  32. package/dist/ui/widgets/FileList.js +185 -0
  33. package/dist/ui/widgets/Footer.js +50 -0
  34. package/dist/ui/widgets/Header.js +68 -0
  35. package/dist/ui/widgets/HistoryView.js +69 -0
  36. package/dist/utils/displayRows.js +185 -6
  37. package/dist/utils/explorerDisplayRows.js +1 -1
  38. package/dist/utils/fileCategories.js +37 -0
  39. package/dist/utils/fileTree.js +148 -0
  40. package/dist/utils/languageDetection.js +56 -0
  41. package/dist/utils/pathUtils.js +27 -0
  42. package/dist/utils/wordDiff.js +50 -0
  43. package/eslint.metrics.js +16 -0
  44. package/metrics/.gitkeep +0 -0
  45. package/metrics/v0.2.1.json +268 -0
  46. package/package.json +14 -12
  47. package/dist/components/BaseBranchPicker.js +0 -60
  48. package/dist/components/BottomPane.js +0 -101
  49. package/dist/components/CommitPanel.js +0 -58
  50. package/dist/components/CompareListView.js +0 -110
  51. package/dist/components/ExplorerContentView.js +0 -80
  52. package/dist/components/ExplorerView.js +0 -37
  53. package/dist/components/FileList.js +0 -131
  54. package/dist/components/Footer.js +0 -6
  55. package/dist/components/Header.js +0 -107
  56. package/dist/components/HistoryView.js +0 -21
  57. package/dist/components/HotkeysModal.js +0 -108
  58. package/dist/components/Modal.js +0 -19
  59. package/dist/components/ScrollableList.js +0 -125
  60. package/dist/components/ThemePicker.js +0 -42
  61. package/dist/components/TopPane.js +0 -14
  62. package/dist/components/UnifiedDiffView.js +0 -115
  63. package/dist/hooks/useCommitFlow.js +0 -66
  64. package/dist/hooks/useCompareState.js +0 -123
  65. package/dist/hooks/useExplorerState.js +0 -248
  66. package/dist/hooks/useGit.js +0 -156
  67. package/dist/hooks/useHistoryState.js +0 -62
  68. package/dist/hooks/useKeymap.js +0 -167
  69. package/dist/hooks/useLayout.js +0 -154
  70. package/dist/hooks/useMouse.js +0 -87
  71. package/dist/hooks/useTerminalSize.js +0 -20
  72. package/dist/hooks/useWatcher.js +0 -137
  73. package/dist/utils/mouseCoordinates.js +0 -165
  74. package/dist/utils/rowCalculations.js +0 -209
@@ -1,80 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo } from 'react';
3
- import { Box, Text } from 'ink';
4
- import { ScrollableList } from './ScrollableList.js';
5
- import { buildExplorerContentRows, wrapExplorerContentRows, getExplorerContentRowCount, getExplorerContentLineNumWidth, applyMiddleDots, } from '../utils/explorerDisplayRows.js';
6
- import { truncateAnsi } from '../utils/ansiTruncate.js';
7
- export function ExplorerContentView({ filePath, content, maxHeight, scrollOffset, truncated = false, wrapMode = false, width, showMiddleDots = false, }) {
8
- // Build base rows with syntax highlighting
9
- const baseRows = useMemo(() => buildExplorerContentRows(content, filePath, truncated), [content, filePath, truncated]);
10
- // Calculate line number width
11
- const lineNumWidth = useMemo(() => getExplorerContentLineNumWidth(baseRows), [baseRows]);
12
- // Calculate content width for wrapping
13
- // Layout: paddingX(1) + lineNum + space(1) + content + paddingX(1)
14
- const contentWidth = width - lineNumWidth - 3;
15
- // Apply wrapping if enabled
16
- const displayRows = useMemo(() => wrapExplorerContentRows(baseRows, contentWidth, wrapMode), [baseRows, contentWidth, wrapMode]);
17
- if (!filePath) {
18
- return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "Select a file to view its contents" }) }));
19
- }
20
- if (!content) {
21
- return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "Loading..." }) }));
22
- }
23
- if (displayRows.length === 0) {
24
- return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "(empty file)" }) }));
25
- }
26
- return (_jsx(Box, { flexDirection: "column", paddingX: 1, children: _jsx(ScrollableList, { items: displayRows, maxHeight: maxHeight, scrollOffset: scrollOffset, getKey: (row, index) => `${index}`, renderItem: (row) => {
27
- if (row.type === 'truncation') {
28
- return (_jsx(Box, { children: _jsx(Text, { color: "yellow", dimColor: true, children: row.content }) }));
29
- }
30
- // Code row
31
- const isContinuation = row.isContinuation ?? false;
32
- // Line number display
33
- let lineNumDisplay;
34
- if (isContinuation) {
35
- // Show continuation marker instead of line number
36
- lineNumDisplay = '>>'.padStart(lineNumWidth, ' ');
37
- }
38
- else {
39
- lineNumDisplay = String(row.lineNum).padStart(lineNumWidth, ' ');
40
- }
41
- // Determine what content to display
42
- const rawContent = row.content;
43
- const shouldTruncate = !wrapMode && rawContent.length > contentWidth;
44
- // Use highlighted content if available and not a continuation or middle-dots mode
45
- const canUseHighlighting = row.highlighted && !isContinuation && !showMiddleDots;
46
- let displayContent;
47
- if (canUseHighlighting && row.highlighted) {
48
- // Use ANSI-aware truncation to preserve syntax highlighting
49
- displayContent = shouldTruncate
50
- ? truncateAnsi(row.highlighted, contentWidth)
51
- : row.highlighted;
52
- }
53
- else {
54
- // Plain content path
55
- let content = rawContent;
56
- // Apply middle-dots to raw content
57
- if (showMiddleDots && !isContinuation) {
58
- content = applyMiddleDots(content, true);
59
- }
60
- // Simple truncation for plain content
61
- if (shouldTruncate) {
62
- content = content.slice(0, Math.max(0, contentWidth - 1)) + '…';
63
- }
64
- displayContent = content;
65
- }
66
- return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: !isContinuation, color: isContinuation ? 'cyan' : undefined, children: [lineNumDisplay, ' '] }), _jsx(Text, { dimColor: showMiddleDots && !canUseHighlighting, children: displayContent || ' ' })] }));
67
- } }) }));
68
- }
69
- /**
70
- * Get total rows for scroll calculations.
71
- * Accounts for wrap mode when calculating.
72
- */
73
- export function getExplorerContentTotalRows(content, filePath, truncated, width, wrapMode) {
74
- if (!content)
75
- return 0;
76
- const rows = buildExplorerContentRows(content, filePath, truncated);
77
- const lineNumWidth = getExplorerContentLineNumWidth(rows);
78
- const contentWidth = width - lineNumWidth - 3;
79
- return getExplorerContentRowCount(rows, contentWidth, wrapMode);
80
- }
@@ -1,37 +0,0 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
3
- import { ScrollableList } from './ScrollableList.js';
4
- export function ExplorerView({ currentPath: _currentPath, items, selectedIndex, scrollOffset, maxHeight, isActive, width, isLoading = false, error = null, }) {
5
- if (error) {
6
- return (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) }));
7
- }
8
- if (isLoading) {
9
- return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "Loading..." }) }));
10
- }
11
- if (items.length === 0) {
12
- return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "(empty directory)" }) }));
13
- }
14
- // Calculate max name width for alignment
15
- const maxNameWidth = Math.min(Math.max(...items.map((item) => item.name.length + (item.isDirectory ? 1 : 0))), width - 10);
16
- return (_jsx(ScrollableList, { items: items, maxHeight: maxHeight, scrollOffset: scrollOffset, getKey: (item) => item.path || item.name, renderItem: (item, actualIndex) => {
17
- const isSelected = actualIndex === selectedIndex && isActive;
18
- const displayName = item.isDirectory ? `${item.name}/` : item.name;
19
- const paddedName = displayName.padEnd(maxNameWidth + 1);
20
- return (_jsx(Box, { children: _jsx(Text, { color: isSelected ? 'cyan' : undefined, bold: isSelected, inverse: isSelected, children: item.isDirectory ? (_jsx(Text, { color: isSelected ? 'cyan' : 'blue', children: paddedName })) : (_jsx(Text, { color: isSelected ? 'cyan' : undefined, children: paddedName })) }) }));
21
- } }));
22
- }
23
- /**
24
- * Build breadcrumb segments from a path.
25
- * Returns segments like ["src", "components"] for "src/components"
26
- */
27
- export function buildBreadcrumbs(currentPath) {
28
- if (!currentPath)
29
- return [];
30
- return currentPath.split('/').filter(Boolean);
31
- }
32
- /**
33
- * Get total rows in explorer for scroll calculations.
34
- */
35
- export function getExplorerTotalRows(items) {
36
- return items.length;
37
- }
@@ -1,131 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
3
- import { shortenPath } from '../utils/formatPath.js';
4
- import { categorizeFiles } from '../utils/fileCategories.js';
5
- function getStatusChar(status) {
6
- switch (status) {
7
- case 'modified':
8
- return 'M';
9
- case 'added':
10
- return 'A';
11
- case 'deleted':
12
- return 'D';
13
- case 'untracked':
14
- return '?';
15
- case 'renamed':
16
- return 'R';
17
- case 'copied':
18
- return 'C';
19
- default:
20
- return ' ';
21
- }
22
- }
23
- function getStatusColor(status) {
24
- switch (status) {
25
- case 'modified':
26
- return 'yellow';
27
- case 'added':
28
- return 'green';
29
- case 'deleted':
30
- return 'red';
31
- case 'untracked':
32
- return 'gray';
33
- case 'renamed':
34
- return 'blue';
35
- case 'copied':
36
- return 'cyan';
37
- default:
38
- return 'white';
39
- }
40
- }
41
- function formatStats(insertions, deletions) {
42
- if (insertions === undefined && deletions === undefined)
43
- return null;
44
- const add = insertions ?? 0;
45
- const del = deletions ?? 0;
46
- if (add === 0 && del === 0)
47
- return null;
48
- const parts = [];
49
- if (add > 0)
50
- parts.push(`+${add}`);
51
- if (del > 0)
52
- parts.push(`-${del}`);
53
- return parts.join(' ');
54
- }
55
- function FileRow({ file, isSelected, isFocused, maxPathLength }) {
56
- const statusChar = getStatusChar(file.status);
57
- const statusColor = getStatusColor(file.status);
58
- const actionButton = file.staged ? '[-]' : '[+]';
59
- const buttonColor = file.staged ? 'red' : 'green';
60
- const stats = formatStats(file.insertions, file.deletions);
61
- const isHighlighted = isSelected && isFocused;
62
- // Calculate available space for path (account for stats if present)
63
- const statsLength = stats ? stats.length + 1 : 0;
64
- const availableForPath = maxPathLength - statsLength;
65
- const displayPath = shortenPath(file.path, availableForPath);
66
- return (_jsxs(Box, { children: [_jsx(Text, { color: isHighlighted ? 'cyan' : undefined, bold: isHighlighted, children: isHighlighted ? '▸ ' : ' ' }), _jsxs(Text, { color: buttonColor, children: [actionButton, " "] }), _jsxs(Text, { color: statusColor, children: [statusChar, " "] }), _jsx(Text, { color: isHighlighted ? 'cyan' : undefined, inverse: isHighlighted, children: displayPath }), file.originalPath && _jsxs(Text, { dimColor: true, children: [" \u2190 ", shortenPath(file.originalPath, 30)] }), stats && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: " " }), file.insertions !== undefined && file.insertions > 0 && (_jsxs(Text, { color: "green", children: ["+", file.insertions] })), file.insertions !== undefined &&
67
- file.insertions > 0 &&
68
- file.deletions !== undefined &&
69
- file.deletions > 0 && _jsx(Text, { dimColor: true, children: " " }), file.deletions !== undefined && file.deletions > 0 && (_jsxs(Text, { color: "red", children: ["-", file.deletions] }))] }))] }));
70
- }
71
- export function FileList({ files, selectedIndex, isFocused, scrollOffset = 0, maxHeight, width = 80, }) {
72
- // Calculate max path length: width minus prefix chars (▸/space + [+]/[-] + status + spaces = ~10)
73
- const maxPathLength = width - 10;
74
- // Split files into 3 categories: Modified, Untracked, Staged
75
- const { modified: modifiedFiles, untracked: untrackedFiles, staged: stagedFiles, } = categorizeFiles(files);
76
- if (files.length === 0) {
77
- return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { dimColor: true, children: " No changes" }) }));
78
- }
79
- // Build a flat list of all rows
80
- // Order: Modified → Untracked → Staged
81
- const rows = [];
82
- let currentFileIndex = 0;
83
- if (modifiedFiles.length > 0) {
84
- rows.push({ type: 'header', content: 'Modified:', headerColor: 'yellow' });
85
- modifiedFiles.forEach((file) => {
86
- rows.push({ type: 'file', file, fileIndex: currentFileIndex++ });
87
- });
88
- }
89
- if (untrackedFiles.length > 0) {
90
- if (modifiedFiles.length > 0) {
91
- rows.push({ type: 'spacer' });
92
- }
93
- rows.push({ type: 'header', content: 'Untracked:', headerColor: 'gray' });
94
- untrackedFiles.forEach((file) => {
95
- rows.push({ type: 'file', file, fileIndex: currentFileIndex++ });
96
- });
97
- }
98
- if (stagedFiles.length > 0) {
99
- if (modifiedFiles.length > 0 || untrackedFiles.length > 0) {
100
- rows.push({ type: 'spacer' });
101
- }
102
- rows.push({ type: 'header', content: 'Staged:', headerColor: 'green' });
103
- stagedFiles.forEach((file) => {
104
- rows.push({ type: 'file', file, fileIndex: currentFileIndex++ });
105
- });
106
- }
107
- // Apply scroll offset and max height
108
- const visibleRows = maxHeight
109
- ? rows.slice(scrollOffset, scrollOffset + maxHeight)
110
- : rows.slice(scrollOffset);
111
- return (_jsx(Box, { flexDirection: "column", children: visibleRows.map((row, i) => {
112
- const key = `row-${scrollOffset + i}`;
113
- if (row.type === 'header') {
114
- return (_jsx(Text, { bold: true, color: row.headerColor, children: row.content }, key));
115
- }
116
- if (row.type === 'spacer') {
117
- return _jsx(Text, { children: " " }, key);
118
- }
119
- if (row.type === 'file' && row.file !== undefined && row.fileIndex !== undefined) {
120
- return (_jsx(FileRow, { file: row.file, isSelected: row.fileIndex === selectedIndex, isFocused: isFocused, maxPathLength: maxPathLength }, key));
121
- }
122
- return null;
123
- }) }));
124
- }
125
- export function getFileAtIndex(files, index) {
126
- const { ordered } = categorizeFiles(files);
127
- return ordered[index] ?? null;
128
- }
129
- export function getTotalFileCount(files) {
130
- return files.length;
131
- }
@@ -1,6 +0,0 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
3
- export function Footer({ activeTab, mouseEnabled = true, autoTabEnabled = false, wrapMode = false, showMiddleDots = false, }) {
4
- // Layout: "? [scroll] [auto] [wrap] [dots]" with spaces between
5
- return (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "?" }), ' ', _jsx(Text, { color: "yellow", children: mouseEnabled ? '[scroll]' : 'm:[select]' }), ' ', _jsx(Text, { color: autoTabEnabled ? 'blue' : undefined, dimColor: !autoTabEnabled, children: "[auto]" }), ' ', _jsx(Text, { color: wrapMode ? 'blue' : undefined, dimColor: !wrapMode, children: "[wrap]" }), activeTab === 'explorer' && (_jsxs(_Fragment, { children: [' ', _jsx(Text, { color: showMiddleDots ? 'blue' : undefined, dimColor: !showMiddleDots, children: "[dots]" })] }))] }), _jsxs(Text, { children: [_jsx(Text, { color: activeTab === 'diff' ? 'cyan' : undefined, bold: activeTab === 'diff', children: "[1]Diff" }), ' ', _jsx(Text, { color: activeTab === 'commit' ? 'cyan' : undefined, bold: activeTab === 'commit', children: "[2]Commit" }), ' ', _jsx(Text, { color: activeTab === 'history' ? 'cyan' : undefined, bold: activeTab === 'history', children: "[3]History" }), ' ', _jsx(Text, { color: activeTab === 'compare' ? 'cyan' : undefined, bold: activeTab === 'compare', children: "[4]Compare" })] })] }));
6
- }
@@ -1,107 +0,0 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
3
- import { abbreviateHomePath } from '../config.js';
4
- /**
5
- * Calculate the header height based on whether content needs to wrap.
6
- * Returns 1 for single line, 2 if branch wraps to second line.
7
- */
8
- export function getHeaderHeight(repoPath, branch, watcherState, width, error = null, isLoading = false) {
9
- if (!repoPath)
10
- return 1;
11
- const displayPath = abbreviateHomePath(repoPath);
12
- const isNotGitRepo = error === 'Not a git repository';
13
- // Calculate branch width
14
- let branchWidth = 0;
15
- if (branch) {
16
- branchWidth = branch.current.length;
17
- if (branch.tracking)
18
- branchWidth += 3 + branch.tracking.length;
19
- if (branch.ahead > 0)
20
- branchWidth += 3 + String(branch.ahead).length;
21
- if (branch.behind > 0)
22
- branchWidth += 3 + String(branch.behind).length;
23
- }
24
- // Calculate left side width
25
- let leftWidth = displayPath.length;
26
- if (isLoading)
27
- leftWidth += 2;
28
- if (isNotGitRepo)
29
- leftWidth += 24;
30
- if (error && !isNotGitRepo)
31
- leftWidth += error.length + 3;
32
- // Check if follow indicator causes wrap
33
- if (watcherState?.enabled && watcherState.sourceFile) {
34
- const followPath = abbreviateHomePath(watcherState.sourceFile);
35
- const fullFollow = ` (follow: ${followPath})`;
36
- const availableOneLine = width - leftWidth - branchWidth - 4;
37
- if (fullFollow.length > availableOneLine) {
38
- // Would need to wrap
39
- const availableWithWrap = width - leftWidth - 2;
40
- if (fullFollow.length <= availableWithWrap) {
41
- return 2; // Branch wraps to second line
42
- }
43
- }
44
- }
45
- return 1;
46
- }
47
- function BranchDisplay({ branch }) {
48
- return (_jsxs(Box, { children: [_jsx(Text, { color: "green", bold: true, children: branch.current }), branch.tracking && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " \u2192 " }), _jsx(Text, { color: "blue", children: branch.tracking })] })), (branch.ahead > 0 || branch.behind > 0) && (_jsxs(Text, { children: [branch.ahead > 0 && _jsxs(Text, { color: "green", children: [" \u2191", branch.ahead] }), branch.behind > 0 && _jsxs(Text, { color: "red", children: [" \u2193", branch.behind] })] }))] }));
49
- }
50
- export function Header({ repoPath, branch, isLoading, error, debug, watcherState, width = 80, }) {
51
- if (!repoPath) {
52
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Waiting for target path..." }), _jsx(Text, { dimColor: true, children: " (write path to ~/.cache/diffstalker/target)" })] }), debug && watcherState && watcherState.enabled && watcherState.sourceFile && (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: ["[debug] source: ", abbreviateHomePath(watcherState.sourceFile)] }), watcherState.rawContent && _jsxs(Text, { dimColor: true, children: [" | raw: \"", watcherState.rawContent, "\""] })] }))] }));
53
- }
54
- const displayPath = abbreviateHomePath(repoPath);
55
- const isNotGitRepo = error === 'Not a git repository';
56
- const formatTime = (date) => {
57
- if (!date)
58
- return '';
59
- return date.toLocaleTimeString();
60
- };
61
- // Calculate branch info width for layout
62
- let branchWidth = 0;
63
- if (branch) {
64
- branchWidth = branch.current.length;
65
- if (branch.tracking) {
66
- branchWidth += 3 + branch.tracking.length; // " → tracking"
67
- }
68
- if (branch.ahead > 0)
69
- branchWidth += 3 + String(branch.ahead).length;
70
- if (branch.behind > 0)
71
- branchWidth += 3 + String(branch.behind).length;
72
- }
73
- // Calculate left side content width (without follow)
74
- let leftWidth = displayPath.length;
75
- if (isLoading)
76
- leftWidth += 2;
77
- if (isNotGitRepo)
78
- leftWidth += 24;
79
- if (error && !isNotGitRepo)
80
- leftWidth += error.length + 3;
81
- // Determine follow indicator display and layout
82
- let followText = null;
83
- let wrapBranch = false;
84
- if (watcherState?.enabled && watcherState.sourceFile) {
85
- const followPath = abbreviateHomePath(watcherState.sourceFile);
86
- const fullFollow = ` (follow: ${followPath})`;
87
- const availableOneLine = width - leftWidth - branchWidth - 4; // 4 for spacing
88
- if (fullFollow.length <= availableOneLine) {
89
- // Everything fits on one line
90
- followText = fullFollow;
91
- }
92
- else {
93
- // Need to wrap branch to second line
94
- const availableWithWrap = width - leftWidth - 2;
95
- if (fullFollow.length <= availableWithWrap) {
96
- followText = fullFollow;
97
- wrapBranch = true;
98
- }
99
- // If it doesn't fit, don't show follow at all
100
- }
101
- }
102
- return (_jsxs(Box, { flexDirection: "column", width: width, children: [wrapBranch ? (
103
- // Two-line layout: path + follow on first line, branch on second
104
- _jsxs(_Fragment, { children: [_jsx(Box, { justifyContent: "space-between", children: _jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: displayPath }), isLoading && _jsx(Text, { color: "yellow", children: " \u27F3" }), isNotGitRepo && _jsx(Text, { color: "yellow", children: " (not a git repository)" }), error && !isNotGitRepo && _jsxs(Text, { color: "red", children: [" (", error, ")"] }), followText && _jsx(Text, { dimColor: true, children: followText })] }) }), _jsx(Box, { justifyContent: "flex-end", children: branch && _jsx(BranchDisplay, { branch: branch }) })] })) : (
105
- // Single-line layout
106
- _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: displayPath }), isLoading && _jsx(Text, { color: "yellow", children: " \u27F3" }), isNotGitRepo && _jsx(Text, { color: "yellow", children: " (not a git repository)" }), error && !isNotGitRepo && _jsxs(Text, { color: "red", children: [" (", error, ")"] }), followText && _jsx(Text, { dimColor: true, children: followText })] }), branch && _jsx(BranchDisplay, { branch: branch })] })), debug && watcherState && watcherState.enabled && watcherState.sourceFile && (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: ["[debug] source: ", abbreviateHomePath(watcherState.sourceFile)] }), _jsxs(Text, { dimColor: true, children: [" | raw: \"", watcherState.rawContent, "\""] }), watcherState.lastUpdate && (_jsxs(Text, { dimColor: true, children: [" | updated: ", formatTime(watcherState.lastUpdate)] }))] }))] }));
107
- }
@@ -1,21 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
3
- import { ScrollableList } from './ScrollableList.js';
4
- import { formatDate } from '../utils/formatDate.js';
5
- import { formatCommitDisplay } from '../utils/commitFormat.js';
6
- // Re-export from utils for backwards compatibility
7
- export { getCommitIndexFromRow, getHistoryTotalRows, getHistoryRowOffset, } from '../utils/rowCalculations.js';
8
- export function HistoryView({ commits, selectedIndex, scrollOffset, maxHeight, isActive, width, onSelectCommit: _onSelectCommit, }) {
9
- if (commits.length === 0) {
10
- return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "No commits yet" }) }));
11
- }
12
- return (_jsx(ScrollableList, { items: commits, maxHeight: maxHeight, scrollOffset: scrollOffset, getKey: (commit) => commit.hash, renderItem: (commit, actualIndex) => {
13
- const isSelected = actualIndex === selectedIndex && isActive;
14
- const dateStr = formatDate(commit.date);
15
- // Fixed parts: hash(7) + spaces(4) + date + parens(2)
16
- const baseWidth = 7 + 4 + dateStr.length + 2;
17
- const remainingWidth = width - baseWidth;
18
- const { displayMessage, displayRefs } = formatCommitDisplay(commit.message, commit.refs, remainingWidth);
19
- return (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: commit.shortHash }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? 'cyan' : undefined, bold: isSelected, inverse: isSelected, children: displayMessage }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: ["(", dateStr, ")"] }), displayRefs && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: "green", children: displayRefs })] }))] }));
20
- } }));
21
- }
@@ -1,108 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text, useInput } from 'ink';
3
- import { Modal, centerModal } from './Modal.js';
4
- const hotkeyGroups = [
5
- {
6
- title: 'Navigation',
7
- entries: [
8
- { key: '↑/k', description: 'Move up' },
9
- { key: '↓/j', description: 'Move down' },
10
- { key: 'Tab', description: 'Toggle pane focus' },
11
- ],
12
- },
13
- {
14
- title: 'Staging',
15
- entries: [
16
- { key: '^S', description: 'Stage file' },
17
- { key: '^U', description: 'Unstage file' },
18
- { key: '^A', description: 'Stage all' },
19
- { key: '^Z', description: 'Unstage all' },
20
- { key: 'Space/Enter', description: 'Toggle stage' },
21
- ],
22
- },
23
- {
24
- title: 'Actions',
25
- entries: [
26
- { key: 'c', description: 'Open commit panel' },
27
- { key: 'r', description: 'Refresh' },
28
- { key: 'q', description: 'Quit' },
29
- ],
30
- },
31
- {
32
- title: 'Pane Resize',
33
- entries: [
34
- { key: '[', description: 'Shrink top pane' },
35
- { key: ']', description: 'Grow top pane' },
36
- ],
37
- },
38
- {
39
- title: 'Tabs',
40
- entries: [
41
- { key: '1', description: 'Diff view' },
42
- { key: '2', description: 'Commit panel' },
43
- { key: '3', description: 'History view' },
44
- { key: '4', description: 'Compare view' },
45
- { key: '5', description: 'Explorer view' },
46
- { key: 'a', description: 'Toggle auto-tab mode' },
47
- ],
48
- },
49
- {
50
- title: 'Explorer',
51
- entries: [
52
- { key: '.', description: 'Toggle middle-dots' },
53
- { key: '^H', description: 'Toggle hidden files' },
54
- { key: '^G', description: 'Toggle gitignored' },
55
- { key: 'Enter', description: 'Enter directory' },
56
- { key: 'Backspace/h', description: 'Go up' },
57
- ],
58
- },
59
- {
60
- title: 'Other',
61
- entries: [
62
- { key: 'm', description: 'Toggle scroll/select' },
63
- { key: 'f', description: 'Toggle follow mode' },
64
- { key: 'w', description: 'Toggle wrap mode' },
65
- { key: 't', description: 'Theme picker' },
66
- { key: 'b', description: 'Base branch picker' },
67
- { key: 'u', description: 'Toggle uncommitted' },
68
- { key: '?', description: 'This help' },
69
- ],
70
- },
71
- ];
72
- export function HotkeysModal({ onClose, width, height }) {
73
- useInput((input, key) => {
74
- if (key.escape || key.return || input === '?') {
75
- onClose();
76
- }
77
- });
78
- // Determine if we should use 2 columns (need at least 90 chars width)
79
- const useTwoColumns = width >= 90;
80
- const columnWidth = useTwoColumns ? 38 : 30;
81
- const boxWidth = useTwoColumns ? Math.min(82, width - 4) : Math.min(40, width - 4);
82
- // Calculate height based on layout
83
- let boxHeight;
84
- if (useTwoColumns) {
85
- // Split groups into two columns
86
- const midpoint = Math.ceil(hotkeyGroups.length / 2);
87
- const leftGroups = hotkeyGroups.slice(0, midpoint);
88
- const rightGroups = hotkeyGroups.slice(midpoint);
89
- const leftLines = leftGroups.reduce((sum, g) => sum + g.entries.length + 2, 0);
90
- const rightLines = rightGroups.reduce((sum, g) => sum + g.entries.length + 2, 0);
91
- boxHeight = Math.min(Math.max(leftLines, rightLines) + 5, height - 4);
92
- }
93
- else {
94
- const totalLines = hotkeyGroups.reduce((sum, g) => sum + g.entries.length + 2, 0) + 4;
95
- boxHeight = Math.min(totalLines, height - 4);
96
- }
97
- // Center the modal
98
- const { x, y } = centerModal(boxWidth, boxHeight, width, height);
99
- // Render a single group
100
- const renderGroup = (group, colWidth) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: group.title }), group.entries.map((entry) => (_jsxs(Box, { children: [_jsx(Box, { width: 13, children: _jsx(Text, { color: "cyan", children: entry.key }) }), _jsx(Box, { width: colWidth - 13, children: _jsx(Text, { children: entry.description }) })] }, entry.key)))] }, group.title));
101
- if (useTwoColumns) {
102
- const midpoint = Math.ceil(hotkeyGroups.length / 2);
103
- const leftGroups = hotkeyGroups.slice(0, midpoint);
104
- const rightGroups = hotkeyGroups.slice(midpoint);
105
- return (_jsx(Modal, { x: x, y: y, width: boxWidth, height: boxHeight, children: _jsxs(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", width: boxWidth, children: [_jsx(Box, { justifyContent: "center", marginBottom: 1, children: _jsxs(Text, { bold: true, color: "cyan", children: [' ', "Keyboard Shortcuts", ' '] }) }), _jsxs(Box, { children: [_jsx(Box, { flexDirection: "column", width: columnWidth, marginRight: 2, children: leftGroups.map((g) => renderGroup(g, columnWidth)) }), _jsx(Box, { flexDirection: "column", width: columnWidth, children: rightGroups.map((g) => renderGroup(g, columnWidth)) })] }), _jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsx(Text, { dimColor: true, children: "Press Esc, Enter, or ? to close" }) })] }) }));
106
- }
107
- return (_jsx(Modal, { x: x, y: y, width: boxWidth, height: boxHeight, children: _jsxs(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", width: boxWidth, children: [_jsx(Box, { justifyContent: "center", marginBottom: 1, children: _jsxs(Text, { bold: true, color: "cyan", children: [' ', "Keyboard Shortcuts", ' '] }) }), hotkeyGroups.map((group) => renderGroup(group, columnWidth)), _jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsx(Text, { dimColor: true, children: "Press Esc, Enter, or ? to close" }) })] }) }));
108
- }
@@ -1,19 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
3
- /**
4
- * A modal overlay that blankets only its own area before rendering children.
5
- * Use this to create popups that cover the content behind them.
6
- */
7
- export function Modal({ x, y, width, height, children }) {
8
- const blankLine = ' '.repeat(width);
9
- return (_jsxs(Box, { position: "absolute", marginLeft: x, marginTop: y, flexDirection: "column", children: [Array.from({ length: height }).map((_, i) => (_jsx(Text, { children: blankLine }, `blank-${i}`))), _jsx(Box, { position: "absolute", flexDirection: "column", children: children })] }));
10
- }
11
- /**
12
- * Helper to calculate centered modal position.
13
- */
14
- export function centerModal(modalWidth, modalHeight, screenWidth, screenHeight) {
15
- return {
16
- x: Math.floor((screenWidth - modalWidth) / 2),
17
- y: Math.floor((screenHeight - modalHeight) / 2),
18
- };
19
- }
@@ -1,125 +0,0 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { useMemo } from 'react';
3
- import { Box, Text } from 'ink';
4
- /**
5
- * A generic scrollable list component that properly handles:
6
- * - Scroll indicators (↑/↓) taking up space
7
- * - Consistent height calculations
8
- * - Proper React keys for re-rendering
9
- *
10
- * Usage:
11
- * ```tsx
12
- * <ScrollableList
13
- * items={myItems}
14
- * renderItem={(item, i) => <MyRow item={item} />}
15
- * maxHeight={20}
16
- * scrollOffset={offset}
17
- * getKey={(item, i) => item.id}
18
- * />
19
- * ```
20
- */
21
- export function ScrollableList({ items, renderItem, maxHeight, scrollOffset, getKey, header, showIndicators = true, getItemHeight, }) {
22
- // If getItemHeight is not provided, use simple item-based scrolling
23
- const hasVariableHeight = !!getItemHeight;
24
- // Calculate cumulative row positions for variable height items
25
- const { itemRowStarts, totalRows } = useMemo(() => {
26
- if (!hasVariableHeight) {
27
- return { itemRowStarts: [], totalRows: items.length };
28
- }
29
- const starts = [];
30
- let cumulative = 0;
31
- for (let i = 0; i < items.length; i++) {
32
- starts.push(cumulative);
33
- cumulative += getItemHeight(items[i], i);
34
- }
35
- return { itemRowStarts: starts, totalRows: cumulative };
36
- }, [items, getItemHeight, hasVariableHeight]);
37
- // Calculate available space for actual content
38
- let availableHeight = maxHeight;
39
- // Reserve space for header if present
40
- if (header) {
41
- availableHeight--;
42
- }
43
- const hasPrevious = scrollOffset > 0;
44
- const contentTotal = hasVariableHeight ? totalRows : items.length;
45
- const needsScrolling = contentTotal > maxHeight;
46
- // Simple rule: if content needs scrolling, ALWAYS reserve 2 rows for indicators
47
- // No clever predictions - just consistent, predictable behavior
48
- if (showIndicators && needsScrolling) {
49
- availableHeight -= 2;
50
- }
51
- // Ensure we have at least 1 line for content
52
- availableHeight = Math.max(1, availableHeight);
53
- // Find visible items based on scroll offset (in rows)
54
- const visibleItems = [];
55
- let usedRows = 0;
56
- let rowsAbove = 0;
57
- let rowsBelow = 0;
58
- if (hasVariableHeight) {
59
- // Find first visible item (the one that contains scrollOffset row)
60
- let startIdx = 0;
61
- for (let i = 0; i < items.length; i++) {
62
- const itemHeight = getItemHeight(items[i], i);
63
- if (itemRowStarts[i] + itemHeight > scrollOffset) {
64
- startIdx = i;
65
- break;
66
- }
67
- }
68
- // Collect items that fit in available height
69
- for (let i = startIdx; i < items.length && usedRows < availableHeight; i++) {
70
- const itemHeight = getItemHeight(items[i], i);
71
- visibleItems.push({ item: items[i], index: i });
72
- usedRows += itemHeight;
73
- }
74
- rowsAbove = scrollOffset;
75
- // Simple calculation: total rows minus what we've scrolled past minus what we're showing
76
- rowsBelow = Math.max(0, totalRows - scrollOffset - usedRows);
77
- }
78
- else {
79
- // Simple item-based scrolling (1 item = 1 row)
80
- const endIdx = Math.min(scrollOffset + availableHeight, items.length);
81
- for (let i = scrollOffset; i < endIdx; i++) {
82
- visibleItems.push({ item: items[i], index: i });
83
- usedRows++;
84
- }
85
- rowsAbove = scrollOffset;
86
- rowsBelow = Math.max(0, items.length - scrollOffset - usedRows);
87
- }
88
- return (_jsxs(Box, { flexDirection: "column", overflowX: "hidden", height: maxHeight, overflow: "hidden", children: [header, showIndicators &&
89
- needsScrolling &&
90
- (hasPrevious ? _jsxs(Text, { dimColor: true, children: ["\u2191 ", rowsAbove, " more above"] }) : _jsx(Text, { children: " " })), visibleItems.map(({ item, index }) => (_jsx(Box, { children: renderItem(item, index) }, `${scrollOffset}-${index}-${getKey(item, index)}`))), showIndicators &&
91
- needsScrolling &&
92
- (rowsBelow > 0 ? _jsxs(Text, { dimColor: true, children: ["\u2193 ", rowsBelow, " more below"] }) : _jsx(Text, { children: " " }))] }));
93
- }
94
- /**
95
- * Calculate the maximum scroll offset for a list.
96
- */
97
- export function getMaxScrollOffset(totalItems, maxHeight, hasHeader = false, showIndicators = true) {
98
- let availableHeight = maxHeight;
99
- if (hasHeader)
100
- availableHeight--;
101
- // When scrolled, we always show "↑ above" indicator
102
- // and usually "↓ below" indicator, so subtract 2
103
- if (showIndicators && totalItems > availableHeight) {
104
- availableHeight -= 2;
105
- }
106
- availableHeight = Math.max(1, availableHeight);
107
- return Math.max(0, totalItems - availableHeight);
108
- }
109
- /**
110
- * Calculate visible item count for a given configuration.
111
- * Useful for scroll calculations in parent components.
112
- */
113
- export function getVisibleItemCount(totalItems, maxHeight, scrollOffset, hasHeader = false, showIndicators = true) {
114
- let availableHeight = maxHeight;
115
- if (hasHeader)
116
- availableHeight--;
117
- if (showIndicators) {
118
- if (scrollOffset > 0)
119
- availableHeight--;
120
- if (totalItems > scrollOffset + availableHeight)
121
- availableHeight--;
122
- }
123
- availableHeight = Math.max(1, availableHeight);
124
- return Math.min(availableHeight, totalItems - scrollOffset);
125
- }