diffstalker 0.2.0 → 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.
@@ -0,0 +1,85 @@
1
+ import { FilePathWatcher } from './core/FilePathWatcher.js';
2
+ /**
3
+ * Manages the file-watching follow mode.
4
+ * Watches a target file for repository path changes and file navigation.
5
+ */
6
+ export class FollowMode {
7
+ targetFile;
8
+ getCurrentRepoPath;
9
+ callbacks;
10
+ watcher = null;
11
+ _watcherState = { enabled: false };
12
+ constructor(targetFile, getCurrentRepoPath, callbacks) {
13
+ this.targetFile = targetFile;
14
+ this.getCurrentRepoPath = getCurrentRepoPath;
15
+ this.callbacks = callbacks;
16
+ }
17
+ get watcherState() {
18
+ return this._watcherState;
19
+ }
20
+ get isEnabled() {
21
+ return this.watcher !== null;
22
+ }
23
+ /**
24
+ * Start watching the target file.
25
+ */
26
+ start() {
27
+ this.watcher = new FilePathWatcher(this.targetFile);
28
+ this.watcher.on('path-change', (state) => {
29
+ if (state.path && state.path !== this.getCurrentRepoPath()) {
30
+ this._watcherState = {
31
+ enabled: true,
32
+ sourceFile: state.sourceFile ?? this.targetFile,
33
+ rawContent: state.rawContent ?? undefined,
34
+ lastUpdate: state.lastUpdate ?? undefined,
35
+ };
36
+ this.callbacks.onRepoChange(state.path, this._watcherState);
37
+ }
38
+ // Navigate to the followed file if it's within the repo
39
+ if (state.rawContent) {
40
+ this.callbacks.onFileNavigate(state.rawContent);
41
+ }
42
+ });
43
+ this._watcherState = {
44
+ enabled: true,
45
+ sourceFile: this.targetFile,
46
+ };
47
+ this.watcher.start();
48
+ // Switch to the repo described in the target file
49
+ const initialState = this.watcher.state;
50
+ if (initialState.path && initialState.path !== this.getCurrentRepoPath()) {
51
+ this._watcherState = {
52
+ enabled: true,
53
+ sourceFile: initialState.sourceFile ?? this.targetFile,
54
+ rawContent: initialState.rawContent ?? undefined,
55
+ lastUpdate: initialState.lastUpdate ?? undefined,
56
+ };
57
+ this.callbacks.onRepoChange(initialState.path, this._watcherState);
58
+ }
59
+ else if (initialState.rawContent) {
60
+ this._watcherState.rawContent = initialState.rawContent;
61
+ this.callbacks.onFileNavigate(initialState.rawContent);
62
+ }
63
+ }
64
+ /**
65
+ * Toggle follow mode on/off.
66
+ */
67
+ toggle() {
68
+ if (this.watcher) {
69
+ this.stop();
70
+ }
71
+ else {
72
+ this.start();
73
+ }
74
+ }
75
+ /**
76
+ * Stop watching.
77
+ */
78
+ stop() {
79
+ if (this.watcher) {
80
+ this.watcher.stop();
81
+ this.watcher = null;
82
+ this._watcherState = { enabled: false };
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,178 @@
1
+ import { SPLIT_RATIO_STEP } from './ui/Layout.js';
2
+ import { getFileAtIndex } from './ui/widgets/FileList.js';
3
+ /**
4
+ * Register all keyboard bindings on the blessed screen.
5
+ */
6
+ export function setupKeyBindings(screen, actions, ctx) {
7
+ // Quit
8
+ screen.key(['q', 'C-c'], () => {
9
+ actions.exit();
10
+ });
11
+ // Navigation (skip if modal is open)
12
+ screen.key(['j', 'down'], () => {
13
+ if (ctx.hasActiveModal())
14
+ return;
15
+ actions.navigateDown();
16
+ });
17
+ screen.key(['k', 'up'], () => {
18
+ if (ctx.hasActiveModal())
19
+ return;
20
+ actions.navigateUp();
21
+ });
22
+ // Tab switching (skip if modal is open)
23
+ const tabs = [
24
+ ['1', 'diff'],
25
+ ['2', 'commit'],
26
+ ['3', 'history'],
27
+ ['4', 'compare'],
28
+ ['5', 'explorer'],
29
+ ];
30
+ for (const [key, tab] of tabs) {
31
+ screen.key([key], () => {
32
+ if (ctx.hasActiveModal())
33
+ return;
34
+ ctx.uiState.setTab(tab);
35
+ });
36
+ }
37
+ // Pane toggle (skip if modal is open)
38
+ screen.key(['tab'], () => {
39
+ if (ctx.hasActiveModal())
40
+ return;
41
+ ctx.uiState.togglePane();
42
+ });
43
+ // Staging operations (skip if modal is open)
44
+ screen.key(['s'], () => {
45
+ if (ctx.hasActiveModal())
46
+ return;
47
+ actions.stageSelected();
48
+ });
49
+ screen.key(['S-u'], () => {
50
+ if (ctx.hasActiveModal())
51
+ return;
52
+ actions.unstageSelected();
53
+ });
54
+ screen.key(['S-a'], () => {
55
+ if (ctx.hasActiveModal())
56
+ return;
57
+ actions.stageAll();
58
+ });
59
+ screen.key(['S-z'], () => {
60
+ if (ctx.hasActiveModal())
61
+ return;
62
+ actions.unstageAll();
63
+ });
64
+ // Select/toggle (skip if modal is open)
65
+ screen.key(['enter', 'space'], () => {
66
+ if (ctx.hasActiveModal())
67
+ return;
68
+ if (ctx.getBottomTab() === 'explorer' && ctx.getCurrentPane() === 'explorer') {
69
+ actions.enterExplorerDirectory();
70
+ }
71
+ else {
72
+ actions.toggleSelected();
73
+ }
74
+ });
75
+ // Explorer: go up directory (skip if modal is open)
76
+ screen.key(['backspace'], () => {
77
+ if (ctx.hasActiveModal())
78
+ return;
79
+ if (ctx.getBottomTab() === 'explorer' && ctx.getCurrentPane() === 'explorer') {
80
+ actions.goExplorerUp();
81
+ }
82
+ });
83
+ // Explorer: toggle show only changes filter
84
+ screen.key(['g'], () => {
85
+ if (ctx.hasActiveModal())
86
+ return;
87
+ if (ctx.getBottomTab() === 'explorer') {
88
+ ctx.explorerManager?.toggleShowOnlyChanges();
89
+ }
90
+ });
91
+ // Explorer: open file finder
92
+ screen.key(['/'], () => {
93
+ if (ctx.hasActiveModal())
94
+ return;
95
+ if (ctx.getBottomTab() === 'explorer') {
96
+ actions.openFileFinder();
97
+ }
98
+ });
99
+ // Commit (skip if modal is open)
100
+ screen.key(['c'], () => {
101
+ if (ctx.hasActiveModal())
102
+ return;
103
+ ctx.uiState.setTab('commit');
104
+ });
105
+ // Commit panel specific keys (only when on commit tab)
106
+ screen.key(['i'], () => {
107
+ if (ctx.getBottomTab() === 'commit' && !ctx.isCommitInputFocused()) {
108
+ actions.focusCommitInput();
109
+ }
110
+ });
111
+ screen.key(['a'], () => {
112
+ if (ctx.getBottomTab() === 'commit' && !ctx.isCommitInputFocused()) {
113
+ ctx.commitFlowState.toggleAmend();
114
+ actions.render();
115
+ }
116
+ else {
117
+ ctx.uiState.toggleAutoTab();
118
+ }
119
+ });
120
+ screen.key(['escape'], () => {
121
+ if (ctx.getBottomTab() === 'commit') {
122
+ if (ctx.isCommitInputFocused()) {
123
+ actions.unfocusCommitInput();
124
+ }
125
+ else {
126
+ ctx.uiState.setTab('diff');
127
+ }
128
+ }
129
+ });
130
+ // Refresh
131
+ screen.key(['r'], () => actions.refresh());
132
+ // Display toggles
133
+ screen.key(['w'], () => ctx.uiState.toggleWrapMode());
134
+ screen.key(['m'], () => actions.toggleMouseMode());
135
+ screen.key(['S-t'], () => ctx.uiState.toggleAutoTab());
136
+ // Split ratio adjustments
137
+ screen.key(['-', '_', '['], () => {
138
+ ctx.uiState.adjustSplitRatio(-SPLIT_RATIO_STEP);
139
+ ctx.layout.setSplitRatio(ctx.uiState.state.splitRatio);
140
+ actions.render();
141
+ });
142
+ screen.key(['=', '+', ']'], () => {
143
+ ctx.uiState.adjustSplitRatio(SPLIT_RATIO_STEP);
144
+ ctx.layout.setSplitRatio(ctx.uiState.state.splitRatio);
145
+ actions.render();
146
+ });
147
+ // Theme picker
148
+ screen.key(['t'], () => ctx.uiState.openModal('theme'));
149
+ // Hotkeys modal
150
+ screen.key(['?'], () => ctx.uiState.toggleModal('hotkeys'));
151
+ // Follow toggle
152
+ screen.key(['f'], () => actions.toggleFollow());
153
+ // Compare view: base branch picker
154
+ screen.key(['b'], () => {
155
+ if (ctx.getBottomTab() === 'compare') {
156
+ ctx.uiState.openModal('baseBranch');
157
+ }
158
+ });
159
+ // Compare view: toggle uncommitted
160
+ screen.key(['u'], () => {
161
+ if (ctx.getBottomTab() === 'compare') {
162
+ ctx.uiState.toggleIncludeUncommitted();
163
+ const includeUncommitted = ctx.uiState.state.includeUncommitted;
164
+ ctx.gitManager?.refreshCompareDiff(includeUncommitted);
165
+ }
166
+ });
167
+ // Discard changes (with confirmation)
168
+ screen.key(['d'], () => {
169
+ if (ctx.getBottomTab() === 'diff') {
170
+ const files = ctx.getStatusFiles();
171
+ const selectedFile = getFileAtIndex(files, ctx.getSelectedIndex());
172
+ // Only allow discard for unstaged modified files
173
+ if (selectedFile && !selectedFile.staged && selectedFile.status !== 'untracked') {
174
+ actions.showDiscardConfirm(selectedFile);
175
+ }
176
+ }
177
+ });
178
+ }
@@ -0,0 +1,156 @@
1
+ import { getFileListTotalRows, getFileIndexFromRow } from './ui/widgets/FileList.js';
2
+ import { getCompareListTotalRows, getCompareSelectionFromRow, } from './ui/widgets/CompareListView.js';
3
+ import { getExplorerTotalRows } from './ui/widgets/ExplorerView.js';
4
+ import { getExplorerContentTotalRows } from './ui/widgets/ExplorerContent.js';
5
+ const SCROLL_AMOUNT = 3;
6
+ /**
7
+ * Register all mouse event handlers on the layout.
8
+ */
9
+ export function setupMouseHandlers(layout, actions, ctx) {
10
+ // Mouse wheel on top pane
11
+ layout.topPane.on('wheeldown', () => {
12
+ handleTopPaneScroll(SCROLL_AMOUNT, layout, ctx);
13
+ });
14
+ layout.topPane.on('wheelup', () => {
15
+ handleTopPaneScroll(-SCROLL_AMOUNT, layout, ctx);
16
+ });
17
+ // Mouse wheel on bottom pane
18
+ layout.bottomPane.on('wheeldown', () => {
19
+ handleBottomPaneScroll(SCROLL_AMOUNT, layout, ctx);
20
+ });
21
+ layout.bottomPane.on('wheelup', () => {
22
+ handleBottomPaneScroll(-SCROLL_AMOUNT, layout, ctx);
23
+ });
24
+ // Click on top pane to select item
25
+ layout.topPane.on('click', (mouse) => {
26
+ const clickedRow = layout.screenYToTopPaneRow(mouse.y);
27
+ if (clickedRow >= 0) {
28
+ handleTopPaneClick(clickedRow, mouse.x, actions, ctx);
29
+ }
30
+ });
31
+ // Click on footer for tabs and toggles
32
+ layout.footerBox.on('click', (mouse) => {
33
+ handleFooterClick(mouse.x, actions, ctx);
34
+ });
35
+ }
36
+ function handleTopPaneClick(row, x, actions, ctx) {
37
+ const state = ctx.uiState.state;
38
+ if (state.bottomTab === 'history') {
39
+ const index = state.historyScrollOffset + row;
40
+ ctx.uiState.setHistorySelectedIndex(index);
41
+ actions.selectHistoryCommitByIndex(index);
42
+ }
43
+ else if (state.bottomTab === 'compare') {
44
+ const commits = ctx.getCompareCommits();
45
+ const files = ctx.getCompareFiles();
46
+ const selection = getCompareSelectionFromRow(state.compareScrollOffset + row, commits, files);
47
+ if (selection) {
48
+ actions.selectCompareItem(selection);
49
+ }
50
+ }
51
+ else if (state.bottomTab === 'explorer') {
52
+ const index = state.explorerScrollOffset + row;
53
+ ctx.explorerManager?.selectIndex(index);
54
+ ctx.uiState.setExplorerSelectedIndex(index);
55
+ }
56
+ else {
57
+ // Diff tab - select file
58
+ const files = ctx.getStatusFiles();
59
+ const fileIndex = getFileIndexFromRow(row + state.fileListScrollOffset, files);
60
+ if (fileIndex !== null && fileIndex >= 0) {
61
+ // Check if click is on the action button [+] or [-] (columns 2-4)
62
+ if (x !== undefined && x >= 2 && x <= 4) {
63
+ actions.toggleFileByIndex(fileIndex);
64
+ }
65
+ else {
66
+ ctx.uiState.setSelectedIndex(fileIndex);
67
+ actions.selectFileByIndex(fileIndex);
68
+ }
69
+ }
70
+ }
71
+ }
72
+ function handleFooterClick(x, actions, ctx) {
73
+ const width = ctx.getScreenWidth();
74
+ // Tabs are right-aligned
75
+ const tabPositions = [
76
+ { tab: 'explorer', width: 11 },
77
+ { tab: 'compare', width: 10 },
78
+ { tab: 'history', width: 10 },
79
+ { tab: 'commit', width: 9 },
80
+ { tab: 'diff', width: 7 },
81
+ ];
82
+ let rightEdge = width;
83
+ for (const { tab, width: tabWidth } of tabPositions) {
84
+ const leftEdge = rightEdge - tabWidth - 1;
85
+ if (x >= leftEdge && x < rightEdge) {
86
+ ctx.uiState.setTab(tab);
87
+ return;
88
+ }
89
+ rightEdge = leftEdge;
90
+ }
91
+ // Left side toggles (approximate positions)
92
+ if (x >= 2 && x <= 9) {
93
+ actions.toggleMouseMode();
94
+ }
95
+ else if (x >= 11 && x <= 16) {
96
+ ctx.uiState.toggleAutoTab();
97
+ }
98
+ else if (x >= 18 && x <= 23) {
99
+ ctx.uiState.toggleWrapMode();
100
+ }
101
+ else if (x >= 25 && x <= 32) {
102
+ actions.toggleFollow();
103
+ }
104
+ else if (x >= 34 && x <= 43 && ctx.uiState.state.bottomTab === 'explorer') {
105
+ ctx.explorerManager?.toggleShowOnlyChanges();
106
+ }
107
+ else if (x === 0) {
108
+ ctx.uiState.openModal('hotkeys');
109
+ }
110
+ }
111
+ function handleTopPaneScroll(delta, layout, ctx) {
112
+ const state = ctx.uiState.state;
113
+ const visibleHeight = layout.dimensions.topPaneHeight;
114
+ if (state.bottomTab === 'history') {
115
+ const totalRows = ctx.getHistoryCommitCount();
116
+ const maxOffset = Math.max(0, totalRows - visibleHeight);
117
+ const newOffset = Math.min(maxOffset, Math.max(0, state.historyScrollOffset + delta));
118
+ ctx.uiState.setHistoryScrollOffset(newOffset);
119
+ }
120
+ else if (state.bottomTab === 'compare') {
121
+ const totalRows = getCompareListTotalRows(ctx.getCompareCommits(), ctx.getCompareFiles());
122
+ const maxOffset = Math.max(0, totalRows - visibleHeight);
123
+ const newOffset = Math.min(maxOffset, Math.max(0, state.compareScrollOffset + delta));
124
+ ctx.uiState.setCompareScrollOffset(newOffset);
125
+ }
126
+ else if (state.bottomTab === 'explorer') {
127
+ const totalRows = getExplorerTotalRows(ctx.explorerManager?.state.displayRows ?? []);
128
+ const maxOffset = Math.max(0, totalRows - visibleHeight);
129
+ const newOffset = Math.min(maxOffset, Math.max(0, state.explorerScrollOffset + delta));
130
+ ctx.uiState.setExplorerScrollOffset(newOffset);
131
+ }
132
+ else {
133
+ const files = ctx.getStatusFiles();
134
+ const totalRows = getFileListTotalRows(files);
135
+ const maxOffset = Math.max(0, totalRows - visibleHeight);
136
+ const newOffset = Math.min(maxOffset, Math.max(0, state.fileListScrollOffset + delta));
137
+ ctx.uiState.setFileListScrollOffset(newOffset);
138
+ }
139
+ }
140
+ function handleBottomPaneScroll(delta, layout, ctx) {
141
+ const state = ctx.uiState.state;
142
+ const visibleHeight = layout.dimensions.bottomPaneHeight;
143
+ const width = ctx.getScreenWidth();
144
+ if (state.bottomTab === 'explorer') {
145
+ const selectedFile = ctx.explorerManager?.state.selectedFile;
146
+ const totalRows = getExplorerContentTotalRows(selectedFile?.content ?? null, selectedFile?.path ?? null, selectedFile?.truncated ?? false, width, state.wrapMode);
147
+ const maxOffset = Math.max(0, totalRows - visibleHeight);
148
+ const newOffset = Math.min(maxOffset, Math.max(0, state.explorerFileScrollOffset + delta));
149
+ ctx.uiState.setExplorerFileScrollOffset(newOffset);
150
+ }
151
+ else {
152
+ const maxOffset = Math.max(0, ctx.getBottomPaneTotalRows() - visibleHeight);
153
+ const newOffset = Math.min(maxOffset, Math.max(0, state.diffScrollOffset + delta));
154
+ ctx.uiState.setDiffScrollOffset(newOffset);
155
+ }
156
+ }