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
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Unix socket IPC client for controlling diffstalker remotely.
3
+ * Sends JSON commands and receives responses.
4
+ */
5
+ import * as net from 'net';
6
+ export class CommandClient {
7
+ socketPath;
8
+ timeout;
9
+ constructor(socketPath, timeout = 5000) {
10
+ this.socketPath = socketPath;
11
+ this.timeout = timeout;
12
+ }
13
+ /**
14
+ * Send a command and wait for response
15
+ */
16
+ async send(command) {
17
+ return new Promise((resolve, reject) => {
18
+ const socket = net.createConnection(this.socketPath);
19
+ let buffer = '';
20
+ let timeoutId;
21
+ const cleanup = () => {
22
+ clearTimeout(timeoutId);
23
+ socket.destroy();
24
+ };
25
+ timeoutId = setTimeout(() => {
26
+ cleanup();
27
+ reject(new Error(`Command timed out after ${this.timeout}ms`));
28
+ }, this.timeout);
29
+ socket.on('connect', () => {
30
+ socket.write(JSON.stringify(command) + '\n');
31
+ });
32
+ socket.on('data', (data) => {
33
+ buffer += data.toString();
34
+ const newlineIndex = buffer.indexOf('\n');
35
+ if (newlineIndex !== -1) {
36
+ const json = buffer.substring(0, newlineIndex);
37
+ cleanup();
38
+ try {
39
+ resolve(JSON.parse(json));
40
+ }
41
+ catch (err) {
42
+ reject(new Error(`Invalid JSON response: ${json}`));
43
+ }
44
+ }
45
+ });
46
+ socket.on('error', (err) => {
47
+ cleanup();
48
+ reject(err);
49
+ });
50
+ });
51
+ }
52
+ // Convenience methods for common operations
53
+ async navigateUp() {
54
+ return this.send({ action: 'navigateUp' });
55
+ }
56
+ async navigateDown() {
57
+ return this.send({ action: 'navigateDown' });
58
+ }
59
+ async switchTab(tab) {
60
+ return this.send({ action: 'switchTab', tab });
61
+ }
62
+ async togglePane() {
63
+ return this.send({ action: 'togglePane' });
64
+ }
65
+ async stage() {
66
+ return this.send({ action: 'stage' });
67
+ }
68
+ async unstage() {
69
+ return this.send({ action: 'unstage' });
70
+ }
71
+ async stageAll() {
72
+ return this.send({ action: 'stageAll' });
73
+ }
74
+ async unstageAll() {
75
+ return this.send({ action: 'unstageAll' });
76
+ }
77
+ async commit(message) {
78
+ return this.send({ action: 'commit', message });
79
+ }
80
+ async refresh() {
81
+ return this.send({ action: 'refresh' });
82
+ }
83
+ async getState() {
84
+ const result = await this.send({ action: 'getState' });
85
+ if (!result.success) {
86
+ throw new Error(result.error || 'Failed to get state');
87
+ }
88
+ if (!result.state) {
89
+ throw new Error('No state returned');
90
+ }
91
+ return result.state;
92
+ }
93
+ async quit() {
94
+ return this.send({ action: 'quit' });
95
+ }
96
+ /**
97
+ * Ping the server to check if it's ready
98
+ * Returns { success: true, ready: boolean }
99
+ */
100
+ async ping() {
101
+ return this.send({ action: 'ping' });
102
+ }
103
+ /**
104
+ * Wait for the server socket file to exist
105
+ */
106
+ async waitForSocketFile(maxWait = 10000, pollInterval = 100) {
107
+ const fs = await import('fs');
108
+ const startTime = Date.now();
109
+ while (Date.now() - startTime < maxWait) {
110
+ if (fs.existsSync(this.socketPath)) {
111
+ return; // Socket file exists
112
+ }
113
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
114
+ }
115
+ throw new Error(`Socket file not found after ${maxWait}ms: ${this.socketPath}`);
116
+ }
117
+ /**
118
+ * Wait for the server socket to be available and accepting connections
119
+ */
120
+ async waitForSocket(maxWait = 10000, pollInterval = 100) {
121
+ const startTime = Date.now();
122
+ let lastError = null;
123
+ let attempts = 0;
124
+ // First wait for the socket file to exist
125
+ await this.waitForSocketFile(maxWait, pollInterval);
126
+ // Then wait for it to accept connections
127
+ while (Date.now() - startTime < maxWait) {
128
+ attempts++;
129
+ try {
130
+ await this.ping();
131
+ return; // Socket is available
132
+ }
133
+ catch (err) {
134
+ // Socket not available yet, wait and retry
135
+ lastError = err;
136
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
137
+ }
138
+ }
139
+ throw new Error(`Socket not accepting connections after ${maxWait}ms (${attempts} attempts). Last error: ${lastError?.message}`);
140
+ }
141
+ /**
142
+ * Wait for the server to be fully ready (socket + handler registered)
143
+ */
144
+ async waitForServer(maxWait = 10000, pollInterval = 100) {
145
+ const startTime = Date.now();
146
+ // First wait for socket to be available
147
+ await this.waitForSocket(maxWait, pollInterval);
148
+ // Then wait for the app to be ready (handler registered)
149
+ while (Date.now() - startTime < maxWait) {
150
+ try {
151
+ const result = await this.ping();
152
+ if (result.success && result.ready) {
153
+ return; // App is ready
154
+ }
155
+ // Socket available but handler not ready yet
156
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
157
+ }
158
+ catch {
159
+ // Connection failed, wait and retry
160
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
161
+ }
162
+ }
163
+ throw new Error(`Server not ready after ${maxWait}ms`);
164
+ }
165
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Unix socket IPC server for remote control of diffstalker.
3
+ * Receives JSON commands and dispatches them to the app.
4
+ */
5
+ import * as net from 'net';
6
+ import * as fs from 'fs';
7
+ import { EventEmitter } from 'events';
8
+ export class CommandServer extends EventEmitter {
9
+ server = null;
10
+ socketPath;
11
+ handler = null;
12
+ ready = false;
13
+ constructor(socketPath) {
14
+ super();
15
+ this.socketPath = socketPath;
16
+ }
17
+ /**
18
+ * Set the command handler (called by App after initialization)
19
+ */
20
+ setHandler(handler) {
21
+ this.handler = handler;
22
+ }
23
+ /**
24
+ * Mark the app as ready (called after handler is set and app is initialized)
25
+ */
26
+ notifyReady() {
27
+ this.ready = true;
28
+ this.emit('ready');
29
+ }
30
+ /**
31
+ * Check if the app is ready
32
+ */
33
+ isReady() {
34
+ return this.ready && this.handler !== null;
35
+ }
36
+ /**
37
+ * Start listening for connections
38
+ */
39
+ async start() {
40
+ // Remove existing socket file if it exists
41
+ if (fs.existsSync(this.socketPath)) {
42
+ fs.unlinkSync(this.socketPath);
43
+ }
44
+ return new Promise((resolve, reject) => {
45
+ this.server = net.createServer((socket) => {
46
+ this.handleConnection(socket);
47
+ });
48
+ this.server.on('error', (err) => {
49
+ reject(err);
50
+ });
51
+ this.server.listen(this.socketPath, () => {
52
+ // Set socket permissions to user-only
53
+ fs.chmodSync(this.socketPath, 0o600);
54
+ resolve();
55
+ });
56
+ });
57
+ }
58
+ /**
59
+ * Stop the server and clean up
60
+ */
61
+ stop() {
62
+ if (this.server) {
63
+ this.server.close();
64
+ this.server = null;
65
+ }
66
+ // Clean up socket file
67
+ if (fs.existsSync(this.socketPath)) {
68
+ fs.unlinkSync(this.socketPath);
69
+ }
70
+ }
71
+ /**
72
+ * Handle an incoming connection
73
+ */
74
+ handleConnection(socket) {
75
+ let buffer = '';
76
+ socket.on('data', async (data) => {
77
+ buffer += data.toString();
78
+ // Process complete JSON messages (newline-delimited)
79
+ const lines = buffer.split('\n');
80
+ buffer = lines.pop() || ''; // Keep incomplete line in buffer
81
+ for (const line of lines) {
82
+ if (line.trim()) {
83
+ const result = await this.processCommand(line);
84
+ socket.write(JSON.stringify(result) + '\n');
85
+ }
86
+ }
87
+ });
88
+ socket.on('error', (err) => {
89
+ this.emit('error', err);
90
+ });
91
+ }
92
+ /**
93
+ * Process a single command
94
+ */
95
+ async processCommand(json) {
96
+ try {
97
+ const command = JSON.parse(json);
98
+ // ping can be handled without a handler - used to check readiness
99
+ if (command.action === 'ping') {
100
+ return { success: true, ready: this.isReady() };
101
+ }
102
+ if (!this.handler) {
103
+ return { success: false, error: 'No handler registered' };
104
+ }
105
+ switch (command.action) {
106
+ case 'navigateUp':
107
+ this.handler.navigateUp();
108
+ return { success: true };
109
+ case 'navigateDown':
110
+ this.handler.navigateDown();
111
+ return { success: true };
112
+ case 'switchTab':
113
+ this.handler.switchTab(command.tab);
114
+ return { success: true };
115
+ case 'togglePane':
116
+ this.handler.togglePane();
117
+ return { success: true };
118
+ case 'stage':
119
+ await this.handler.stage();
120
+ return { success: true };
121
+ case 'unstage':
122
+ await this.handler.unstage();
123
+ return { success: true };
124
+ case 'stageAll':
125
+ await this.handler.stageAll();
126
+ return { success: true };
127
+ case 'unstageAll':
128
+ await this.handler.unstageAll();
129
+ return { success: true };
130
+ case 'commit':
131
+ await this.handler.commit(command.message);
132
+ return { success: true };
133
+ case 'refresh':
134
+ await this.handler.refresh();
135
+ return { success: true };
136
+ case 'getState':
137
+ return { success: true, state: this.handler.getState() };
138
+ case 'quit':
139
+ this.handler.quit();
140
+ return { success: true };
141
+ default:
142
+ return {
143
+ success: false,
144
+ error: `Unknown action: ${command.action}`,
145
+ };
146
+ }
147
+ }
148
+ catch (err) {
149
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,86 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { validateCommit, formatCommitMessage } from '../services/commitService.js';
3
+ const DEFAULT_STATE = {
4
+ message: '',
5
+ amend: false,
6
+ isCommitting: false,
7
+ error: null,
8
+ inputFocused: false,
9
+ };
10
+ /**
11
+ * CommitFlowState manages commit panel state independently of React.
12
+ */
13
+ export class CommitFlowState extends EventEmitter {
14
+ _state = { ...DEFAULT_STATE };
15
+ getHeadMessage;
16
+ onCommit;
17
+ onSuccess;
18
+ stagedCount = 0;
19
+ constructor(options) {
20
+ super();
21
+ this.getHeadMessage = options.getHeadMessage;
22
+ this.onCommit = options.onCommit;
23
+ this.onSuccess = options.onSuccess;
24
+ }
25
+ get state() {
26
+ return this._state;
27
+ }
28
+ update(partial) {
29
+ this._state = { ...this._state, ...partial };
30
+ this.emit('change', this._state);
31
+ }
32
+ setStagedCount(count) {
33
+ this.stagedCount = count;
34
+ }
35
+ setMessage(message) {
36
+ this.update({ message, error: null });
37
+ }
38
+ setInputFocused(focused) {
39
+ this.update({ inputFocused: focused });
40
+ this.emit('focus-change', focused);
41
+ }
42
+ async toggleAmend() {
43
+ const newAmend = !this._state.amend;
44
+ this.update({ amend: newAmend });
45
+ // Load HEAD message when amend is enabled
46
+ if (newAmend && !this._state.message) {
47
+ try {
48
+ const msg = await this.getHeadMessage();
49
+ if (msg) {
50
+ this.update({ message: msg });
51
+ }
52
+ }
53
+ catch {
54
+ // Ignore errors
55
+ }
56
+ }
57
+ }
58
+ async submit() {
59
+ const validation = validateCommit(this._state.message, this.stagedCount, this._state.amend);
60
+ if (!validation.valid) {
61
+ this.update({ error: validation.error });
62
+ return;
63
+ }
64
+ this.update({ isCommitting: true, error: null });
65
+ try {
66
+ await this.onCommit(formatCommitMessage(this._state.message), this._state.amend);
67
+ this.update({
68
+ message: '',
69
+ amend: false,
70
+ isCommitting: false,
71
+ inputFocused: false,
72
+ });
73
+ this.onSuccess();
74
+ }
75
+ catch (err) {
76
+ this.update({
77
+ isCommitting: false,
78
+ error: err instanceof Error ? err.message : 'Commit failed',
79
+ });
80
+ }
81
+ }
82
+ reset() {
83
+ this._state = { ...DEFAULT_STATE };
84
+ this.emit('change', this._state);
85
+ }
86
+ }
@@ -0,0 +1,182 @@
1
+ import { EventEmitter } from 'node:events';
2
+ const DEFAULT_STATE = {
3
+ currentPane: 'files',
4
+ bottomTab: 'diff',
5
+ selectedIndex: 0,
6
+ fileListScrollOffset: 0,
7
+ diffScrollOffset: 0,
8
+ historyScrollOffset: 0,
9
+ compareScrollOffset: 0,
10
+ explorerScrollOffset: 0,
11
+ explorerFileScrollOffset: 0,
12
+ historySelectedIndex: 0,
13
+ compareSelectedIndex: 0,
14
+ includeUncommitted: false,
15
+ explorerSelectedIndex: 0,
16
+ wrapMode: false,
17
+ autoTabEnabled: false,
18
+ mouseEnabled: true,
19
+ showMiddleDots: false,
20
+ hideHiddenFiles: true,
21
+ hideGitignored: true,
22
+ splitRatio: 0.4,
23
+ activeModal: null,
24
+ pendingDiscard: null,
25
+ commitInputFocused: false,
26
+ };
27
+ /**
28
+ * UIState manages all UI-related state independently of React.
29
+ * It emits events when state changes so widgets can update.
30
+ */
31
+ export class UIState extends EventEmitter {
32
+ _state;
33
+ constructor(initialState = {}) {
34
+ super();
35
+ this._state = { ...DEFAULT_STATE, ...initialState };
36
+ }
37
+ get state() {
38
+ return this._state;
39
+ }
40
+ update(partial) {
41
+ this._state = { ...this._state, ...partial };
42
+ this.emit('change', this._state);
43
+ }
44
+ // Navigation
45
+ setPane(pane) {
46
+ if (this._state.currentPane !== pane) {
47
+ this.update({ currentPane: pane });
48
+ this.emit('pane-change', pane);
49
+ }
50
+ }
51
+ setTab(tab) {
52
+ if (this._state.bottomTab !== tab) {
53
+ // Map tab to appropriate pane
54
+ const paneMap = {
55
+ diff: 'files',
56
+ commit: 'commit',
57
+ history: 'history',
58
+ compare: 'compare',
59
+ explorer: 'explorer',
60
+ };
61
+ this.update({
62
+ bottomTab: tab,
63
+ currentPane: paneMap[tab],
64
+ });
65
+ this.emit('tab-change', tab);
66
+ }
67
+ }
68
+ setSelectedIndex(index) {
69
+ if (this._state.selectedIndex !== index) {
70
+ this.update({ selectedIndex: index });
71
+ this.emit('selection-change', index);
72
+ }
73
+ }
74
+ // Scroll operations
75
+ setFileListScrollOffset(offset) {
76
+ this.update({ fileListScrollOffset: Math.max(0, offset) });
77
+ this.emit('scroll-change', { type: 'fileList', offset });
78
+ }
79
+ setDiffScrollOffset(offset) {
80
+ this.update({ diffScrollOffset: Math.max(0, offset) });
81
+ this.emit('scroll-change', { type: 'diff', offset });
82
+ }
83
+ setHistoryScrollOffset(offset) {
84
+ this.update({ historyScrollOffset: Math.max(0, offset) });
85
+ this.emit('scroll-change', { type: 'history', offset });
86
+ }
87
+ setCompareScrollOffset(offset) {
88
+ this.update({ compareScrollOffset: Math.max(0, offset) });
89
+ this.emit('scroll-change', { type: 'compare', offset });
90
+ }
91
+ setExplorerScrollOffset(offset) {
92
+ this.update({ explorerScrollOffset: Math.max(0, offset) });
93
+ this.emit('scroll-change', { type: 'explorer', offset });
94
+ }
95
+ setExplorerFileScrollOffset(offset) {
96
+ this.update({ explorerFileScrollOffset: Math.max(0, offset) });
97
+ this.emit('scroll-change', { type: 'explorerFile', offset });
98
+ }
99
+ // History navigation
100
+ setHistorySelectedIndex(index) {
101
+ this.update({ historySelectedIndex: Math.max(0, index) });
102
+ }
103
+ // Compare navigation
104
+ setCompareSelectedIndex(index) {
105
+ this.update({ compareSelectedIndex: Math.max(0, index) });
106
+ }
107
+ toggleIncludeUncommitted() {
108
+ this.update({ includeUncommitted: !this._state.includeUncommitted });
109
+ }
110
+ // Explorer navigation
111
+ setExplorerSelectedIndex(index) {
112
+ this.update({ explorerSelectedIndex: Math.max(0, index) });
113
+ }
114
+ // Display toggles
115
+ toggleWrapMode() {
116
+ this.update({ wrapMode: !this._state.wrapMode, diffScrollOffset: 0 });
117
+ }
118
+ toggleAutoTab() {
119
+ this.update({ autoTabEnabled: !this._state.autoTabEnabled });
120
+ }
121
+ toggleMouse() {
122
+ this.update({ mouseEnabled: !this._state.mouseEnabled });
123
+ }
124
+ toggleMiddleDots() {
125
+ this.update({ showMiddleDots: !this._state.showMiddleDots });
126
+ }
127
+ toggleHideHiddenFiles() {
128
+ this.update({ hideHiddenFiles: !this._state.hideHiddenFiles });
129
+ }
130
+ toggleHideGitignored() {
131
+ this.update({ hideGitignored: !this._state.hideGitignored });
132
+ }
133
+ // Split ratio
134
+ adjustSplitRatio(delta) {
135
+ const newRatio = Math.min(0.85, Math.max(0.15, this._state.splitRatio + delta));
136
+ this.update({ splitRatio: newRatio });
137
+ }
138
+ setSplitRatio(ratio) {
139
+ this.update({ splitRatio: Math.min(0.85, Math.max(0.15, ratio)) });
140
+ }
141
+ // Modals
142
+ openModal(modal) {
143
+ this.update({ activeModal: modal });
144
+ this.emit('modal-change', modal);
145
+ }
146
+ closeModal() {
147
+ this.update({ activeModal: null });
148
+ this.emit('modal-change', null);
149
+ }
150
+ toggleModal(modal) {
151
+ if (this._state.activeModal === modal) {
152
+ this.closeModal();
153
+ }
154
+ else {
155
+ this.openModal(modal);
156
+ }
157
+ }
158
+ // Discard confirmation
159
+ setPendingDiscard(file) {
160
+ this.update({ pendingDiscard: file });
161
+ }
162
+ // Commit input focus
163
+ setCommitInputFocused(focused) {
164
+ this.update({ commitInputFocused: focused });
165
+ }
166
+ // Helper for toggling between panes
167
+ togglePane() {
168
+ const { bottomTab, currentPane } = this._state;
169
+ if (bottomTab === 'diff' || bottomTab === 'commit') {
170
+ this.setPane(currentPane === 'files' ? 'diff' : 'files');
171
+ }
172
+ else if (bottomTab === 'history') {
173
+ this.setPane(currentPane === 'history' ? 'diff' : 'history');
174
+ }
175
+ else if (bottomTab === 'compare') {
176
+ this.setPane(currentPane === 'compare' ? 'diff' : 'compare');
177
+ }
178
+ else if (bottomTab === 'explorer') {
179
+ this.setPane(currentPane === 'explorer' ? 'diff' : 'explorer');
180
+ }
181
+ }
182
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Shared type definitions for tabs and panes.
3
+ */
4
+ export {};