paneful 0.9.17 → 0.9.19
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/README.md +29 -0
- package/dist/server/git-source-control.js +398 -0
- package/dist/server/index.js +31 -0
- package/dist/server/pty-manager.js +54 -2
- package/dist/server/schedule-store.js +99 -0
- package/dist/server/scheduler.js +189 -0
- package/dist/server/settings-store.js +4 -0
- package/dist/server/ws-handler.js +418 -6
- package/dist/web/assets/index-BCSI40O7.css +32 -0
- package/dist/web/assets/{index-BO_xZPlW.js → index-gI76jzKp.js} +10 -10
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BptWthNY.css +0 -32
package/README.md
CHANGED
|
@@ -67,6 +67,34 @@ Press `Cmd+P` to open the command palette. Quickly switch projects, launch favou
|
|
|
67
67
|
|
|
68
68
|
The sidebar shows the current Git branch next to each project's working directory as a small pill badge. Updates automatically every 10 seconds. Non-git directories show no badge.
|
|
69
69
|
|
|
70
|
+
### Scheduled Jobs (beta)
|
|
71
|
+
|
|
72
|
+
Run any shell command on a recurring schedule. Open the **Schedules** section in the sidebar (below Favourites) and click **+** to create one. Pick a name, a working directory via the Finder picker, the command to run, and a schedule — every N minutes/hours/days, daily at HH:MM, weekly on chosen days, or a raw cron expression. The next-fire time is shown live as you build the schedule.
|
|
73
|
+
|
|
74
|
+
Each fire spawns a fresh server-side terminal in your `$SHELL` (login + interactive, so your full PATH is loaded) and runs the command — schedules are completely separate from projects so they don't pollute your layouts. The command runs, then drops into an interactive shell, so the terminal **never dies on its own** — you decide when to close it.
|
|
75
|
+
|
|
76
|
+
Every run is captured to disk so the history is inspectable later. Click any past run from the schedule's history dialog to open an interactive viewer:
|
|
77
|
+
|
|
78
|
+
- **Active runs** — live streaming output, full keystroke forwarding (answer Claude prompts, type follow-up commands, etc.).
|
|
79
|
+
- **Closed runs** — read-only replay of the captured log via xterm so colors and formatting are preserved.
|
|
80
|
+
- **Pause / Resume** — SIGSTOP / SIGCONT the running process. The shell freezes exactly where it was and continues from that exact state when you come back.
|
|
81
|
+
- **Terminate** — kills the run when you're done with it.
|
|
82
|
+
|
|
83
|
+
A common pattern: schedule a Claude CLI command (`claude --dangerously-skip-permissions "..."`) to run at 8am on weekdays. When it fires you get a toast; click it to drop into the live terminal, answer any prompts, and let it work.
|
|
84
|
+
|
|
85
|
+
### Source Control
|
|
86
|
+
|
|
87
|
+
Press `Cmd+Shift+G` (or click the branch icon in the toolbar) to open the source control panel — a resizable right-side panel that shows the active project's working changes. Powered by `git status` + `fs.watch`, so updates are instant. Includes:
|
|
88
|
+
|
|
89
|
+
- **Branch bar** with current branch, ahead/behind counts, and one-click pull (`--ff-only`) and push.
|
|
90
|
+
- **File list** grouped by Staged Changes, Changes, Untracked, and Conflicted. Each row has hover actions for stage, unstage, discard, and "Open in editor" (uses your system's default app association).
|
|
91
|
+
- **Multi-select** with Cmd-click (toggle) and Shift-click (range) for bulk stage/unstage/discard within a group.
|
|
92
|
+
- **Diff view** — click any file to see the full file with line numbers, red/green backgrounds for added/removed lines, and syntax highlighting for ~25 languages (TypeScript, Python, Go, Rust, Swift, Java, C/C++, and more).
|
|
93
|
+
- **Inline commit** message + button; auto-clears on success.
|
|
94
|
+
- **Stash** — create (with optional message, includes untracked), apply, pop, or drop directly from the panel.
|
|
95
|
+
|
|
96
|
+
The panel only polls its data when open, and the file watcher only runs while focused on the active project — zero overhead when collapsed.
|
|
97
|
+
|
|
70
98
|
### AI Agent Detection
|
|
71
99
|
|
|
72
100
|
Automatically detects when Claude Code or Codex CLI is running in a Paneful terminal. A purple **AI** badge appears next to the project name in the sidebar — pulsing when the agent is actively working, dimmed when idle. Disappears instantly when the agent exits. Uses zero filesystem access; detection is purely in-memory via the PTY process name and terminal output timestamps.
|
|
@@ -131,6 +159,7 @@ Paneful checks for newer versions on npm and shows a notification in the sidebar
|
|
|
131
159
|
| `Ctrl+Shift+Arrow` | Move focus to adjacent pane |
|
|
132
160
|
| `Shift+Arrow` | Swap focused pane with adjacent |
|
|
133
161
|
| `Cmd+D` | Toggle sidebar |
|
|
162
|
+
| `Cmd+Shift+G` | Toggle source control panel |
|
|
134
163
|
| `Cmd+T` | Cycle through layout presets |
|
|
135
164
|
| `Cmd+R` | Auto reorganize panes |
|
|
136
165
|
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { watch } from 'node:fs';
|
|
4
|
+
const execFileP = promisify(execFile);
|
|
5
|
+
const SAFETY_POLL_MS = 15_000;
|
|
6
|
+
const WATCH_DEBOUNCE_MS = 300;
|
|
7
|
+
// Headroom for whole-file diffs (git -U99999). Most source files fit comfortably.
|
|
8
|
+
const DIFF_MAX_BYTES = 5_000_000;
|
|
9
|
+
const DIFF_TIMEOUT_MS = 5_000;
|
|
10
|
+
const ACTION_TIMEOUT_MS = 10_000;
|
|
11
|
+
const NETWORK_TIMEOUT_MS = 120_000;
|
|
12
|
+
// Force enough unified-diff context to cover any reasonable file
|
|
13
|
+
const FULL_CONTEXT_FLAG = '-U99999';
|
|
14
|
+
// Filenames/dirs in change events to ignore (lots of churn, no user-visible status impact)
|
|
15
|
+
const IGNORE_PATHS = ['.git/objects', '.git/lfs', '.git/logs', 'node_modules', '.next/cache'];
|
|
16
|
+
export class GitSourceControl {
|
|
17
|
+
projectStore;
|
|
18
|
+
onStatus;
|
|
19
|
+
activeProjectId = null;
|
|
20
|
+
lastStatus = null;
|
|
21
|
+
safetyTimer = null;
|
|
22
|
+
debounceTimer = null;
|
|
23
|
+
watcher = null;
|
|
24
|
+
polling = false;
|
|
25
|
+
destroyed = false;
|
|
26
|
+
paused = true;
|
|
27
|
+
constructor(projectStore, onStatus) {
|
|
28
|
+
this.projectStore = projectStore;
|
|
29
|
+
this.onStatus = onStatus;
|
|
30
|
+
}
|
|
31
|
+
setActive(projectId) {
|
|
32
|
+
if (projectId === this.activeProjectId)
|
|
33
|
+
return;
|
|
34
|
+
this.stopWatcher();
|
|
35
|
+
this.activeProjectId = projectId;
|
|
36
|
+
this.lastStatus = null;
|
|
37
|
+
if (projectId && !this.paused) {
|
|
38
|
+
this.startWatcher(projectId);
|
|
39
|
+
this.poll();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
resume() {
|
|
43
|
+
if (this.destroyed || !this.paused)
|
|
44
|
+
return;
|
|
45
|
+
this.paused = false;
|
|
46
|
+
if (this.activeProjectId) {
|
|
47
|
+
this.startWatcher(this.activeProjectId);
|
|
48
|
+
this.poll();
|
|
49
|
+
this.safetyTimer = setInterval(() => this.poll(), SAFETY_POLL_MS);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
pause() {
|
|
53
|
+
this.paused = true;
|
|
54
|
+
this.stopWatcher();
|
|
55
|
+
if (this.safetyTimer) {
|
|
56
|
+
clearInterval(this.safetyTimer);
|
|
57
|
+
this.safetyTimer = null;
|
|
58
|
+
}
|
|
59
|
+
if (this.debounceTimer) {
|
|
60
|
+
clearTimeout(this.debounceTimer);
|
|
61
|
+
this.debounceTimer = null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
destroy() {
|
|
65
|
+
this.destroyed = true;
|
|
66
|
+
this.pause();
|
|
67
|
+
this.lastStatus = null;
|
|
68
|
+
this.activeProjectId = null;
|
|
69
|
+
}
|
|
70
|
+
startWatcher(projectId) {
|
|
71
|
+
const project = this.projectStore.list().find((p) => p.id === projectId);
|
|
72
|
+
if (!project)
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
this.watcher = watch(project.cwd, { recursive: true }, (_event, filename) => {
|
|
76
|
+
if (!filename)
|
|
77
|
+
return;
|
|
78
|
+
// Skip noisy paths (e.g., git objects, node_modules build chatter)
|
|
79
|
+
for (const ignore of IGNORE_PATHS) {
|
|
80
|
+
if (filename.includes(ignore))
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.scheduleRePoll();
|
|
84
|
+
});
|
|
85
|
+
this.watcher.on('error', () => {
|
|
86
|
+
// Watcher failed (path missing, permission denied, etc.) — safety poll still runs
|
|
87
|
+
this.stopWatcher();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// fs.watch unavailable or path bad — silently rely on safety poll
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
stopWatcher() {
|
|
95
|
+
if (this.watcher) {
|
|
96
|
+
try {
|
|
97
|
+
this.watcher.close();
|
|
98
|
+
}
|
|
99
|
+
catch { }
|
|
100
|
+
this.watcher = null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
scheduleRePoll() {
|
|
104
|
+
if (this.debounceTimer)
|
|
105
|
+
clearTimeout(this.debounceTimer);
|
|
106
|
+
this.debounceTimer = setTimeout(() => {
|
|
107
|
+
this.debounceTimer = null;
|
|
108
|
+
this.poll();
|
|
109
|
+
}, WATCH_DEBOUNCE_MS);
|
|
110
|
+
}
|
|
111
|
+
async requestDiff(projectId, file, kind) {
|
|
112
|
+
const project = this.projectStore.list().find((p) => p.id === projectId);
|
|
113
|
+
if (!project)
|
|
114
|
+
return null;
|
|
115
|
+
return this.getDiff(project.cwd, file, kind);
|
|
116
|
+
}
|
|
117
|
+
async stage(projectId, files) {
|
|
118
|
+
return this.runAction(projectId, ['add', '--', ...files]);
|
|
119
|
+
}
|
|
120
|
+
async unstage(projectId, files) {
|
|
121
|
+
return this.runAction(projectId, ['restore', '--staged', '--', ...files]);
|
|
122
|
+
}
|
|
123
|
+
async discard(projectId, trackedFiles, untrackedFiles) {
|
|
124
|
+
const project = this.projectStore.list().find((p) => p.id === projectId);
|
|
125
|
+
if (!project)
|
|
126
|
+
return { ok: false, error: 'project not found' };
|
|
127
|
+
try {
|
|
128
|
+
if (trackedFiles.length > 0) {
|
|
129
|
+
await execFileP('git', ['restore', '--staged', '--worktree', '--', ...trackedFiles], {
|
|
130
|
+
cwd: project.cwd,
|
|
131
|
+
timeout: ACTION_TIMEOUT_MS,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (untrackedFiles.length > 0) {
|
|
135
|
+
await execFileP('git', ['clean', '-f', '--', ...untrackedFiles], {
|
|
136
|
+
cwd: project.cwd,
|
|
137
|
+
timeout: ACTION_TIMEOUT_MS,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
this.poll();
|
|
141
|
+
return { ok: true };
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
return { ok: false, error: e.message };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async commit(projectId, message) {
|
|
148
|
+
if (!message.trim())
|
|
149
|
+
return { ok: false, error: 'commit message is empty' };
|
|
150
|
+
return this.runAction(projectId, ['commit', '-m', message]);
|
|
151
|
+
}
|
|
152
|
+
async push(projectId) {
|
|
153
|
+
return this.runNetworkAction(projectId, ['push']);
|
|
154
|
+
}
|
|
155
|
+
async pull(projectId) {
|
|
156
|
+
return this.runNetworkAction(projectId, ['pull', '--ff-only']);
|
|
157
|
+
}
|
|
158
|
+
async stashCreate(projectId, message) {
|
|
159
|
+
const args = message.trim() ? ['stash', 'push', '-u', '-m', message] : ['stash', 'push', '-u'];
|
|
160
|
+
return this.runAction(projectId, args);
|
|
161
|
+
}
|
|
162
|
+
async stashPop(projectId, index) {
|
|
163
|
+
return this.runAction(projectId, ['stash', 'pop', `stash@{${index}}`]);
|
|
164
|
+
}
|
|
165
|
+
async stashApply(projectId, index) {
|
|
166
|
+
return this.runAction(projectId, ['stash', 'apply', `stash@{${index}}`]);
|
|
167
|
+
}
|
|
168
|
+
async stashDrop(projectId, index) {
|
|
169
|
+
return this.runAction(projectId, ['stash', 'drop', `stash@{${index}}`]);
|
|
170
|
+
}
|
|
171
|
+
async runNetworkAction(projectId, args) {
|
|
172
|
+
const project = this.projectStore.list().find((p) => p.id === projectId);
|
|
173
|
+
if (!project)
|
|
174
|
+
return { ok: false, error: 'project not found' };
|
|
175
|
+
try {
|
|
176
|
+
await execFileP('git', args, {
|
|
177
|
+
cwd: project.cwd,
|
|
178
|
+
timeout: NETWORK_TIMEOUT_MS,
|
|
179
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
180
|
+
});
|
|
181
|
+
this.poll();
|
|
182
|
+
return { ok: true };
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
const err = e;
|
|
186
|
+
const msg = (err.stderr || err.message || 'unknown error').trim();
|
|
187
|
+
return { ok: false, error: msg };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async runAction(projectId, args) {
|
|
191
|
+
const project = this.projectStore.list().find((p) => p.id === projectId);
|
|
192
|
+
if (!project)
|
|
193
|
+
return { ok: false, error: 'project not found' };
|
|
194
|
+
try {
|
|
195
|
+
await execFileP('git', args, { cwd: project.cwd, timeout: ACTION_TIMEOUT_MS });
|
|
196
|
+
this.poll();
|
|
197
|
+
return { ok: true };
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
return { ok: false, error: e.message };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async poll() {
|
|
204
|
+
if (this.destroyed || this.polling)
|
|
205
|
+
return;
|
|
206
|
+
const projectId = this.activeProjectId;
|
|
207
|
+
if (!projectId)
|
|
208
|
+
return;
|
|
209
|
+
this.polling = true;
|
|
210
|
+
try {
|
|
211
|
+
const project = this.projectStore.list().find((p) => p.id === projectId);
|
|
212
|
+
if (!project) {
|
|
213
|
+
if (this.lastStatus !== null) {
|
|
214
|
+
this.lastStatus = null;
|
|
215
|
+
this.onStatus(projectId, null);
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const status = await this.getStatus(project.cwd);
|
|
220
|
+
if (this.destroyed || this.activeProjectId !== projectId)
|
|
221
|
+
return;
|
|
222
|
+
if (this.statusEqual(status, this.lastStatus))
|
|
223
|
+
return;
|
|
224
|
+
this.lastStatus = status;
|
|
225
|
+
this.onStatus(projectId, status);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// Swallow — leave previous state in place
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
this.polling = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async getStatus(cwd) {
|
|
235
|
+
try {
|
|
236
|
+
const [statusRes, stashRes] = await Promise.all([
|
|
237
|
+
execFileP('git', ['status', '--porcelain=v2', '--untracked-files=all'], {
|
|
238
|
+
cwd,
|
|
239
|
+
timeout: 3000,
|
|
240
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
241
|
+
}),
|
|
242
|
+
execFileP('git', ['stash', 'list'], { cwd, timeout: 2000, maxBuffer: 256 * 1024 }).catch(() => ({ stdout: '' })),
|
|
243
|
+
]);
|
|
244
|
+
const status = this.parseStatus(statusRes.stdout);
|
|
245
|
+
status.stashes = this.parseStashes(stashRes.stdout);
|
|
246
|
+
return status;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
parseStashes(stdout) {
|
|
253
|
+
const out = [];
|
|
254
|
+
for (const line of stdout.split('\n')) {
|
|
255
|
+
if (!line)
|
|
256
|
+
continue;
|
|
257
|
+
// "stash@{0}: WIP on master: abc1234 commit subject" OR "stash@{0}: On master: my message"
|
|
258
|
+
const m = line.match(/^stash@\{(\d+)\}: (?:WIP )?[oO]n (\S+?): (?:[0-9a-f]+ )?(.+)$/);
|
|
259
|
+
if (!m)
|
|
260
|
+
continue;
|
|
261
|
+
out.push({ index: parseInt(m[1], 10), branch: m[2], message: m[3] });
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
parseStatus(stdout) {
|
|
266
|
+
const staged = [];
|
|
267
|
+
const changes = [];
|
|
268
|
+
const untracked = [];
|
|
269
|
+
const conflicted = [];
|
|
270
|
+
for (const line of stdout.split('\n')) {
|
|
271
|
+
if (line.length === 0)
|
|
272
|
+
continue;
|
|
273
|
+
if (line.startsWith('# '))
|
|
274
|
+
continue;
|
|
275
|
+
if (line.startsWith('? ')) {
|
|
276
|
+
untracked.push({ path: line.slice(2), status: '?' });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (line.startsWith('! '))
|
|
280
|
+
continue;
|
|
281
|
+
if (line.startsWith('1 ')) {
|
|
282
|
+
// 1 XY sub mH mI mW hH hI <path>
|
|
283
|
+
const m = line.match(/^1 (\S\S) \S+ \S+ \S+ \S+ \S+ \S+ (.+)$/);
|
|
284
|
+
if (!m)
|
|
285
|
+
continue;
|
|
286
|
+
const xy = m[1];
|
|
287
|
+
const path = m[2];
|
|
288
|
+
const x = xy[0];
|
|
289
|
+
const y = xy[1];
|
|
290
|
+
if (x !== '.' && x !== ' ') {
|
|
291
|
+
staged.push({ path, status: x });
|
|
292
|
+
}
|
|
293
|
+
if (y !== '.' && y !== ' ') {
|
|
294
|
+
changes.push({ path, status: y });
|
|
295
|
+
}
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (line.startsWith('2 ')) {
|
|
299
|
+
// 2 XY sub mH mI mW hH hI Xscore <path>\t<origPath>
|
|
300
|
+
const m = line.match(/^2 (\S\S) \S+ \S+ \S+ \S+ \S+ \S+ \S+ (.+)$/);
|
|
301
|
+
if (!m)
|
|
302
|
+
continue;
|
|
303
|
+
const xy = m[1];
|
|
304
|
+
const rest = m[2];
|
|
305
|
+
const tabIdx = rest.indexOf('\t');
|
|
306
|
+
const path = tabIdx >= 0 ? rest.slice(0, tabIdx) : rest;
|
|
307
|
+
const oldPath = tabIdx >= 0 ? rest.slice(tabIdx + 1) : undefined;
|
|
308
|
+
const x = xy[0];
|
|
309
|
+
const y = xy[1];
|
|
310
|
+
if (x !== '.' && x !== ' ') {
|
|
311
|
+
staged.push({ path, oldPath, status: x });
|
|
312
|
+
}
|
|
313
|
+
if (y !== '.' && y !== ' ') {
|
|
314
|
+
changes.push({ path, oldPath, status: y });
|
|
315
|
+
}
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (line.startsWith('u ')) {
|
|
319
|
+
// u XY sub m1 m2 m3 mW h1 h2 h3 <path>
|
|
320
|
+
const m = line.match(/^u \S\S \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ (.+)$/);
|
|
321
|
+
if (!m)
|
|
322
|
+
continue;
|
|
323
|
+
conflicted.push({ path: m[1], status: 'U' });
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return { staged, changes, untracked, conflicted, stashes: [] };
|
|
328
|
+
}
|
|
329
|
+
async getDiff(cwd, file, kind) {
|
|
330
|
+
let args;
|
|
331
|
+
if (kind === 'staged') {
|
|
332
|
+
args = ['diff', FULL_CONTEXT_FLAG, '--cached', '--', file];
|
|
333
|
+
}
|
|
334
|
+
else if (kind === 'untracked') {
|
|
335
|
+
// Show the file's full contents as an all-additions diff
|
|
336
|
+
args = ['diff', FULL_CONTEXT_FLAG, '--no-index', '--', '/dev/null', file];
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
args = ['diff', FULL_CONTEXT_FLAG, '--', file];
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
const { stdout } = await execFileP('git', args, {
|
|
343
|
+
cwd,
|
|
344
|
+
timeout: DIFF_TIMEOUT_MS,
|
|
345
|
+
maxBuffer: DIFF_MAX_BYTES + 1024,
|
|
346
|
+
});
|
|
347
|
+
const truncated = stdout.length >= DIFF_MAX_BYTES;
|
|
348
|
+
const diff = truncated ? stdout.slice(0, DIFF_MAX_BYTES) : stdout;
|
|
349
|
+
const binary = /^Binary files .* differ$/m.test(diff);
|
|
350
|
+
return { diff, binary, truncated };
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
353
|
+
// `git diff --no-index` exits 1 when files differ — still valid output
|
|
354
|
+
const err = e;
|
|
355
|
+
if (err.stdout !== undefined && (err.code === 1 || kind === 'untracked')) {
|
|
356
|
+
const truncated = err.stdout.length >= DIFF_MAX_BYTES;
|
|
357
|
+
const diff = truncated ? err.stdout.slice(0, DIFF_MAX_BYTES) : err.stdout;
|
|
358
|
+
const binary = /^Binary files .* differ$/m.test(diff);
|
|
359
|
+
return { diff, binary, truncated };
|
|
360
|
+
}
|
|
361
|
+
return { diff: '', binary: false, truncated: false };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
statusEqual(a, b) {
|
|
365
|
+
if (a === b)
|
|
366
|
+
return true;
|
|
367
|
+
if (!a || !b)
|
|
368
|
+
return false;
|
|
369
|
+
return (this.entriesEqual(a.staged, b.staged) &&
|
|
370
|
+
this.entriesEqual(a.changes, b.changes) &&
|
|
371
|
+
this.entriesEqual(a.untracked, b.untracked) &&
|
|
372
|
+
this.entriesEqual(a.conflicted, b.conflicted) &&
|
|
373
|
+
this.stashesEqual(a.stashes, b.stashes));
|
|
374
|
+
}
|
|
375
|
+
stashesEqual(a, b) {
|
|
376
|
+
if (a.length !== b.length)
|
|
377
|
+
return false;
|
|
378
|
+
for (let i = 0; i < a.length; i++) {
|
|
379
|
+
if (a[i].index !== b[i].index || a[i].message !== b[i].message || a[i].branch !== b[i].branch) {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
entriesEqual(a, b) {
|
|
386
|
+
if (a.length !== b.length)
|
|
387
|
+
return false;
|
|
388
|
+
for (let i = 0; i < a.length; i++) {
|
|
389
|
+
if (a[i].path !== b[i].path)
|
|
390
|
+
return false;
|
|
391
|
+
if (a[i].status !== b[i].status)
|
|
392
|
+
return false;
|
|
393
|
+
if (a[i].oldPath !== b[i].oldPath)
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -268,6 +268,37 @@ async function startServer(devMode, port) {
|
|
|
268
268
|
res.json({ valid: false });
|
|
269
269
|
}
|
|
270
270
|
});
|
|
271
|
+
// macOS-only Finder folder picker via osascript. Returns the chosen POSIX path or null.
|
|
272
|
+
app.post('/api/pick-folder', async (req, res) => {
|
|
273
|
+
if (process.platform !== 'darwin') {
|
|
274
|
+
res.json({ path: null, error: 'folder picker is macOS-only' });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const { default: defaultLocation } = req.body ?? {};
|
|
278
|
+
const seed = (typeof defaultLocation === 'string' && defaultLocation)
|
|
279
|
+
? defaultLocation.replace(/^~/, os.homedir())
|
|
280
|
+
: os.homedir();
|
|
281
|
+
const { execFile } = await import('node:child_process');
|
|
282
|
+
const script = `
|
|
283
|
+
try
|
|
284
|
+
tell application "System Events"
|
|
285
|
+
activate
|
|
286
|
+
set chosen to choose folder with prompt "Select a folder" default location POSIX file "${seed.replace(/"/g, '\\"')}"
|
|
287
|
+
end tell
|
|
288
|
+
return POSIX path of chosen
|
|
289
|
+
on error number -128
|
|
290
|
+
return ""
|
|
291
|
+
end try
|
|
292
|
+
`;
|
|
293
|
+
execFile('osascript', ['-e', script], { timeout: 120_000 }, (err, stdout) => {
|
|
294
|
+
if (err) {
|
|
295
|
+
res.json({ path: null });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const trimmed = stdout.trim().replace(/\/$/, '');
|
|
299
|
+
res.json({ path: trimmed || null });
|
|
300
|
+
});
|
|
301
|
+
});
|
|
271
302
|
// Resolve a dropped file's full path via tiered search (stat → find → Spotlight)
|
|
272
303
|
const resolvePathCache = new Map();
|
|
273
304
|
const RESOLVE_CACHE_TTL = 30_000;
|
|
@@ -3,7 +3,7 @@ import { execSync } from 'node:child_process';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
export class PtyManager {
|
|
5
5
|
sessions = new Map();
|
|
6
|
-
spawn(terminalId, projectId, cwd, onOutput, onExit) {
|
|
6
|
+
spawn(terminalId, projectId, cwd, onOutput, onExit, command) {
|
|
7
7
|
const shell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : '/bin/bash');
|
|
8
8
|
// Filter out undefined values from process.env before spreading
|
|
9
9
|
const env = {};
|
|
@@ -14,7 +14,22 @@ export class PtyManager {
|
|
|
14
14
|
env.TERM = 'xterm-256color';
|
|
15
15
|
env.LANG = 'en_US.UTF-8';
|
|
16
16
|
env.LC_ALL = 'en_US.UTF-8';
|
|
17
|
-
|
|
17
|
+
// Command mode: run the command in the user's own login+interactive shell,
|
|
18
|
+
// then drop back into an interactive shell so the PTY stays alive (matches
|
|
19
|
+
// Terminal.app behavior). We use `-l -i` so both profile AND rc files
|
|
20
|
+
// (e.g. ~/.zshrc) are sourced — that's where user-installed binaries
|
|
21
|
+
// like `claude`, `nvm`-managed `node`, etc. land on PATH.
|
|
22
|
+
//
|
|
23
|
+
// Passing the command via $0 + eval keeps the user's command opaque to the
|
|
24
|
+
// wrapper script's quoting.
|
|
25
|
+
const wrapper = `eval "$0"; printf '\\r\\n\\033[90m[paneful: command finished — type exit to close this run]\\033[0m\\r\\n'; exec "$1" -i -l`;
|
|
26
|
+
const isCommandMode = !!command;
|
|
27
|
+
// bash and zsh both accept -l -i -c SCRIPT ARG ARG. Fish doesn't, so fall back to bash.
|
|
28
|
+
const wrapperShell = /\/(bash|zsh)$/.test(shell) ? shell : '/bin/bash';
|
|
29
|
+
const args = isCommandMode
|
|
30
|
+
? ['-l', '-i', '-c', wrapper, command, shell]
|
|
31
|
+
: ['--login'];
|
|
32
|
+
const proc = pty.spawn(isCommandMode ? wrapperShell : shell, args, {
|
|
18
33
|
name: 'xterm-256color',
|
|
19
34
|
cols: 80,
|
|
20
35
|
rows: 24,
|
|
@@ -70,6 +85,26 @@ export class PtyManager {
|
|
|
70
85
|
}
|
|
71
86
|
this.sessions.clear();
|
|
72
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Pauses the PTY by sending SIGSTOP to its process group. The shell and its
|
|
90
|
+
* descendants freeze in place — no CPU, no I/O — and their in-memory state
|
|
91
|
+
* is preserved until `cont()` is called.
|
|
92
|
+
*/
|
|
93
|
+
pause(terminalId) {
|
|
94
|
+
const managed = this.sessions.get(terminalId);
|
|
95
|
+
if (!managed)
|
|
96
|
+
return false;
|
|
97
|
+
return signalPty(managed.process.pid, 'SIGSTOP');
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Continues a paused PTY (SIGCONT). The process picks up exactly where it was.
|
|
101
|
+
*/
|
|
102
|
+
cont(terminalId) {
|
|
103
|
+
const managed = this.sessions.get(terminalId);
|
|
104
|
+
if (!managed)
|
|
105
|
+
return false;
|
|
106
|
+
return signalPty(managed.process.pid, 'SIGCONT');
|
|
107
|
+
}
|
|
73
108
|
terminalExists(terminalId) {
|
|
74
109
|
return this.sessions.has(terminalId);
|
|
75
110
|
}
|
|
@@ -130,3 +165,20 @@ export class PtyManager {
|
|
|
130
165
|
const RUNTIME_PROCESSES = new Set(['node', 'python', 'python3']);
|
|
131
166
|
// Match agent binary names at the end of a path or as a standalone token
|
|
132
167
|
const AGENT_CMD_PATTERN = /(?:^|\/)(codex|claude|aider)(?:\s|$)/;
|
|
168
|
+
// Signal the PTY's whole process group (negative pid) so children pause/continue with it.
|
|
169
|
+
// Falls back to single-process if the group signal fails (unlikely on macOS).
|
|
170
|
+
function signalPty(pid, signal) {
|
|
171
|
+
try {
|
|
172
|
+
process.kill(-pid, signal);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
try {
|
|
177
|
+
process.kill(pid, signal);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const RUN_HISTORY_LIMIT = 200;
|
|
4
|
+
export class ScheduleStore {
|
|
5
|
+
jobs = new Map();
|
|
6
|
+
runs = [];
|
|
7
|
+
filePath;
|
|
8
|
+
constructor(dataDir, getProjectCwd) {
|
|
9
|
+
this.filePath = path.join(dataDir, 'schedules.json');
|
|
10
|
+
if (fs.existsSync(this.filePath)) {
|
|
11
|
+
try {
|
|
12
|
+
const raw = JSON.parse(fs.readFileSync(this.filePath, 'utf-8'));
|
|
13
|
+
let mutated = false;
|
|
14
|
+
for (const job of raw.jobs ?? []) {
|
|
15
|
+
// Migration: older schedules used projectId, now we store cwd directly
|
|
16
|
+
if (!job.cwd && job.projectId && getProjectCwd) {
|
|
17
|
+
const cwd = getProjectCwd(job.projectId);
|
|
18
|
+
if (cwd) {
|
|
19
|
+
job.cwd = cwd;
|
|
20
|
+
mutated = true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
delete job.projectId;
|
|
24
|
+
if (job.cwd)
|
|
25
|
+
this.jobs.set(job.id, job);
|
|
26
|
+
}
|
|
27
|
+
this.runs = (raw.runs ?? []).slice(-RUN_HISTORY_LIMIT);
|
|
28
|
+
if (mutated)
|
|
29
|
+
this.persist();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// corrupt file, start fresh
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
listJobs() {
|
|
37
|
+
return Array.from(this.jobs.values()).sort((a, b) => a.createdAt - b.createdAt);
|
|
38
|
+
}
|
|
39
|
+
getJob(id) {
|
|
40
|
+
return this.jobs.get(id);
|
|
41
|
+
}
|
|
42
|
+
createJob(job) {
|
|
43
|
+
this.jobs.set(job.id, job);
|
|
44
|
+
this.persist();
|
|
45
|
+
}
|
|
46
|
+
updateJob(job) {
|
|
47
|
+
if (!this.jobs.has(job.id))
|
|
48
|
+
return;
|
|
49
|
+
this.jobs.set(job.id, job);
|
|
50
|
+
this.persist();
|
|
51
|
+
}
|
|
52
|
+
deleteJob(id) {
|
|
53
|
+
this.jobs.delete(id);
|
|
54
|
+
// Drop runs for the deleted job too
|
|
55
|
+
this.runs = this.runs.filter((r) => r.jobId !== id);
|
|
56
|
+
this.persist();
|
|
57
|
+
}
|
|
58
|
+
listRuns(jobId) {
|
|
59
|
+
return jobId ? this.runs.filter((r) => r.jobId === jobId) : this.runs.slice();
|
|
60
|
+
}
|
|
61
|
+
addRun(run) {
|
|
62
|
+
this.runs.push(run);
|
|
63
|
+
if (this.runs.length > RUN_HISTORY_LIMIT) {
|
|
64
|
+
this.runs.splice(0, this.runs.length - RUN_HISTORY_LIMIT);
|
|
65
|
+
}
|
|
66
|
+
this.persist();
|
|
67
|
+
}
|
|
68
|
+
updateRun(runId, patch) {
|
|
69
|
+
const idx = this.runs.findIndex((r) => r.id === runId);
|
|
70
|
+
if (idx < 0)
|
|
71
|
+
return undefined;
|
|
72
|
+
this.runs[idx] = { ...this.runs[idx], ...patch };
|
|
73
|
+
this.persist();
|
|
74
|
+
return this.runs[idx];
|
|
75
|
+
}
|
|
76
|
+
removeRun(runId) {
|
|
77
|
+
this.runs = this.runs.filter((r) => r.id !== runId);
|
|
78
|
+
this.persist();
|
|
79
|
+
}
|
|
80
|
+
hasEnabledJobs() {
|
|
81
|
+
for (const job of this.jobs.values()) {
|
|
82
|
+
if (job.enabled)
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
persist() {
|
|
88
|
+
try {
|
|
89
|
+
const dir = path.dirname(this.filePath);
|
|
90
|
+
if (!fs.existsSync(dir))
|
|
91
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
92
|
+
const data = { jobs: this.listJobs(), runs: this.runs };
|
|
93
|
+
fs.writeFileSync(this.filePath, JSON.stringify(data, null, 2));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
console.error('Failed to persist schedules');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|