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.
- package/.github/workflows/release.yml +8 -0
- package/CHANGELOG.md +36 -0
- package/bun.lock +89 -306
- package/dist/App.js +895 -520
- package/dist/FollowMode.js +85 -0
- package/dist/KeyBindings.js +178 -0
- package/dist/MouseHandlers.js +156 -0
- package/dist/core/ExplorerStateManager.js +632 -0
- package/dist/core/FilePathWatcher.js +133 -0
- package/dist/core/GitStateManager.js +221 -86
- package/dist/git/diff.js +4 -0
- package/dist/git/ignoreUtils.js +30 -0
- package/dist/git/status.js +2 -34
- package/dist/index.js +68 -53
- package/dist/ipc/CommandClient.js +165 -0
- package/dist/ipc/CommandServer.js +152 -0
- package/dist/state/CommitFlowState.js +86 -0
- package/dist/state/UIState.js +195 -0
- package/dist/types/tabs.js +4 -0
- package/dist/ui/Layout.js +252 -0
- package/dist/ui/PaneRenderers.js +56 -0
- package/dist/ui/modals/BaseBranchPicker.js +110 -0
- package/dist/ui/modals/DiscardConfirm.js +77 -0
- package/dist/ui/modals/FileFinder.js +232 -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 +238 -0
- package/dist/ui/widgets/DiffView.js +281 -0
- package/dist/ui/widgets/ExplorerContent.js +89 -0
- package/dist/ui/widgets/ExplorerView.js +204 -0
- package/dist/ui/widgets/FileList.js +185 -0
- package/dist/ui/widgets/Footer.js +50 -0
- package/dist/ui/widgets/Header.js +68 -0
- package/dist/ui/widgets/HistoryView.js +69 -0
- package/dist/utils/displayRows.js +185 -6
- package/dist/utils/explorerDisplayRows.js +1 -1
- package/dist/utils/fileCategories.js +37 -0
- package/dist/utils/fileTree.js +148 -0
- package/dist/utils/languageDetection.js +56 -0
- package/dist/utils/pathUtils.js +27 -0
- package/dist/utils/wordDiff.js +50 -0
- package/eslint.metrics.js +16 -0
- package/metrics/.gitkeep +0 -0
- package/metrics/v0.2.1.json +268 -0
- package/package.json +14 -12
- package/dist/components/BaseBranchPicker.js +0 -60
- package/dist/components/BottomPane.js +0 -101
- package/dist/components/CommitPanel.js +0 -58
- package/dist/components/CompareListView.js +0 -110
- package/dist/components/ExplorerContentView.js +0 -80
- package/dist/components/ExplorerView.js +0 -37
- package/dist/components/FileList.js +0 -131
- package/dist/components/Footer.js +0 -6
- package/dist/components/Header.js +0 -107
- package/dist/components/HistoryView.js +0 -21
- package/dist/components/HotkeysModal.js +0 -108
- package/dist/components/Modal.js +0 -19
- package/dist/components/ScrollableList.js +0 -125
- package/dist/components/ThemePicker.js +0 -42
- package/dist/components/TopPane.js +0 -14
- package/dist/components/UnifiedDiffView.js +0 -115
- package/dist/hooks/useCommitFlow.js +0 -66
- package/dist/hooks/useCompareState.js +0 -123
- package/dist/hooks/useExplorerState.js +0 -248
- package/dist/hooks/useGit.js +0 -156
- package/dist/hooks/useHistoryState.js +0 -62
- package/dist/hooks/useKeymap.js +0 -167
- package/dist/hooks/useLayout.js +0 -154
- package/dist/hooks/useMouse.js +0 -87
- package/dist/hooks/useTerminalSize.js +0 -20
- package/dist/hooks/useWatcher.js +0 -137
- package/dist/utils/mouseCoordinates.js +0 -165
- 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
|
-
}
|