diffstalker 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/bun.lock +72 -312
  3. package/dist/App.js +1136 -515
  4. package/dist/core/ExplorerStateManager.js +266 -0
  5. package/dist/core/FilePathWatcher.js +133 -0
  6. package/dist/core/GitStateManager.js +75 -16
  7. package/dist/git/ignoreUtils.js +30 -0
  8. package/dist/git/status.js +2 -34
  9. package/dist/index.js +67 -53
  10. package/dist/ipc/CommandClient.js +165 -0
  11. package/dist/ipc/CommandServer.js +152 -0
  12. package/dist/state/CommitFlowState.js +86 -0
  13. package/dist/state/UIState.js +182 -0
  14. package/dist/types/tabs.js +4 -0
  15. package/dist/ui/Layout.js +252 -0
  16. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  17. package/dist/ui/modals/DiscardConfirm.js +77 -0
  18. package/dist/ui/modals/HotkeysModal.js +209 -0
  19. package/dist/ui/modals/ThemePicker.js +107 -0
  20. package/dist/ui/widgets/CommitPanel.js +58 -0
  21. package/dist/ui/widgets/CompareListView.js +216 -0
  22. package/dist/ui/widgets/DiffView.js +279 -0
  23. package/dist/ui/widgets/ExplorerContent.js +102 -0
  24. package/dist/ui/widgets/ExplorerView.js +95 -0
  25. package/dist/ui/widgets/FileList.js +185 -0
  26. package/dist/ui/widgets/Footer.js +46 -0
  27. package/dist/ui/widgets/Header.js +111 -0
  28. package/dist/ui/widgets/HistoryView.js +69 -0
  29. package/dist/utils/ansiToBlessed.js +125 -0
  30. package/dist/utils/displayRows.js +185 -6
  31. package/dist/utils/explorerDisplayRows.js +1 -1
  32. package/dist/utils/languageDetection.js +56 -0
  33. package/dist/utils/pathUtils.js +27 -0
  34. package/dist/utils/rowCalculations.js +37 -0
  35. package/dist/utils/wordDiff.js +50 -0
  36. package/package.json +11 -12
  37. package/dist/components/BaseBranchPicker.js +0 -60
  38. package/dist/components/BottomPane.js +0 -101
  39. package/dist/components/CommitPanel.js +0 -58
  40. package/dist/components/CompareListView.js +0 -110
  41. package/dist/components/ExplorerContentView.js +0 -80
  42. package/dist/components/ExplorerView.js +0 -37
  43. package/dist/components/FileList.js +0 -131
  44. package/dist/components/Footer.js +0 -6
  45. package/dist/components/Header.js +0 -107
  46. package/dist/components/HistoryView.js +0 -21
  47. package/dist/components/HotkeysModal.js +0 -108
  48. package/dist/components/Modal.js +0 -19
  49. package/dist/components/ScrollableList.js +0 -125
  50. package/dist/components/ThemePicker.js +0 -42
  51. package/dist/components/TopPane.js +0 -14
  52. package/dist/components/UnifiedDiffView.js +0 -115
  53. package/dist/hooks/useCommitFlow.js +0 -66
  54. package/dist/hooks/useCompareState.js +0 -123
  55. package/dist/hooks/useExplorerState.js +0 -248
  56. package/dist/hooks/useGit.js +0 -156
  57. package/dist/hooks/useHistoryState.js +0 -62
  58. package/dist/hooks/useKeymap.js +0 -167
  59. package/dist/hooks/useLayout.js +0 -154
  60. package/dist/hooks/useMouse.js +0 -87
  61. package/dist/hooks/useTerminalSize.js +0 -20
  62. package/dist/hooks/useWatcher.js +0 -137
