diffstalker 0.2.3 → 0.2.4
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/.dependency-cruiser.cjs +2 -2
- package/dist/App.js +278 -758
- package/dist/KeyBindings.js +103 -91
- package/dist/ModalController.js +166 -0
- package/dist/MouseHandlers.js +37 -30
- package/dist/NavigationController.js +290 -0
- package/dist/StagingOperations.js +199 -0
- package/dist/config.js +39 -0
- package/dist/core/CompareManager.js +134 -0
- package/dist/core/ExplorerStateManager.js +7 -3
- package/dist/core/GitStateManager.js +28 -771
- package/dist/core/HistoryManager.js +72 -0
- package/dist/core/RemoteOperationManager.js +109 -0
- package/dist/core/WorkingTreeManager.js +412 -0
- package/dist/index.js +57 -57
- package/dist/state/FocusRing.js +40 -0
- package/dist/state/UIState.js +82 -48
- package/dist/ui/PaneRenderers.js +3 -6
- package/dist/ui/modals/BaseBranchPicker.js +4 -7
- package/dist/ui/modals/CommitActionConfirm.js +4 -4
- package/dist/ui/modals/DiscardConfirm.js +4 -7
- package/dist/ui/modals/FileFinder.js +3 -6
- package/dist/ui/modals/HotkeysModal.js +17 -21
- package/dist/ui/modals/Modal.js +1 -0
- package/dist/ui/modals/RepoPicker.js +109 -0
- package/dist/ui/modals/ThemePicker.js +4 -7
- package/dist/ui/widgets/CommitPanel.js +26 -94
- package/dist/ui/widgets/CompareListView.js +1 -11
- package/dist/ui/widgets/DiffView.js +2 -27
- package/dist/ui/widgets/ExplorerContent.js +1 -4
- package/dist/ui/widgets/ExplorerView.js +1 -11
- package/dist/ui/widgets/FileList.js +2 -8
- package/dist/ui/widgets/Footer.js +1 -0
- package/dist/utils/ansi.js +38 -0
- package/dist/utils/ansiTruncate.js +1 -5
- package/dist/utils/displayRows.js +72 -59
- package/dist/utils/fileCategories.js +7 -0
- package/dist/utils/fileResolution.js +23 -0
- package/dist/utils/languageDetection.js +3 -2
- package/dist/utils/logger.js +32 -0
- package/metrics/v0.2.4.json +236 -0
- package/package.json +1 -1
- package/dist/ui/modals/BranchPicker.js +0 -157
- package/dist/ui/modals/SoftResetConfirm.js +0 -68
- package/dist/ui/modals/StashListModal.js +0 -98
- package/dist/utils/layoutCalculations.js +0 -100
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { getCommitHistory, getHeadMessage } from '../git/status.js';
|
|
3
|
+
import { getCommitDiff } from '../git/diff.js';
|
|
4
|
+
/**
|
|
5
|
+
* Manages commit history state: loading, selection, and diff display.
|
|
6
|
+
*/
|
|
7
|
+
export class HistoryManager extends EventEmitter {
|
|
8
|
+
repoPath;
|
|
9
|
+
queue;
|
|
10
|
+
_historyState = {
|
|
11
|
+
commits: [],
|
|
12
|
+
selectedCommit: null,
|
|
13
|
+
commitDiff: null,
|
|
14
|
+
isLoading: false,
|
|
15
|
+
};
|
|
16
|
+
constructor(repoPath, queue) {
|
|
17
|
+
super();
|
|
18
|
+
this.repoPath = repoPath;
|
|
19
|
+
this.queue = queue;
|
|
20
|
+
}
|
|
21
|
+
get historyState() {
|
|
22
|
+
return this._historyState;
|
|
23
|
+
}
|
|
24
|
+
updateHistoryState(partial) {
|
|
25
|
+
this._historyState = { ...this._historyState, ...partial };
|
|
26
|
+
this.emit('history-state-change', this._historyState);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Refresh history if it was previously loaded (has commits).
|
|
30
|
+
* Called by the cascade refresh after file changes.
|
|
31
|
+
*/
|
|
32
|
+
async refreshIfLoaded(count = 100) {
|
|
33
|
+
if (this._historyState.commits.length > 0) {
|
|
34
|
+
await this.doLoadHistory(count);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Load commit history for the history view.
|
|
39
|
+
*/
|
|
40
|
+
async loadHistory(count = 100) {
|
|
41
|
+
this.updateHistoryState({ isLoading: true });
|
|
42
|
+
try {
|
|
43
|
+
await this.queue.enqueue(() => this.doLoadHistory(count));
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
this.updateHistoryState({ isLoading: false });
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async doLoadHistory(count = 100) {
|
|
51
|
+
const commits = await getCommitHistory(this.repoPath, count);
|
|
52
|
+
this.updateHistoryState({ commits, selectedCommit: null, commitDiff: null, isLoading: false });
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Select a commit in history view and load its diff.
|
|
56
|
+
*/
|
|
57
|
+
async selectHistoryCommit(commit) {
|
|
58
|
+
this.updateHistoryState({ selectedCommit: commit, commitDiff: null });
|
|
59
|
+
if (!commit)
|
|
60
|
+
return;
|
|
61
|
+
await this.queue.enqueue(async () => {
|
|
62
|
+
const diff = await getCommitDiff(this.repoPath, commit.hash);
|
|
63
|
+
this.updateHistoryState({ commitDiff: diff });
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Get the HEAD commit message.
|
|
68
|
+
*/
|
|
69
|
+
async getHeadCommitMessage() {
|
|
70
|
+
return this.queue.enqueue(() => getHeadMessage(this.repoPath));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { push as gitPush, fetchRemote as gitFetchRemote, pullRebase as gitPullRebase, stashSave as gitStashSave, stashPop as gitStashPop, getLocalBranches as gitGetLocalBranches, switchBranch as gitSwitchBranch, createBranch as gitCreateBranch, softResetHead as gitSoftResetHead, cherryPick as gitCherryPick, revertCommit as gitRevertCommit, } from '../git/status.js';
|
|
3
|
+
/**
|
|
4
|
+
* Manages remote operations (push/pull/fetch), stash, branch switching, and undo operations.
|
|
5
|
+
* Uses callbacks for cross-manager coordination (refresh, stash list, compare reset).
|
|
6
|
+
*/
|
|
7
|
+
export class RemoteOperationManager extends EventEmitter {
|
|
8
|
+
repoPath;
|
|
9
|
+
queue;
|
|
10
|
+
callbacks;
|
|
11
|
+
_remoteState = {
|
|
12
|
+
operation: null,
|
|
13
|
+
inProgress: false,
|
|
14
|
+
error: null,
|
|
15
|
+
lastResult: null,
|
|
16
|
+
};
|
|
17
|
+
constructor(repoPath, queue, callbacks) {
|
|
18
|
+
super();
|
|
19
|
+
this.repoPath = repoPath;
|
|
20
|
+
this.queue = queue;
|
|
21
|
+
this.callbacks = callbacks;
|
|
22
|
+
}
|
|
23
|
+
get remoteState() {
|
|
24
|
+
return this._remoteState;
|
|
25
|
+
}
|
|
26
|
+
updateRemoteState(partial) {
|
|
27
|
+
this._remoteState = { ...this._remoteState, ...partial };
|
|
28
|
+
this.emit('remote-state-change', this._remoteState);
|
|
29
|
+
}
|
|
30
|
+
async runRemoteOperation(operation, fn) {
|
|
31
|
+
this.updateRemoteState({ operation, inProgress: true, error: null, lastResult: null });
|
|
32
|
+
try {
|
|
33
|
+
const result = await this.queue.enqueue(fn);
|
|
34
|
+
this.updateRemoteState({ inProgress: false, lastResult: result });
|
|
35
|
+
this.callbacks.scheduleRefresh();
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
39
|
+
this.updateRemoteState({ inProgress: false, error: message });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Clear the remote state (e.g. after auto-clear timeout).
|
|
44
|
+
*/
|
|
45
|
+
clearRemoteState() {
|
|
46
|
+
this.updateRemoteState({ operation: null, error: null, lastResult: null });
|
|
47
|
+
}
|
|
48
|
+
// --- Remote operations ---
|
|
49
|
+
async push() {
|
|
50
|
+
if (this._remoteState.inProgress)
|
|
51
|
+
return;
|
|
52
|
+
await this.runRemoteOperation('push', () => gitPush(this.repoPath));
|
|
53
|
+
}
|
|
54
|
+
async fetchRemote() {
|
|
55
|
+
if (this._remoteState.inProgress)
|
|
56
|
+
return;
|
|
57
|
+
await this.runRemoteOperation('fetch', () => gitFetchRemote(this.repoPath));
|
|
58
|
+
}
|
|
59
|
+
async pullRebase() {
|
|
60
|
+
if (this._remoteState.inProgress)
|
|
61
|
+
return;
|
|
62
|
+
await this.runRemoteOperation('pull', () => gitPullRebase(this.repoPath));
|
|
63
|
+
}
|
|
64
|
+
// --- Stash operations ---
|
|
65
|
+
async stash(message) {
|
|
66
|
+
if (this._remoteState.inProgress)
|
|
67
|
+
return;
|
|
68
|
+
await this.runRemoteOperation('stash', () => gitStashSave(this.repoPath, message));
|
|
69
|
+
await this.callbacks.loadStashList();
|
|
70
|
+
}
|
|
71
|
+
async stashPop(index = 0) {
|
|
72
|
+
if (this._remoteState.inProgress)
|
|
73
|
+
return;
|
|
74
|
+
await this.runRemoteOperation('stashPop', () => gitStashPop(this.repoPath, index));
|
|
75
|
+
await this.callbacks.loadStashList();
|
|
76
|
+
}
|
|
77
|
+
// --- Branch operations ---
|
|
78
|
+
async getLocalBranches() {
|
|
79
|
+
return this.queue.enqueue(() => gitGetLocalBranches(this.repoPath));
|
|
80
|
+
}
|
|
81
|
+
async switchBranch(name) {
|
|
82
|
+
if (this._remoteState.inProgress)
|
|
83
|
+
return;
|
|
84
|
+
await this.runRemoteOperation('branchSwitch', () => gitSwitchBranch(this.repoPath, name));
|
|
85
|
+
this.callbacks.resetCompareBaseBranch();
|
|
86
|
+
}
|
|
87
|
+
async createBranch(name) {
|
|
88
|
+
if (this._remoteState.inProgress)
|
|
89
|
+
return;
|
|
90
|
+
await this.runRemoteOperation('branchCreate', () => gitCreateBranch(this.repoPath, name));
|
|
91
|
+
this.callbacks.resetCompareBaseBranch();
|
|
92
|
+
}
|
|
93
|
+
// --- Undo operations ---
|
|
94
|
+
async softReset(count = 1) {
|
|
95
|
+
if (this._remoteState.inProgress)
|
|
96
|
+
return;
|
|
97
|
+
await this.runRemoteOperation('softReset', () => gitSoftResetHead(this.repoPath, count));
|
|
98
|
+
}
|
|
99
|
+
async cherryPick(hash) {
|
|
100
|
+
if (this._remoteState.inProgress)
|
|
101
|
+
return;
|
|
102
|
+
await this.runRemoteOperation('cherryPick', () => gitCherryPick(this.repoPath, hash));
|
|
103
|
+
}
|
|
104
|
+
async revertCommit(hash) {
|
|
105
|
+
if (this._remoteState.inProgress)
|
|
106
|
+
return;
|
|
107
|
+
await this.runRemoteOperation('revert', () => gitRevertCommit(this.repoPath, hash));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { watch } from 'chokidar';
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import ignore from 'ignore';
|
|
7
|
+
import * as logger from '../utils/logger.js';
|
|
8
|
+
import { getStatus, stageFile, unstageFile, stageAll as gitStageAll, unstageAll as gitUnstageAll, discardChanges as gitDiscardChanges, commit as gitCommit, stageHunk as gitStageHunk, unstageHunk as gitUnstageHunk, getStashList as gitGetStashList, } from '../git/status.js';
|
|
9
|
+
import { getDiff, getDiffForUntracked, getStagedDiff, countHunksPerFile, } from '../git/diff.js';
|
|
10
|
+
/**
|
|
11
|
+
* Manages the working tree: file watching, status, diffs, staging, and commits.
|
|
12
|
+
* Accepts an onRefresh callback for cascading refreshes to history/compare managers.
|
|
13
|
+
*/
|
|
14
|
+
export class WorkingTreeManager extends EventEmitter {
|
|
15
|
+
repoPath;
|
|
16
|
+
queue;
|
|
17
|
+
onRefresh;
|
|
18
|
+
gitWatcher = null;
|
|
19
|
+
workingDirWatcher = null;
|
|
20
|
+
ignorers = new Map();
|
|
21
|
+
diffDebounceTimer = null;
|
|
22
|
+
_state = {
|
|
23
|
+
status: null,
|
|
24
|
+
diff: null,
|
|
25
|
+
combinedFileDiffs: null,
|
|
26
|
+
selectedFile: null,
|
|
27
|
+
isLoading: false,
|
|
28
|
+
error: null,
|
|
29
|
+
hunkCounts: null,
|
|
30
|
+
stashList: [],
|
|
31
|
+
};
|
|
32
|
+
constructor(repoPath, queue, onRefresh) {
|
|
33
|
+
super();
|
|
34
|
+
this.repoPath = repoPath;
|
|
35
|
+
this.queue = queue;
|
|
36
|
+
this.onRefresh = onRefresh ?? null;
|
|
37
|
+
}
|
|
38
|
+
get state() {
|
|
39
|
+
return this._state;
|
|
40
|
+
}
|
|
41
|
+
updateState(partial) {
|
|
42
|
+
this._state = { ...this._state, ...partial };
|
|
43
|
+
this.emit('state-change', this._state);
|
|
44
|
+
}
|
|
45
|
+
// --- Gitignore loading ---
|
|
46
|
+
loadGitignores() {
|
|
47
|
+
const ignorers = new Map();
|
|
48
|
+
const rootIg = ignore();
|
|
49
|
+
rootIg.add('.git');
|
|
50
|
+
const rootGitignorePath = path.join(this.repoPath, '.gitignore');
|
|
51
|
+
if (fs.existsSync(rootGitignorePath)) {
|
|
52
|
+
rootIg.add(fs.readFileSync(rootGitignorePath, 'utf-8'));
|
|
53
|
+
}
|
|
54
|
+
const excludePath = path.join(this.repoPath, '.git', 'info', 'exclude');
|
|
55
|
+
if (fs.existsSync(excludePath)) {
|
|
56
|
+
rootIg.add(fs.readFileSync(excludePath, 'utf-8'));
|
|
57
|
+
}
|
|
58
|
+
ignorers.set('', rootIg);
|
|
59
|
+
try {
|
|
60
|
+
const output = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '**/.gitignore'], { cwd: this.repoPath, encoding: 'utf-8' });
|
|
61
|
+
for (const entry of output.split('\0')) {
|
|
62
|
+
if (!entry || entry === '.gitignore')
|
|
63
|
+
continue;
|
|
64
|
+
if (!entry.endsWith('.gitignore'))
|
|
65
|
+
continue;
|
|
66
|
+
const dir = path.dirname(entry);
|
|
67
|
+
const absPath = path.join(this.repoPath, entry);
|
|
68
|
+
try {
|
|
69
|
+
const content = fs.readFileSync(absPath, 'utf-8');
|
|
70
|
+
const ig = ignore();
|
|
71
|
+
ig.add(content);
|
|
72
|
+
ignorers.set(dir, ig);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
logger.warn(`Failed to read ${absPath}: ${err instanceof Error ? err.message : err}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// git ls-files failed — we still have the root ignorer
|
|
81
|
+
}
|
|
82
|
+
return ignorers;
|
|
83
|
+
}
|
|
84
|
+
// --- File watching ---
|
|
85
|
+
startWatching() {
|
|
86
|
+
const gitDir = path.join(this.repoPath, '.git');
|
|
87
|
+
if (!fs.existsSync(gitDir))
|
|
88
|
+
return;
|
|
89
|
+
const indexFile = path.join(gitDir, 'index');
|
|
90
|
+
const headFile = path.join(gitDir, 'HEAD');
|
|
91
|
+
const refsDir = path.join(gitDir, 'refs');
|
|
92
|
+
const gitignorePath = path.join(this.repoPath, '.gitignore');
|
|
93
|
+
this.gitWatcher = watch([indexFile, headFile, refsDir, gitignorePath], {
|
|
94
|
+
persistent: true,
|
|
95
|
+
ignoreInitial: true,
|
|
96
|
+
usePolling: true,
|
|
97
|
+
interval: 100,
|
|
98
|
+
});
|
|
99
|
+
this.ignorers = this.loadGitignores();
|
|
100
|
+
this.workingDirWatcher = watch(this.repoPath, {
|
|
101
|
+
persistent: true,
|
|
102
|
+
ignoreInitial: true,
|
|
103
|
+
ignored: (filePath) => {
|
|
104
|
+
const relativePath = path.relative(this.repoPath, filePath);
|
|
105
|
+
if (!relativePath)
|
|
106
|
+
return false;
|
|
107
|
+
const parts = relativePath.split('/');
|
|
108
|
+
for (let depth = 0; depth < parts.length; depth++) {
|
|
109
|
+
const dir = depth === 0 ? '' : parts.slice(0, depth).join('/');
|
|
110
|
+
const ig = this.ignorers.get(dir);
|
|
111
|
+
if (ig) {
|
|
112
|
+
const relToDir = depth === 0 ? relativePath : parts.slice(depth).join('/');
|
|
113
|
+
if (ig.ignores(relToDir))
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
},
|
|
119
|
+
awaitWriteFinish: {
|
|
120
|
+
stabilityThreshold: 100,
|
|
121
|
+
pollInterval: 50,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
const scheduleRefresh = () => this.scheduleRefresh();
|
|
125
|
+
this.gitWatcher.on('change', (filePath) => {
|
|
126
|
+
if (filePath === gitignorePath) {
|
|
127
|
+
this.ignorers = this.loadGitignores();
|
|
128
|
+
}
|
|
129
|
+
scheduleRefresh();
|
|
130
|
+
});
|
|
131
|
+
this.gitWatcher.on('add', scheduleRefresh);
|
|
132
|
+
this.gitWatcher.on('unlink', scheduleRefresh);
|
|
133
|
+
this.gitWatcher.on('error', (err) => {
|
|
134
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
135
|
+
this.emit('error', `Git watcher error: ${message}`);
|
|
136
|
+
});
|
|
137
|
+
this.workingDirWatcher.on('change', scheduleRefresh);
|
|
138
|
+
this.workingDirWatcher.on('add', scheduleRefresh);
|
|
139
|
+
this.workingDirWatcher.on('unlink', scheduleRefresh);
|
|
140
|
+
this.workingDirWatcher.on('error', (err) => {
|
|
141
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
142
|
+
this.emit('error', `Working dir watcher error: ${message}`);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
dispose() {
|
|
146
|
+
if (this.diffDebounceTimer)
|
|
147
|
+
clearTimeout(this.diffDebounceTimer);
|
|
148
|
+
this.gitWatcher?.close();
|
|
149
|
+
this.workingDirWatcher?.close();
|
|
150
|
+
}
|
|
151
|
+
// --- Refresh ---
|
|
152
|
+
scheduleRefresh() {
|
|
153
|
+
this.queue.scheduleRefresh(async () => {
|
|
154
|
+
await this.doRefresh();
|
|
155
|
+
// Cascade refresh to history and compare if loaded
|
|
156
|
+
if (this.onRefresh) {
|
|
157
|
+
await this.onRefresh();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
scheduleStatusRefresh() {
|
|
162
|
+
this.queue.scheduleRefresh(async () => {
|
|
163
|
+
const newStatus = await getStatus(this.repoPath);
|
|
164
|
+
if (!newStatus.isRepo) {
|
|
165
|
+
this.updateState({
|
|
166
|
+
status: newStatus,
|
|
167
|
+
diff: null,
|
|
168
|
+
isLoading: false,
|
|
169
|
+
error: 'Not a git repository',
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.updateState({ status: newStatus, isLoading: false });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
async refresh() {
|
|
177
|
+
await this.queue.enqueue(() => this.doRefresh());
|
|
178
|
+
}
|
|
179
|
+
async doRefresh() {
|
|
180
|
+
this.updateState({ isLoading: true, error: null });
|
|
181
|
+
try {
|
|
182
|
+
const newStatus = await getStatus(this.repoPath);
|
|
183
|
+
if (!newStatus.isRepo) {
|
|
184
|
+
this.updateState({
|
|
185
|
+
status: newStatus,
|
|
186
|
+
diff: null,
|
|
187
|
+
isLoading: false,
|
|
188
|
+
error: 'Not a git repository',
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const [allUnstagedDiff, allStagedDiff] = await Promise.all([
|
|
193
|
+
getDiff(this.repoPath, undefined, false),
|
|
194
|
+
getDiff(this.repoPath, undefined, true),
|
|
195
|
+
]);
|
|
196
|
+
const hunkCounts = {
|
|
197
|
+
unstaged: countHunksPerFile(allUnstagedDiff.raw),
|
|
198
|
+
staged: countHunksPerFile(allStagedDiff.raw),
|
|
199
|
+
};
|
|
200
|
+
const { displayDiff, combinedFileDiffs } = await this.resolveFileDiffs(newStatus, allUnstagedDiff);
|
|
201
|
+
this.updateState({
|
|
202
|
+
status: newStatus,
|
|
203
|
+
diff: displayDiff,
|
|
204
|
+
combinedFileDiffs,
|
|
205
|
+
hunkCounts,
|
|
206
|
+
isLoading: false,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
this.updateState({
|
|
211
|
+
isLoading: false,
|
|
212
|
+
error: err instanceof Error ? err.message : 'Unknown error',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async resolveFileDiffs(newStatus, fallbackDiff) {
|
|
217
|
+
const currentSelectedFile = this._state.selectedFile;
|
|
218
|
+
if (!currentSelectedFile) {
|
|
219
|
+
return { displayDiff: fallbackDiff, combinedFileDiffs: null };
|
|
220
|
+
}
|
|
221
|
+
const currentFile = newStatus.files.find((f) => f.path === currentSelectedFile.path && f.staged === currentSelectedFile.staged) ?? newStatus.files.find((f) => f.path === currentSelectedFile.path);
|
|
222
|
+
if (!currentFile) {
|
|
223
|
+
this.updateState({ selectedFile: null });
|
|
224
|
+
return { displayDiff: fallbackDiff, combinedFileDiffs: null };
|
|
225
|
+
}
|
|
226
|
+
if (currentFile.status === 'untracked') {
|
|
227
|
+
const displayDiff = await getDiffForUntracked(this.repoPath, currentFile.path);
|
|
228
|
+
return {
|
|
229
|
+
displayDiff,
|
|
230
|
+
combinedFileDiffs: { unstaged: displayDiff, staged: { raw: '', lines: [] } },
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const [unstagedFileDiff, stagedFileDiff] = await Promise.all([
|
|
234
|
+
getDiff(this.repoPath, currentFile.path, false),
|
|
235
|
+
getDiff(this.repoPath, currentFile.path, true),
|
|
236
|
+
]);
|
|
237
|
+
const displayDiff = currentFile.staged ? stagedFileDiff : unstagedFileDiff;
|
|
238
|
+
return {
|
|
239
|
+
displayDiff,
|
|
240
|
+
combinedFileDiffs: { unstaged: unstagedFileDiff, staged: stagedFileDiff },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// --- File selection ---
|
|
244
|
+
selectFile(file) {
|
|
245
|
+
this.updateState({ selectedFile: file });
|
|
246
|
+
if (!this._state.status?.isRepo)
|
|
247
|
+
return;
|
|
248
|
+
if (this.diffDebounceTimer) {
|
|
249
|
+
clearTimeout(this.diffDebounceTimer);
|
|
250
|
+
this.diffDebounceTimer = setTimeout(() => {
|
|
251
|
+
this.diffDebounceTimer = null;
|
|
252
|
+
this.fetchDiffForSelection();
|
|
253
|
+
}, 20);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
this.fetchDiffForSelection();
|
|
257
|
+
this.diffDebounceTimer = setTimeout(() => {
|
|
258
|
+
this.diffDebounceTimer = null;
|
|
259
|
+
}, 20);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
fetchDiffForSelection() {
|
|
263
|
+
const file = this._state.selectedFile;
|
|
264
|
+
this.queue
|
|
265
|
+
.enqueue(async () => {
|
|
266
|
+
if (file !== this._state.selectedFile)
|
|
267
|
+
return;
|
|
268
|
+
await this.doFetchDiffForFile(file);
|
|
269
|
+
})
|
|
270
|
+
.catch((err) => {
|
|
271
|
+
this.updateState({
|
|
272
|
+
error: `Failed to load diff: ${err instanceof Error ? err.message : String(err)}`,
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
async doFetchDiffForFile(file) {
|
|
277
|
+
if (!file) {
|
|
278
|
+
const allDiff = await getStagedDiff(this.repoPath);
|
|
279
|
+
if (this._state.selectedFile === null) {
|
|
280
|
+
this.updateState({ diff: allDiff, combinedFileDiffs: null });
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (file.status === 'untracked') {
|
|
285
|
+
const fileDiff = await getDiffForUntracked(this.repoPath, file.path);
|
|
286
|
+
if (file === this._state.selectedFile) {
|
|
287
|
+
this.updateState({
|
|
288
|
+
diff: fileDiff,
|
|
289
|
+
combinedFileDiffs: { unstaged: fileDiff, staged: { raw: '', lines: [] } },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const [unstagedDiff, stagedDiff] = await Promise.all([
|
|
295
|
+
getDiff(this.repoPath, file.path, false),
|
|
296
|
+
getDiff(this.repoPath, file.path, true),
|
|
297
|
+
]);
|
|
298
|
+
if (file === this._state.selectedFile) {
|
|
299
|
+
const displayDiff = file.staged ? stagedDiff : unstagedDiff;
|
|
300
|
+
this.updateState({
|
|
301
|
+
diff: displayDiff,
|
|
302
|
+
combinedFileDiffs: { unstaged: unstagedDiff, staged: stagedDiff },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// --- Staging operations ---
|
|
307
|
+
async stage(file) {
|
|
308
|
+
try {
|
|
309
|
+
await this.queue.enqueueMutation(() => stageFile(this.repoPath, file.path));
|
|
310
|
+
this.scheduleStatusRefresh();
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
await this.refresh();
|
|
314
|
+
this.updateState({
|
|
315
|
+
error: `Failed to stage ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
async unstage(file) {
|
|
320
|
+
try {
|
|
321
|
+
await this.queue.enqueueMutation(() => unstageFile(this.repoPath, file.path));
|
|
322
|
+
this.scheduleStatusRefresh();
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
await this.refresh();
|
|
326
|
+
this.updateState({
|
|
327
|
+
error: `Failed to unstage ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async stageHunk(patch) {
|
|
332
|
+
try {
|
|
333
|
+
await this.queue.enqueueMutation(async () => gitStageHunk(this.repoPath, patch));
|
|
334
|
+
this.scheduleRefresh();
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
await this.refresh();
|
|
338
|
+
this.updateState({
|
|
339
|
+
error: `Failed to stage hunk: ${err instanceof Error ? err.message : String(err)}`,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async unstageHunk(patch) {
|
|
344
|
+
try {
|
|
345
|
+
await this.queue.enqueueMutation(async () => gitUnstageHunk(this.repoPath, patch));
|
|
346
|
+
this.scheduleRefresh();
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
await this.refresh();
|
|
350
|
+
this.updateState({
|
|
351
|
+
error: `Failed to unstage hunk: ${err instanceof Error ? err.message : String(err)}`,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async discard(file) {
|
|
356
|
+
if (file.staged || file.status === 'untracked')
|
|
357
|
+
return;
|
|
358
|
+
try {
|
|
359
|
+
await this.queue.enqueueMutation(() => gitDiscardChanges(this.repoPath, file.path));
|
|
360
|
+
await this.refresh();
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
this.updateState({
|
|
364
|
+
error: `Failed to discard ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async stageAll() {
|
|
369
|
+
try {
|
|
370
|
+
await this.queue.enqueueMutation(() => gitStageAll(this.repoPath));
|
|
371
|
+
await this.refresh();
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
this.updateState({
|
|
375
|
+
error: `Failed to stage all: ${err instanceof Error ? err.message : String(err)}`,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
async unstageAll() {
|
|
380
|
+
try {
|
|
381
|
+
await this.queue.enqueueMutation(() => gitUnstageAll(this.repoPath));
|
|
382
|
+
await this.refresh();
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
this.updateState({
|
|
386
|
+
error: `Failed to unstage all: ${err instanceof Error ? err.message : String(err)}`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// --- Commit ---
|
|
391
|
+
async commit(message, amend = false) {
|
|
392
|
+
try {
|
|
393
|
+
await this.queue.enqueue(() => gitCommit(this.repoPath, message, amend));
|
|
394
|
+
await this.refresh();
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
this.updateState({
|
|
398
|
+
error: `Failed to commit: ${err instanceof Error ? err.message : String(err)}`,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// --- Stash list ---
|
|
403
|
+
async loadStashList() {
|
|
404
|
+
try {
|
|
405
|
+
const stashList = await this.queue.enqueue(() => gitGetStashList(this.repoPath));
|
|
406
|
+
this.updateState({ stashList });
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
// Silently ignore — stash list is non-critical
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|