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.
- package/CHANGELOG.md +36 -0
- package/bun.lock +72 -312
- package/dist/App.js +1136 -515
- package/dist/core/ExplorerStateManager.js +266 -0
- package/dist/core/FilePathWatcher.js +133 -0
- package/dist/core/GitStateManager.js +75 -16
- package/dist/git/ignoreUtils.js +30 -0
- package/dist/git/status.js +2 -34
- package/dist/index.js +67 -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 +182 -0
- package/dist/types/tabs.js +4 -0
- package/dist/ui/Layout.js +252 -0
- package/dist/ui/modals/BaseBranchPicker.js +110 -0
- package/dist/ui/modals/DiscardConfirm.js +77 -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 +216 -0
- package/dist/ui/widgets/DiffView.js +279 -0
- package/dist/ui/widgets/ExplorerContent.js +102 -0
- package/dist/ui/widgets/ExplorerView.js +95 -0
- package/dist/ui/widgets/FileList.js +185 -0
- package/dist/ui/widgets/Footer.js +46 -0
- package/dist/ui/widgets/Header.js +111 -0
- package/dist/ui/widgets/HistoryView.js +69 -0
- package/dist/utils/ansiToBlessed.js +125 -0
- package/dist/utils/displayRows.js +185 -6
- package/dist/utils/explorerDisplayRows.js +1 -1
- package/dist/utils/languageDetection.js +56 -0
- package/dist/utils/pathUtils.js +27 -0
- package/dist/utils/rowCalculations.js +37 -0
- package/dist/utils/wordDiff.js +50 -0
- package/package.json +11 -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
|
@@ -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
|
+
}
|