@@ -1,154 +0,0 @@
1
- import { useState, useEffect, useMemo, useCallback } from 'react';
2
- import { getRowForFileIndex, calculateScrollOffset, getFileListSectionCounts, getFileListTotalRows, } from '../utils/layoutCalculations.js';
3
- import { calculatePaneBoundaries } from '../utils/mouseCoordinates.js';
4
- import { getMaxScrollOffset } from '../components/ScrollableList.js';
5
- // Layout constants (compact: single-line separators)
6
- // Header (1) + sep (1) + sep (1) + sep (1) + footer (1) = 5 lines overhead
7
- // Note: Header can be 2 lines when follow indicator causes branch to wrap
8
- export const LAYOUT_OVERHEAD = 5;
9
- // Default split ratios for different modes
10
- const DEFAULT_SPLIT_RATIOS = {
11
- diff: 0.4, // 40% top pane for staging area
12
- commit: 0.4, // 40% top pane for staging area
13
- history: 0.5, // 50% top pane for commit list (larger default)
14
- compare: 0.5, // 50% top pane for compare list (larger default)
15
- explorer: 0.4, // 40% top pane for file listing
16
- };
17
- // Step size for keyboard-based pane resizing (5% per keypress)
18
- export const SPLIT_RATIO_STEP = 0.05;
19
- export function useLayout(terminalHeight, terminalWidth, files, selectedIndex, diff, mode = 'diff', historySelectedIndex, initialSplitRatio, extraOverhead = 0) {
20
- // Calculate content height (terminal minus overhead)
21
- const contentHeight = terminalHeight - LAYOUT_OVERHEAD - extraOverhead;
22
- // Custom split ratio state (null means use default for mode)
23
- const [customSplitRatio, setCustomSplitRatio] = useState(initialSplitRatio ?? null);
24
- // Get the effective split ratio
25
- const effectiveSplitRatio = customSplitRatio ?? DEFAULT_SPLIT_RATIOS[mode];
26
- // Calculate pane heights based on custom ratio or mode default
27
- const { topPaneHeight, bottomPaneHeight } = useMemo(() => {
28
- // Apply the split ratio directly
29
- const minHeight = 5;
30
- const maxHeight = contentHeight - minHeight; // Leave at least minHeight for bottom pane
31
- const targetHeight = Math.floor(contentHeight * effectiveSplitRatio);
32
- const topHeight = Math.max(minHeight, Math.min(targetHeight, maxHeight));
33
- const bottomHeight = contentHeight - topHeight;
34
- return { topPaneHeight: topHeight, bottomPaneHeight: bottomHeight };
35
- }, [contentHeight, effectiveSplitRatio]);
36
- // Setter for split ratio with bounds checking
37
- const setSplitRatio = useCallback((ratio) => {
38
- // Clamp ratio between 0.15 and 0.85 to ensure both panes remain usable
39
- const clampedRatio = Math.max(0.15, Math.min(0.85, ratio));
40
- setCustomSplitRatio(clampedRatio);
41
- }, []);
42
- // Adjust split ratio by delta (for keyboard-based resizing)
43
- const adjustSplitRatio = useCallback((delta) => {
44
- const currentRatio = customSplitRatio ?? DEFAULT_SPLIT_RATIOS[mode];
45
- const newRatio = Math.max(0.15, Math.min(0.85, currentRatio + delta));
46
- setCustomSplitRatio(newRatio);
47
- }, [customSplitRatio, mode]);
48
- // Expose current split ratio
49
- const splitRatio = effectiveSplitRatio;
50
- // Calculate pane boundaries for mouse handling
51
- // extraOverhead = headerHeight - 1, so headerHeight = extraOverhead + 1
52
- const headerHeight = extraOverhead + 1;
53
- const paneBoundaries = useMemo(() => calculatePaneBoundaries(topPaneHeight, bottomPaneHeight, terminalHeight, headerHeight), [topPaneHeight, bottomPaneHeight, terminalHeight, headerHeight]);
54
- // Scroll state
55
- const [fileListScrollOffset, setFileListScrollOffset] = useState(0);
56
- const [diffScrollOffset, setDiffScrollOffset] = useState(0);
57
- const [historyScrollOffset, setHistoryScrollOffset] = useState(0);
58
- const [compareScrollOffset, setCompareScrollOffset] = useState(0);
59
- // Reset file list scroll when files change
60
- useEffect(() => {
61
- setFileListScrollOffset(0);
62
- }, [files.length]);
63
- // Reset diff scroll when diff changes
64
- useEffect(() => {
65
- setDiffScrollOffset(0);
66
- }, [diff]);
67
- // Auto-scroll file list to keep selected item visible (only when selection changes)
68
- useEffect(() => {
69
- const { modifiedCount, untrackedCount, stagedCount } = getFileListSectionCounts(files);
70
- const selectedRow = getRowForFileIndex(selectedIndex, modifiedCount, untrackedCount, stagedCount);
71
- const visibleHeight = topPaneHeight - 1;
72
- setFileListScrollOffset((prev) => {
73
- const newOffset = calculateScrollOffset(selectedRow, prev, visibleHeight);
74
- return newOffset;
75
- });
76
- }, [selectedIndex, files, topPaneHeight]);
77
- // Scroll helpers
78
- const scrollDiff = useCallback((direction, amount = 3, totalRows = 0) => {
79
- // Simple: totalRows = displayRows.length (every row = 1 terminal row)
80
- // Match ScrollableList's available height calculation:
81
- // - DiffView receives maxHeight = bottomPaneHeight - 1 (BottomPane header)
82
- // - ScrollableList reserves 2 rows for indicators when content needs scrolling
83
- const diffViewMaxHeight = bottomPaneHeight - 1;
84
- const needsScrolling = totalRows > diffViewMaxHeight;
85
- const availableAtMaxScroll = needsScrolling ? diffViewMaxHeight - 2 : diffViewMaxHeight;
86
- const maxOffset = Math.max(0, totalRows - availableAtMaxScroll);
87
- setDiffScrollOffset((prev) => {
88
- if (direction === 'up') {
89
- return Math.max(0, prev - amount);
90
- }
91
- else {
92
- return Math.min(maxOffset, prev + amount);
93
- }
94
- });
95
- }, [bottomPaneHeight]);
96
- const scrollFileList = useCallback((direction, amount = 3) => {
97
- const totalRows = getFileListTotalRows(files);
98
- const visibleRows = topPaneHeight - 1;
99
- const maxScroll = Math.max(0, totalRows - visibleRows);
100
- setFileListScrollOffset((prev) => {
101
- if (direction === 'up') {
102
- return Math.max(0, prev - amount);
103
- }
104
- else {
105
- return Math.min(maxScroll, prev + amount);
106
- }
107
- });
108
- }, [files, topPaneHeight]);
109
- const scrollHistory = useCallback((direction, totalItems = 0, amount = 3) => {
110
- // History is in top pane, maxHeight is topPaneHeight - 1 for "COMMITS" header
111
- const maxOffset = getMaxScrollOffset(totalItems, topPaneHeight - 1);
112
- setHistoryScrollOffset((prev) => {
113
- if (direction === 'up') {
114
- return Math.max(0, prev - amount);
115
- }
116
- else {
117
- return Math.min(maxOffset, prev + amount);
118
- }
119
- });
120
- }, [topPaneHeight]);
121
- const scrollCompare = useCallback((direction, totalRows, amount = 3) => {
122
- // Compare list is in top pane, maxHeight is topPaneHeight - 1 for "COMPARE" header
123
- const maxOffset = getMaxScrollOffset(totalRows, topPaneHeight - 1);
124
- setCompareScrollOffset((prev) => {
125
- if (direction === 'up') {
126
- return Math.max(0, prev - amount);
127
- }
128
- else {
129
- return Math.min(maxOffset, prev + amount);
130
- }
131
- });
132
- }, [topPaneHeight]);
133
- return {
134
- topPaneHeight,
135
- bottomPaneHeight,
136
- contentHeight,
137
- paneBoundaries,
138
- splitRatio,
139
- setSplitRatio,
140
- adjustSplitRatio,
141
- fileListScrollOffset,
142
- diffScrollOffset,
143
- historyScrollOffset,
144
- compareScrollOffset,
145
- setFileListScrollOffset,
146
- setDiffScrollOffset,
147
- setHistoryScrollOffset,
148
- setCompareScrollOffset,
149
- scrollDiff,
150
- scrollFileList,
151
- scrollHistory,
152
- scrollCompare,
153
- };
154
- }
@@ -1,87 +0,0 @@
1
- import { useEffect, useState, useCallback, useRef } from 'react';
2
- import { useStdin } from 'ink';
3
- export function useMouse(onEvent, disabled = false) {
4
- const { stdin, setRawMode } = useStdin();
5
- const [mouseEnabled, setMouseEnabled] = useState(true);
6
- const onEventRef = useRef(onEvent);
7
- useEffect(() => {
8
- onEventRef.current = onEvent;
9
- });
10
- const toggleMouse = useCallback(() => {
11
- setMouseEnabled((prev) => !prev);
12
- }, []);
13
- // Store mouseEnabled in ref for use in event handler
14
- const mouseEnabledRef = useRef(mouseEnabled);
15
- useEffect(() => {
16
- mouseEnabledRef.current = mouseEnabled;
17
- }, [mouseEnabled]);
18
- // Handle mouse mode changes
19
- // When disabled (text input focused) or in select mode, disable mouse tracking for text selection
20
- useEffect(() => {
21
- if (!disabled && mouseEnabled) {
22
- process.stdout.write('\x1b[?1000h');
23
- process.stdout.write('\x1b[?1006h');
24
- }
25
- else {
26
- process.stdout.write('\x1b[?1006l');
27
- process.stdout.write('\x1b[?1000l');
28
- }
29
- }, [disabled, mouseEnabled]);
30
- // Set up event listener (separate from mode toggle)
31
- useEffect(() => {
32
- if (!stdin || !setRawMode)
33
- return;
34
- const handleData = (data) => {
35
- const str = data.toString();
36
- // Parse SGR mouse events: \x1b[<button;x;y[Mm]
37
- // eslint-disable-next-line no-control-regex
38
- const sgrMatch = str.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
39
- if (sgrMatch) {
40
- const buttonCode = parseInt(sgrMatch[1], 10);
41
- const x = parseInt(sgrMatch[2], 10);
42
- const y = parseInt(sgrMatch[3], 10);
43
- const isRelease = sgrMatch[4] === 'm';
44
- // Scroll wheel events (button codes 64-67) - only when in scroll mode
45
- if (buttonCode >= 64 && buttonCode < 96) {
46
- if (mouseEnabledRef.current) {
47
- const type = buttonCode === 64 ? 'scroll-up' : 'scroll-down';
48
- onEventRef.current({ x, y, type, button: 'none' });
49
- }
50
- }
51
- // Click events (button codes 0-2) - only on release to avoid double-firing
52
- else if (isRelease && buttonCode >= 0 && buttonCode < 3) {
53
- const button = buttonCode === 0 ? 'left' : buttonCode === 1 ? 'middle' : 'right';
54
- onEventRef.current({ x, y, type: 'click', button });
55
- }
56
- return;
57
- }
58
- // Parse legacy mouse events
59
- // eslint-disable-next-line no-control-regex
60
- const legacyMatch = str.match(/\x1b\[M(.)(.)(.)/);
61
- if (legacyMatch) {
62
- const buttonCode = legacyMatch[1].charCodeAt(0) - 32;
63
- const x = legacyMatch[2].charCodeAt(0) - 32;
64
- const y = legacyMatch[3].charCodeAt(0) - 32;
65
- if (buttonCode >= 64) {
66
- if (mouseEnabledRef.current) {
67
- const type = buttonCode === 64 ? 'scroll-up' : 'scroll-down';
68
- onEventRef.current({ x, y, type, button: 'none' });
69
- }
70
- }
71
- // Legacy click events (button codes 0-2)
72
- else if (buttonCode >= 0 && buttonCode < 3) {
73
- const button = buttonCode === 0 ? 'left' : buttonCode === 1 ? 'middle' : 'right';
74
- onEventRef.current({ x, y, type: 'click', button });
75
- }
76
- }
77
- };
78
- stdin.on('data', handleData);
79
- return () => {
80
- stdin.off('data', handleData);
81
- // Disable mouse tracking on unmount
82
- process.stdout.write('\x1b[?1006l');
83
- process.stdout.write('\x1b[?1000l');
84
- };
85
- }, [stdin, setRawMode]);
86
- return { mouseEnabled, toggleMouse };
87
- }
@@ -1,20 +0,0 @@
1
- import { useState, useEffect } from 'react';
2
- export function useTerminalSize() {
3
- const [size, setSize] = useState({
4
- rows: process.stdout.rows ?? 24,
5
- columns: process.stdout.columns ?? 80,
6
- });
7
- useEffect(() => {
8
- const handleResize = () => {
9
- setSize({
10
- rows: process.stdout.rows ?? 24,
11
- columns: process.stdout.columns ?? 80,
12
- });
13
- };
14
- process.stdout.on('resize', handleResize);
15
- return () => {
16
- process.stdout.off('resize', handleResize);
17
- };
18
- }, []);
19
- return size;
20
- }
@@ -1,137 +0,0 @@
1
- import { useState, useEffect, useRef } from 'react';
2
- import * as fs from 'node:fs';
3
- import * as path from 'node:path';
4
- import * as os from 'node:os';
5
- import { watch } from 'chokidar';
6
- import { ensureTargetDir } from '../config.js';
7
- function expandPath(p) {
8
- // Expand ~ to home directory
9
- if (p.startsWith('~/')) {
10
- return path.join(os.homedir(), p.slice(2));
11
- }
12
- if (p === '~') {
13
- return os.homedir();
14
- }
15
- return p;
16
- }
17
- function getLastNonEmptyLine(content) {
18
- // Support append-only files by reading only the last non-empty line
19
- const lines = content.split('\n');
20
- for (let i = lines.length - 1; i >= 0; i--) {
21
- const line = lines[i].trim();
22
- if (line)
23
- return line;
24
- }
25
- return '';
26
- }
27
- export function useWatcher(initialEnabled, targetFile, debug = false) {
28
- const [enabled, setEnabled] = useState(initialEnabled);
29
- const [state, setState] = useState({
30
- path: null,
31
- lastUpdate: null,
32
- rawContent: null,
33
- sourceFile: initialEnabled ? targetFile : null,
34
- enabled: initialEnabled,
35
- });
36
- const debounceTimer = useRef(null);
37
- const lastReadPath = useRef(null);
38
- // Update state when enabled changes
39
- useEffect(() => {
40
- setState((prev) => ({
41
- ...prev,
42
- enabled,
43
- sourceFile: enabled ? targetFile : null,
44
- }));
45
- }, [enabled, targetFile]);
46
- useEffect(() => {
47
- // If watcher is disabled, do nothing
48
- if (!enabled) {
49
- return;
50
- }
51
- // Ensure the directory exists
52
- ensureTargetDir(targetFile);
53
- // Create the file if it doesn't exist
54
- if (!fs.existsSync(targetFile)) {
55
- fs.writeFileSync(targetFile, '');
56
- }
57
- // Read and set target path with debouncing
58
- const readTarget = () => {
59
- // Clear any pending debounce
60
- if (debounceTimer.current) {
61
- clearTimeout(debounceTimer.current);
62
- }
63
- debounceTimer.current = setTimeout(() => {
64
- try {
65
- const raw = fs.readFileSync(targetFile, 'utf-8');
66
- const content = getLastNonEmptyLine(raw);
67
- if (content && content !== lastReadPath.current) {
68
- // Expand ~ and resolve to absolute path
69
- const expanded = expandPath(content);
70
- const resolved = path.isAbsolute(expanded) ? expanded : path.resolve(expanded);
71
- const now = new Date();
72
- if (debug) {
73
- process.stderr.write(`[diffstalker ${now.toISOString()}] Path change detected\n`);
74
- process.stderr.write(` Source file: ${targetFile}\n`);
75
- process.stderr.write(` Raw content: "${content}"\n`);
76
- process.stderr.write(` Previous: "${lastReadPath.current ?? '(none)'}"\n`);
77
- process.stderr.write(` Resolved: "${resolved}"\n`);
78
- }
79
- lastReadPath.current = resolved;
80
- setState({
81
- path: resolved,
82
- lastUpdate: now,
83
- rawContent: content,
84
- sourceFile: targetFile,
85
- enabled: true,
86
- });
87
- }
88
- }
89
- catch {
90
- // Ignore read errors
91
- }
92
- }, 100);
93
- };
94
- // Read initial value immediately (no debounce for first read)
95
- try {
96
- const raw = fs.readFileSync(targetFile, 'utf-8');
97
- const content = getLastNonEmptyLine(raw);
98
- if (content) {
99
- // Expand ~ and resolve to absolute path
100
- const expanded = expandPath(content);
101
- const resolved = path.isAbsolute(expanded) ? expanded : path.resolve(expanded);
102
- const now = new Date();
103
- if (debug) {
104
- process.stderr.write(`[diffstalker ${now.toISOString()}] Initial path read\n`);
105
- process.stderr.write(` Source file: ${targetFile}\n`);
106
- process.stderr.write(` Raw content: "${content}"\n`);
107
- process.stderr.write(` Resolved: "${resolved}"\n`);
108
- }
109
- lastReadPath.current = resolved;
110
- setState({
111
- path: resolved,
112
- lastUpdate: now,
113
- rawContent: content,
114
- sourceFile: targetFile,
115
- enabled: true,
116
- });
117
- }
118
- }
119
- catch {
120
- // Ignore read errors
121
- }
122
- // Watch for changes
123
- const watcher = watch(targetFile, {
124
- persistent: true,
125
- ignoreInitial: true,
126
- });
127
- watcher.on('change', readTarget);
128
- watcher.on('add', readTarget);
129
- return () => {
130
- if (debounceTimer.current) {
131
- clearTimeout(debounceTimer.current);
132
- }
133
- watcher.close();
134
- };
135
- }, [enabled, targetFile, debug]);
136
- return { state, setEnabled };
137
- }