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,42 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from 'react';
3
- import { Box, Text, useInput } from 'ink';
4
- import { themes, themeOrder, getTheme } from '../themes.js';
5
- import { Modal, centerModal } from './Modal.js';
6
- // Preview sample for theme visualization
7
- function ThemePreview({ theme }) {
8
- const { colors } = theme;
9
- return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { backgroundColor: colors.delBg, color: colors.delLineNum, children: ' 5 ' }), _jsx(Text, { backgroundColor: colors.delBg, color: colors.delSymbol, bold: true, children: '- ' }), _jsx(Text, { backgroundColor: colors.delBg, color: colors.text, children: 'const ' }), _jsx(Text, { backgroundColor: colors.delHighlight, color: colors.text, children: 'old' }), _jsx(Text, { backgroundColor: colors.delBg, color: colors.text, children: ' = value;' })] }), _jsxs(Box, { children: [_jsx(Text, { backgroundColor: colors.addBg, color: colors.addLineNum, children: ' 5 ' }), _jsx(Text, { backgroundColor: colors.addBg, color: colors.addSymbol, bold: true, children: '+ ' }), _jsx(Text, { backgroundColor: colors.addBg, color: colors.text, children: 'const ' }), _jsx(Text, { backgroundColor: colors.addHighlight, color: colors.text, children: 'new' }), _jsx(Text, { backgroundColor: colors.addBg, color: colors.text, children: ' = value;' })] })] }));
10
- }
11
- export function ThemePicker({ currentTheme, onSelect, onCancel, width, height, }) {
12
- const [selectedIndex, setSelectedIndex] = useState(() => {
13
- const idx = themeOrder.indexOf(currentTheme);
14
- return idx >= 0 ? idx : 0;
15
- });
16
- const previewTheme = getTheme(themeOrder[selectedIndex]);
17
- useInput((input, key) => {
18
- if (key.escape) {
19
- onCancel();
20
- }
21
- else if (key.return) {
22
- onSelect(themeOrder[selectedIndex]);
23
- }
24
- else if (key.upArrow || input === 'k') {
25
- setSelectedIndex((prev) => Math.max(0, prev - 1));
26
- }
27
- else if (key.downArrow || input === 'j') {
28
- setSelectedIndex((prev) => Math.min(themeOrder.length - 1, prev + 1));
29
- }
30
- });
31
- // Calculate box dimensions
32
- const boxWidth = Math.min(50, width - 4);
33
- const boxHeight = Math.min(themeOrder.length + 10, height - 4); // +10 for header, preview, footer, borders
34
- // Center the modal
35
- const { x, y } = centerModal(boxWidth, boxHeight, width, height);
36
- 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: [' ', "Select Theme", ' '] }) }), themeOrder.map((themeName, index) => {
37
- const theme = themes[themeName];
38
- const isSelected = index === selectedIndex;
39
- const isCurrent = themeName === currentTheme;
40
- return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? 'cyan' : undefined, children: isSelected ? '▸ ' : ' ' }), _jsx(Text, { bold: isSelected, color: isSelected ? 'cyan' : undefined, children: theme.displayName }), isCurrent && _jsx(Text, { dimColor: true, children: " (current)" })] }, themeName));
41
- }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Preview:" }), _jsx(ThemePreview, { theme: previewTheme })] }), _jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate \u2022 Enter select \u2022 Esc cancel" }) })] }) }));
42
- }
@@ -1,14 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import React from 'react';
3
- import { Box, Text } from 'ink';
4
- import { FileList } from './FileList.js';
5
- import { HistoryView } from './HistoryView.js';
6
- import { CompareListView } from './CompareListView.js';
7
- import { ExplorerView, buildBreadcrumbs } from './ExplorerView.js';
8
- import { categorizeFiles } from '../utils/fileCategories.js';
9
- export function TopPane({ bottomTab, currentPane, terminalWidth, topPaneHeight, files, selectedIndex, fileListScrollOffset, stagedCount, onStage, onUnstage, commits, historySelectedIndex, historyScrollOffset, onSelectHistoryCommit, compareDiff, compareListSelection, compareScrollOffset, includeUncommitted, explorerCurrentPath = '', explorerItems = [], explorerSelectedIndex = 0, explorerScrollOffset = 0, explorerIsLoading = false, explorerError = null, hideHiddenFiles = true, hideGitignored = true, }) {
10
- const { modified, untracked } = categorizeFiles(files);
11
- const modifiedCount = modified.length;
12
- const untrackedCount = untracked.length;
13
- return (_jsxs(Box, { flexDirection: "column", height: topPaneHeight, width: terminalWidth, overflowX: "hidden", overflowY: "hidden", children: [(bottomTab === 'diff' || bottomTab === 'commit') && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: currentPane === 'files' ? 'cyan' : undefined, children: "STAGING AREA" }), _jsxs(Text, { dimColor: true, children: [' ', "(", modifiedCount, " modified, ", untrackedCount, " untracked, ", stagedCount, " staged)"] })] }), _jsx(FileList, { files: files, selectedIndex: selectedIndex, isFocused: currentPane === 'files', scrollOffset: fileListScrollOffset, maxHeight: topPaneHeight - 1, width: terminalWidth, onStage: onStage, onUnstage: onUnstage })] })), bottomTab === 'history' && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: currentPane === 'history' ? 'cyan' : undefined, children: "COMMITS" }), _jsxs(Text, { dimColor: true, children: [" (", commits.length, " commits)"] })] }), _jsx(HistoryView, { commits: commits, selectedIndex: historySelectedIndex, scrollOffset: historyScrollOffset, maxHeight: topPaneHeight - 1, isActive: currentPane === 'history', width: terminalWidth, onSelectCommit: onSelectHistoryCommit })] })), bottomTab === 'compare' && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: currentPane === 'compare' ? 'cyan' : undefined, children: "COMPARE" }), _jsx(Text, { dimColor: true, children: " (vs " }), _jsx(Text, { color: "cyan", children: compareDiff?.baseBranch ?? '...' }), _jsxs(Text, { dimColor: true, children: [": ", compareDiff?.commits.length ?? 0, " commits, ", compareDiff?.files.length ?? 0, " files) (b)"] }), compareDiff && compareDiff.uncommittedCount > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " | " }), _jsxs(Text, { color: includeUncommitted ? 'magenta' : 'yellow', children: ["[", includeUncommitted ? 'x' : ' ', "] uncommitted"] }), _jsx(Text, { dimColor: true, children: " (u)" })] }))] }), _jsx(CompareListView, { commits: compareDiff?.commits ?? [], files: compareDiff?.files ?? [], selectedItem: compareListSelection, scrollOffset: compareScrollOffset, maxHeight: topPaneHeight - 1, isActive: currentPane === 'compare', width: terminalWidth })] })), bottomTab === 'explorer' && (_jsxs(_Fragment, { children: [_jsxs(Box, { justifyContent: "space-between", width: terminalWidth, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: currentPane === 'explorer' ? 'cyan' : undefined, children: "EXPLORER" }), _jsx(Text, { dimColor: true, children: " " }), buildBreadcrumbs(explorerCurrentPath).map((segment, i, arr) => (_jsxs(React.Fragment, { children: [_jsx(Text, { color: "blue", children: segment }), i < arr.length - 1 && _jsx(Text, { dimColor: true, children: " / " })] }, i))), explorerCurrentPath && _jsx(Text, { dimColor: true, children: " /" }), !explorerCurrentPath && _jsx(Text, { dimColor: true, children: "(root)" })] }), _jsx(Box, { children: (hideHiddenFiles || hideGitignored) && (_jsxs(Text, { dimColor: true, children: [hideHiddenFiles && 'H', hideGitignored && 'G'] })) })] }), _jsx(ExplorerView, { currentPath: explorerCurrentPath, items: explorerItems, selectedIndex: explorerSelectedIndex, scrollOffset: explorerScrollOffset, maxHeight: topPaneHeight - 1, isActive: currentPane === 'explorer', width: terminalWidth, isLoading: explorerIsLoading, error: explorerError })] }))] }));
14
- }
@@ -1,115 +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
- import { getTheme } from '../themes.js';
5
- import { ScrollableList } from './ScrollableList.js';
6
- import { getDisplayRowsLineNumWidth } from '../utils/displayRows.js';
7
- // Truncate string to fit within maxWidth, adding ellipsis if needed
8
- function truncate(str, maxWidth) {
9
- if (maxWidth <= 0 || str.length <= maxWidth)
10
- return str;
11
- if (maxWidth <= 1)
12
- return '\u2026';
13
- return str.slice(0, maxWidth - 1) + '\u2026';
14
- }
15
- // Format line number with padding
16
- function formatLineNum(lineNum, width) {
17
- if (lineNum === undefined)
18
- return ' '.repeat(width);
19
- return String(lineNum).padStart(width, ' ');
20
- }
21
- function DisplayRowRenderer({ row, lineNumWidth, width, theme, wrapMode, }) {
22
- const { colors } = theme;
23
- // Available width for content: width - paddingX(1) - lineNum - space(1) - symbol(1) - space(1) - paddingX(1)
24
- const contentWidth = width - lineNumWidth - 5;
25
- // Width for headers (just subtract paddingX on each side)
26
- const headerWidth = width - 2;
27
- switch (row.type) {
28
- case 'diff-header': {
29
- // Extract file path from diff --git and show as clean separator
30
- const content = row.content;
31
- if (content.startsWith('diff --git')) {
32
- const match = content.match(/diff --git a\/.+ b\/(.+)$/);
33
- if (match) {
34
- const maxPathLen = headerWidth - 6; // "── " + " ──"
35
- const path = truncate(match[1], maxPathLen);
36
- return (_jsxs(Text, { color: "cyan", bold: true, children: ["\u2500\u2500 ", path, " \u2500\u2500"] }));
37
- }
38
- }
39
- return _jsx(Text, { dimColor: true, children: truncate(content, headerWidth) });
40
- }
41
- case 'diff-hunk': {
42
- // Parse hunk header: @@ -oldStart,oldCount +newStart,newCount @@ context
43
- const match = row.content.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/);
44
- if (match) {
45
- const oldStart = parseInt(match[1], 10);
46
- const oldCount = match[2] ? parseInt(match[2], 10) : 1;
47
- const newStart = parseInt(match[3], 10);
48
- const newCount = match[4] ? parseInt(match[4], 10) : 1;
49
- const context = match[5].trim();
50
- const oldEnd = oldStart + oldCount - 1;
51
- const newEnd = newStart + newCount - 1;
52
- const oldRange = oldCount === 1 ? `${oldStart}` : `${oldStart}-${oldEnd}`;
53
- const newRange = newCount === 1 ? `${newStart}` : `${newStart}-${newEnd}`;
54
- const rangeText = `Lines ${oldRange} \u2192 ${newRange}`;
55
- const contextMaxLen = headerWidth - rangeText.length - 1;
56
- const truncatedContext = context && contextMaxLen > 3 ? ' ' + truncate(context, contextMaxLen) : '';
57
- return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", dimColor: true, children: rangeText }), truncatedContext && _jsx(Text, { color: "gray", children: truncatedContext })] }));
58
- }
59
- return (_jsx(Text, { color: "cyan", dimColor: true, children: truncate(row.content, headerWidth) }));
60
- }
61
- case 'diff-add': {
62
- const isCont = row.isContinuation;
63
- // Use » for continuation - it's single-width and renders background correctly
64
- const symbol = isCont ? '\u00bb' : '+';
65
- const rawContent = wrapMode ? row.content || '' : truncate(row.content, contentWidth) || '';
66
- // Always prepend space to content
67
- const content = ' ' + rawContent || ' ';
68
- return (_jsxs(Box, { children: [_jsx(Text, { backgroundColor: colors.addBg, color: colors.addLineNum, children: formatLineNum(row.lineNum, lineNumWidth) + ' ' }), _jsx(Text, { backgroundColor: colors.addBg, color: isCont ? colors.addLineNum : colors.addSymbol, bold: !isCont, children: symbol }), _jsx(Text, { backgroundColor: colors.addBg, color: colors.text, children: content })] }));
69
- }
70
- case 'diff-del': {
71
- const isCont = row.isContinuation;
72
- // Use » for continuation - it's single-width and renders background correctly
73
- const symbol = isCont ? '\u00bb' : '-';
74
- const rawContent = wrapMode ? row.content || '' : truncate(row.content, contentWidth) || '';
75
- // Always prepend space to content
76
- const content = ' ' + rawContent || ' ';
77
- return (_jsxs(Box, { children: [_jsx(Text, { backgroundColor: colors.delBg, color: colors.delLineNum, children: formatLineNum(row.lineNum, lineNumWidth) + ' ' }), _jsx(Text, { backgroundColor: colors.delBg, color: isCont ? colors.delLineNum : colors.delSymbol, bold: !isCont, children: symbol }), _jsx(Text, { backgroundColor: colors.delBg, color: colors.text, children: content })] }));
78
- }
79
- case 'diff-context': {
80
- const isCont = row.isContinuation;
81
- // Use » for continuation - it's single-width and renders correctly
82
- const symbol = isCont ? '\u00bb ' : ' ';
83
- const content = wrapMode ? row.content : truncate(row.content, contentWidth);
84
- return (_jsxs(Box, { children: [_jsxs(Text, { color: colors.contextLineNum, children: [formatLineNum(row.lineNum, lineNumWidth), " "] }), _jsx(Text, { dimColor: true, children: symbol }), _jsx(Text, { children: content })] }));
85
- }
86
- case 'commit-header':
87
- return _jsx(Text, { color: "yellow", children: truncate(row.content, headerWidth) });
88
- case 'commit-message':
89
- return _jsx(Text, { children: truncate(row.content, headerWidth) });
90
- case 'spacer':
91
- return _jsx(Text, { children: " " });
92
- }
93
- }
94
- /**
95
- * The ONE diff renderer used by all tabs.
96
- * Every row = exactly 1 terminal row.
97
- * No variable heights, no complexity.
98
- */
99
- export function UnifiedDiffView({ rows, maxHeight, scrollOffset, theme: themeName, width, wrapMode = false, }) {
100
- const theme = useMemo(() => getTheme(themeName), [themeName]);
101
- const lineNumWidth = useMemo(() => getDisplayRowsLineNumWidth(rows), [rows]);
102
- if (rows.length === 0) {
103
- return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "No diff to display" }) }));
104
- }
105
- return (_jsx(Box, { flexDirection: "column", paddingX: 1, width: width, children: _jsx(ScrollableList, { items: rows, maxHeight: maxHeight, scrollOffset: scrollOffset, getKey: (_, i) => `row-${i}`,
106
- // NO getItemHeight - all rows are 1 line
107
- renderItem: (row) => (_jsx(DisplayRowRenderer, { row: row, lineNumWidth: lineNumWidth, width: width, theme: theme, wrapMode: wrapMode })) }) }));
108
- }
109
- /**
110
- * Get total row count for scroll calculation.
111
- * Since every row = 1 terminal row, this is just rows.length.
112
- */
113
- export function getUnifiedDiffTotalRows(rows) {
114
- return rows.length;
115
- }
@@ -1,66 +0,0 @@
1
- import { useState, useCallback, useEffect } from 'react';
2
- import { validateCommit, formatCommitMessage } from '../services/commitService.js';
3
- /**
4
- * Hook that manages the commit flow state and logic.
5
- * Extracted from CommitPanel to separate concerns.
6
- */
7
- export function useCommitFlow(options) {
8
- const { stagedCount, onCommit, onSuccess, getHeadMessage } = options;
9
- const [message, setMessage] = useState('');
10
- const [amend, setAmend] = useState(false);
11
- const [isCommitting, setIsCommitting] = useState(false);
12
- const [error, setError] = useState(null);
13
- const [inputFocused, setInputFocused] = useState(false);
14
- // Load HEAD message when amend is toggled
15
- useEffect(() => {
16
- if (amend) {
17
- getHeadMessage().then((msg) => {
18
- if (msg && !message) {
19
- setMessage(msg);
20
- }
21
- });
22
- }
23
- }, [amend, getHeadMessage]);
24
- const toggleAmend = useCallback(() => {
25
- setAmend((prev) => !prev);
26
- }, []);
27
- const handleSubmit = useCallback(async () => {
28
- const validation = validateCommit(message, stagedCount, amend);
29
- if (!validation.valid) {
30
- setError(validation.error);
31
- return;
32
- }
33
- setIsCommitting(true);
34
- setError(null);
35
- try {
36
- await onCommit(formatCommitMessage(message), amend);
37
- setMessage('');
38
- setAmend(false);
39
- onSuccess();
40
- }
41
- catch (err) {
42
- setError(err instanceof Error ? err.message : 'Commit failed');
43
- }
44
- finally {
45
- setIsCommitting(false);
46
- }
47
- }, [message, stagedCount, amend, onCommit, onSuccess]);
48
- const reset = useCallback(() => {
49
- setMessage('');
50
- setAmend(false);
51
- setError(null);
52
- setInputFocused(false);
53
- }, []);
54
- return {
55
- message,
56
- amend,
57
- isCommitting,
58
- error,
59
- inputFocused,
60
- setMessage,
61
- toggleAmend,
62
- setInputFocused,
63
- handleSubmit,
64
- reset,
65
- };
66
- }
@@ -1,123 +0,0 @@
1
- import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
2
- import { getCompareItemIndexFromRow, getFileScrollOffset } from '../utils/rowCalculations.js';
3
- import { buildCompareDisplayRows, getDisplayRowsLineNumWidth, getWrappedRowCount, } from '../utils/displayRows.js';
4
- export function useCompareState({ repoPath, isActive, compareDiff, refreshCompareDiff, getCandidateBaseBranches, setCompareBaseBranch, selectCompareCommit, topPaneHeight, compareScrollOffset, setCompareScrollOffset, setDiffScrollOffset, status, wrapMode, terminalWidth, }) {
5
- const [includeUncommitted, setIncludeUncommitted] = useState(true);
6
- const [compareListSelection, setCompareListSelection] = useState(null);
7
- const [compareSelectedIndex, setCompareSelectedIndex] = useState(0);
8
- const compareSelectionInitialized = useRef(false);
9
- const [baseBranchCandidates, setBaseBranchCandidates] = useState([]);
10
- const [showBaseBranchPicker, setShowBaseBranchPicker] = useState(false);
11
- // Fetch compare diff when tab becomes active
12
- useEffect(() => {
13
- if (repoPath && isActive) {
14
- refreshCompareDiff(includeUncommitted);
15
- }
16
- }, [repoPath, isActive, status, refreshCompareDiff, includeUncommitted]);
17
- // Fetch base branch candidates when entering compare view
18
- useEffect(() => {
19
- if (repoPath && isActive) {
20
- getCandidateBaseBranches().then(setBaseBranchCandidates);
21
- }
22
- }, [repoPath, isActive, getCandidateBaseBranches]);
23
- // Reset compare selection state when entering compare tab
24
- useEffect(() => {
25
- if (isActive) {
26
- compareSelectionInitialized.current = false;
27
- setCompareListSelection(null);
28
- setDiffScrollOffset(0);
29
- }
30
- }, [isActive, setDiffScrollOffset]);
31
- // Update compare selection when compareSelectedIndex changes (only after user interaction)
32
- useEffect(() => {
33
- if (isActive && compareDiff && compareSelectionInitialized.current) {
34
- const commitCount = compareDiff.commits.length;
35
- const fileCount = compareDiff.files.length;
36
- if (compareSelectedIndex < commitCount) {
37
- setCompareListSelection({ type: 'commit', index: compareSelectedIndex });
38
- selectCompareCommit(compareSelectedIndex);
39
- setDiffScrollOffset(0);
40
- }
41
- else if (compareSelectedIndex < commitCount + fileCount) {
42
- const fileIndex = compareSelectedIndex - commitCount;
43
- setCompareListSelection({ type: 'file', index: fileIndex });
44
- const scrollTo = getFileScrollOffset(compareDiff, fileIndex);
45
- setDiffScrollOffset(scrollTo);
46
- }
47
- }
48
- }, [isActive, compareDiff, compareSelectedIndex, selectCompareCommit, setDiffScrollOffset]);
49
- // Computed values
50
- const compareTotalItems = useMemo(() => {
51
- if (!compareDiff)
52
- return 0;
53
- return compareDiff.commits.length + compareDiff.files.length;
54
- }, [compareDiff]);
55
- // When wrap mode is enabled, account for wrapped lines
56
- const compareDiffTotalRows = useMemo(() => {
57
- const displayRows = buildCompareDisplayRows(compareDiff);
58
- if (!wrapMode)
59
- return displayRows.length;
60
- const lineNumWidth = getDisplayRowsLineNumWidth(displayRows);
61
- const contentWidth = terminalWidth - lineNumWidth - 5;
62
- return getWrappedRowCount(displayRows, contentWidth, true);
63
- }, [compareDiff, wrapMode, terminalWidth]);
64
- // Handlers
65
- const toggleIncludeUncommitted = useCallback(() => {
66
- setIncludeUncommitted((prev) => !prev);
67
- }, []);
68
- const openBaseBranchPicker = useCallback(() => {
69
- setShowBaseBranchPicker(true);
70
- }, []);
71
- const closeBaseBranchPicker = useCallback(() => {
72
- setShowBaseBranchPicker(false);
73
- }, []);
74
- const selectBaseBranch = useCallback((branch) => {
75
- setShowBaseBranchPicker(false);
76
- setCompareBaseBranch(branch, includeUncommitted);
77
- }, [setCompareBaseBranch, includeUncommitted]);
78
- const markSelectionInitialized = useCallback(() => {
79
- compareSelectionInitialized.current = true;
80
- }, []);
81
- const navigateCompareUp = useCallback(() => {
82
- compareSelectionInitialized.current = true;
83
- setCompareSelectedIndex((prev) => {
84
- const newIndex = Math.max(0, prev - 1);
85
- if (newIndex < compareScrollOffset)
86
- setCompareScrollOffset(newIndex);
87
- return newIndex;
88
- });
89
- }, [compareScrollOffset, setCompareScrollOffset]);
90
- const navigateCompareDown = useCallback(() => {
91
- compareSelectionInitialized.current = true;
92
- setCompareSelectedIndex((prev) => {
93
- const newIndex = Math.min(compareTotalItems - 1, prev + 1);
94
- const visibleEnd = compareScrollOffset + topPaneHeight - 2;
95
- if (newIndex >= visibleEnd)
96
- setCompareScrollOffset(compareScrollOffset + 1);
97
- return newIndex;
98
- });
99
- }, [compareTotalItems, compareScrollOffset, topPaneHeight, setCompareScrollOffset]);
100
- const getItemIndexFromRow = useCallback((visualRow) => {
101
- if (!compareDiff)
102
- return -1;
103
- return getCompareItemIndexFromRow(visualRow, compareDiff.commits.length, compareDiff.files.length);
104
- }, [compareDiff]);
105
- return {
106
- includeUncommitted,
107
- compareListSelection,
108
- compareSelectedIndex,
109
- baseBranchCandidates,
110
- showBaseBranchPicker,
111
- compareTotalItems,
112
- compareDiffTotalRows,
113
- setCompareSelectedIndex,
114
- toggleIncludeUncommitted,
115
- openBaseBranchPicker,
116
- closeBaseBranchPicker,
117
- selectBaseBranch,
118
- navigateCompareUp,
119
- navigateCompareDown,
120
- markSelectionInitialized,
121
- getItemIndexFromRow,
122
- };
123
- }
@@ -1,248 +0,0 @@
1
- import { useState, useEffect, useCallback, useMemo } from 'react';
2
- import * as fs from 'node:fs';
3
- import * as path from 'node:path';
4
- import { simpleGit } from 'simple-git';
5
- const MAX_FILE_SIZE = 1024 * 1024; // 1MB
6
- const WARN_FILE_SIZE = 100 * 1024; // 100KB
7
- // Check if content appears to be binary
8
- function isBinaryContent(buffer) {
9
- // Check first 8KB for null bytes (common in binary files)
10
- const checkLength = Math.min(buffer.length, 8192);
11
- for (let i = 0; i < checkLength; i++) {
12
- if (buffer[i] === 0)
13
- return true;
14
- }
15
- return false;
16
- }
17
- // Get ignored files using git check-ignore
18
- async function getIgnoredFiles(repoPath, files) {
19
- if (files.length === 0)
20
- return new Set();
21
- const git = simpleGit(repoPath);
22
- const ignoredFiles = new Set();
23
- const batchSize = 100;
24
- for (let i = 0; i < files.length; i += batchSize) {
25
- const batch = files.slice(i, i + batchSize);
26
- try {
27
- const result = await git.raw(['check-ignore', ...batch]);
28
- const ignored = result
29
- .trim()
30
- .split('\n')
31
- .filter((f) => f.length > 0);
32
- for (const f of ignored) {
33
- ignoredFiles.add(f);
34
- }
35
- }
36
- catch {
37
- // check-ignore exits with code 1 if no files are ignored
38
- }
39
- }
40
- return ignoredFiles;
41
- }
42
- export function useExplorerState({ repoPath, isActive, topPaneHeight, explorerScrollOffset, setExplorerScrollOffset, fileScrollOffset, setFileScrollOffset, hideHiddenFiles, hideGitignored, }) {
43
- const [currentPath, setCurrentPath] = useState('');
44
- const [items, setItems] = useState([]);
45
- const [selectedIndex, setSelectedIndex] = useState(0);
46
- const [selectedFile, setSelectedFile] = useState(null);
47
- const [isLoading, setIsLoading] = useState(false);
48
- const [error, setError] = useState(null);
49
- // Load directory contents when path changes or tab becomes active
50
- useEffect(() => {
51
- if (!isActive || !repoPath)
52
- return;
53
- const loadDirectory = async () => {
54
- setIsLoading(true);
55
- setError(null);
56
- try {
57
- const fullPath = path.join(repoPath, currentPath);
58
- // Read directory
59
- const entries = await fs.promises.readdir(fullPath, { withFileTypes: true });
60
- // Build list of paths for gitignore check
61
- const pathsToCheck = entries.map((e) => currentPath ? path.join(currentPath, e.name) : e.name);
62
- // Get ignored files (only if we need to filter them)
63
- const ignoredFiles = hideGitignored
64
- ? await getIgnoredFiles(repoPath, pathsToCheck)
65
- : new Set();
66
- // Filter and map entries
67
- const explorerItems = entries
68
- .filter((entry) => {
69
- // Filter dot-prefixed hidden files (e.g., .env, .gitignore)
70
- if (hideHiddenFiles && entry.name.startsWith('.')) {
71
- return false;
72
- }
73
- // Filter gitignored files
74
- if (hideGitignored) {
75
- const relativePath = currentPath ? path.join(currentPath, entry.name) : entry.name;
76
- if (ignoredFiles.has(relativePath)) {
77
- return false;
78
- }
79
- }
80
- return true;
81
- })
82
- .map((entry) => ({
83
- name: entry.name,
84
- path: currentPath ? path.join(currentPath, entry.name) : entry.name,
85
- isDirectory: entry.isDirectory(),
86
- }));
87
- // Sort: directories first (alphabetical), then files (alphabetical)
88
- explorerItems.sort((a, b) => {
89
- if (a.isDirectory && !b.isDirectory)
90
- return -1;
91
- if (!a.isDirectory && b.isDirectory)
92
- return 1;
93
- return a.name.localeCompare(b.name);
94
- });
95
- // Add ".." at the beginning if not at root
96
- if (currentPath) {
97
- explorerItems.unshift({
98
- name: '..',
99
- path: path.dirname(currentPath) || '',
100
- isDirectory: true,
101
- });
102
- }
103
- setItems(explorerItems);
104
- setSelectedIndex(0);
105
- setExplorerScrollOffset(0);
106
- }
107
- catch (err) {
108
- setError(err instanceof Error ? err.message : 'Failed to read directory');
109
- setItems([]);
110
- }
111
- finally {
112
- setIsLoading(false);
113
- }
114
- };
115
- loadDirectory();
116
- }, [repoPath, currentPath, isActive, setExplorerScrollOffset, hideHiddenFiles, hideGitignored]);
117
- // Load file content when selection changes to a file
118
- useEffect(() => {
119
- if (!isActive || !repoPath || items.length === 0) {
120
- setSelectedFile(null);
121
- return;
122
- }
123
- const selected = items[selectedIndex];
124
- if (!selected || selected.isDirectory) {
125
- setSelectedFile(null);
126
- return;
127
- }
128
- const loadFile = async () => {
129
- try {
130
- const fullPath = path.join(repoPath, selected.path);
131
- const stats = await fs.promises.stat(fullPath);
132
- // Check file size
133
- if (stats.size > MAX_FILE_SIZE) {
134
- setSelectedFile({
135
- path: selected.path,
136
- content: `File too large to display (${(stats.size / 1024 / 1024).toFixed(2)} MB).\nMaximum size: 1 MB`,
137
- truncated: true,
138
- });
139
- return;
140
- }
141
- const buffer = await fs.promises.readFile(fullPath);
142
- // Check if binary
143
- if (isBinaryContent(buffer)) {
144
- setSelectedFile({
145
- path: selected.path,
146
- content: 'Binary file - cannot display',
147
- });
148
- return;
149
- }
150
- let content = buffer.toString('utf-8');
151
- let truncated = false;
152
- // Warn about large files
153
- if (stats.size > WARN_FILE_SIZE) {
154
- const warning = `⚠ Large file (${(stats.size / 1024).toFixed(1)} KB)\n\n`;
155
- content = warning + content;
156
- }
157
- // Truncate if needed (shouldn't happen given MAX_FILE_SIZE, but just in case)
158
- const maxLines = 5000;
159
- const lines = content.split('\n');
160
- if (lines.length > maxLines) {
161
- content =
162
- lines.slice(0, maxLines).join('\n') +
163
- `\n\n... (truncated, ${lines.length - maxLines} more lines)`;
164
- truncated = true;
165
- }
166
- setSelectedFile({
167
- path: selected.path,
168
- content,
169
- truncated,
170
- });
171
- setFileScrollOffset(0);
172
- }
173
- catch (err) {
174
- setSelectedFile({
175
- path: selected.path,
176
- content: err instanceof Error ? `Error: ${err.message}` : 'Failed to read file',
177
- });
178
- }
179
- };
180
- loadFile();
181
- }, [repoPath, items, selectedIndex, isActive, setFileScrollOffset]);
182
- // Total rows for scroll calculations (item count)
183
- const explorerTotalRows = useMemo(() => items.length, [items]);
184
- // Navigation handlers
185
- const navigateUp = useCallback(() => {
186
- setSelectedIndex((prev) => {
187
- const newIndex = Math.max(0, prev - 1);
188
- // When scrolled, the top indicator takes a row, so first visible item is scrollOffset
189
- // but we want to keep item visible above the indicator when scrolling up
190
- if (newIndex < explorerScrollOffset) {
191
- setExplorerScrollOffset(newIndex);
192
- }
193
- return newIndex;
194
- });
195
- }, [explorerScrollOffset, setExplorerScrollOffset]);
196
- const navigateDown = useCallback(() => {
197
- setSelectedIndex((prev) => {
198
- const newIndex = Math.min(items.length - 1, prev + 1);
199
- // Calculate visible area: topPaneHeight - 1 for "EXPLORER" header
200
- // When content needs scrolling, ScrollableList reserves 2 more rows for indicators
201
- const maxHeight = topPaneHeight - 1;
202
- const needsScrolling = items.length > maxHeight;
203
- const availableHeight = needsScrolling ? maxHeight - 2 : maxHeight;
204
- const visibleEnd = explorerScrollOffset + availableHeight;
205
- if (newIndex >= visibleEnd) {
206
- setExplorerScrollOffset(explorerScrollOffset + 1);
207
- }
208
- return newIndex;
209
- });
210
- }, [items.length, explorerScrollOffset, topPaneHeight, setExplorerScrollOffset]);
211
- const enterDirectory = useCallback(() => {
212
- const selected = items[selectedIndex];
213
- if (!selected)
214
- return;
215
- if (selected.isDirectory) {
216
- if (selected.name === '..') {
217
- // Go to parent directory
218
- setCurrentPath(path.dirname(currentPath) || '');
219
- }
220
- else {
221
- // Enter the directory
222
- setCurrentPath(selected.path);
223
- }
224
- }
225
- // If it's a file, do nothing (file content is already shown in bottom pane)
226
- }, [items, selectedIndex, currentPath]);
227
- const goUp = useCallback(() => {
228
- if (currentPath) {
229
- setCurrentPath(path.dirname(currentPath) || '');
230
- }
231
- }, [currentPath]);
232
- return {
233
- currentPath,
234
- items,
235
- selectedIndex,
236
- setSelectedIndex,
237
- selectedFile,
238
- fileScrollOffset,
239
- setFileScrollOffset,
240
- navigateUp,
241
- navigateDown,
242
- enterDirectory,
243
- goUp,
244
- isLoading,
245
- error,
246
- explorerTotalRows,
247
- };
248
- }