paneful 0.9.16 → 0.9.18

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 CHANGED
@@ -67,6 +67,19 @@ 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
+ ### Source Control
71
+
72
+ 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:
73
+
74
+ - **Branch bar** with current branch, ahead/behind counts, and one-click pull (`--ff-only`) and push.
75
+ - **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).
76
+ - **Multi-select** with Cmd-click (toggle) and Shift-click (range) for bulk stage/unstage/discard within a group.
77
+ - **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).
78
+ - **Inline commit** message + button; auto-clears on success.
79
+ - **Stash** — create (with optional message, includes untracked), apply, pop, or drop directly from the panel.
80
+
81
+ The panel only polls its data when open, and the file watcher only runs while focused on the active project — zero overhead when collapsed.
82
+
70
83
  ### AI Agent Detection
71
84
 
72
85
  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 +144,7 @@ Paneful checks for newer versions on npm and shows a notification in the sidebar
131
144
  | `Ctrl+Shift+Arrow` | Move focus to adjacent pane |
132
145
  | `Shift+Arrow` | Swap focused pane with adjacent |
133
146
  | `Cmd+D` | Toggle sidebar |
147
+ | `Cmd+Shift+G` | Toggle source control panel |
134
148
  | `Cmd+T` | Cycle through layout presets |
135
149
  | `Cmd+R` | Auto reorganize panes |
136
150
 
@@ -155,6 +169,9 @@ npm run build
155
169
 
156
170
  # Run locally
157
171
  npm start
172
+
173
+ # Install the local build globally (replaces any npm-published version)
174
+ npm -g uninstall paneful; npm run build; npm install -g .; paneful --install-app
158
175
  ```
159
176
 
160
177
  Vite dev server proxies `/ws` and `/api` to `localhost:3000`. Open `http://localhost:5173` or use Chrome in app mode for full keyboard shortcut support:
@@ -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
+ }