paneful 0.9.0 → 0.9.2
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 +22 -0
- package/dist/server/claude-monitor.js +16 -1
- package/dist/server/editor-monitor.js +394 -0
- package/dist/server/git-monitor.js +98 -0
- package/dist/server/index.js +28 -65
- package/dist/server/port-monitor.js +34 -2
- package/dist/server/ws-handler.js +48 -1
- package/dist/web/assets/index-CaxQEraM.css +32 -0
- package/dist/web/assets/index-DZHbyL92.js +394 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CYMQU0Pz.js +0 -349
- package/dist/web/assets/index-Df0yoS2W.css +0 -32
package/README.md
CHANGED
|
@@ -51,10 +51,30 @@ Requires:
|
|
|
51
51
|
|
|
52
52
|
Save a workspace layout as a favourite — name, layout preset, and per-pane commands. Launch any favourite with a click to instantly recreate the setup.
|
|
53
53
|
|
|
54
|
+
### Terminal Search
|
|
55
|
+
|
|
56
|
+
Press `Cmd+F` in any focused terminal to search its scrollback. Navigate matches with Enter / Shift+Enter or the up/down buttons. Press Escape to close.
|
|
57
|
+
|
|
58
|
+
### Command Palette
|
|
59
|
+
|
|
60
|
+
Press `Cmd+P` to open the command palette. Quickly switch projects, launch favourites, change layouts, or run any action — all from one fuzzy-searchable list.
|
|
61
|
+
|
|
62
|
+
### Git Branch Display
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
### AI Agent Detection
|
|
67
|
+
|
|
68
|
+
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.
|
|
69
|
+
|
|
54
70
|
### Dev Server Detection
|
|
55
71
|
|
|
56
72
|
Automatically detects when a dev server starts in a terminal (Vite, Next.js, Angular, etc.). A green dot appears next to the project name in the sidebar while the port is alive, and disappears when it stops. Tracks ports per-terminal so the same port across different projects is handled correctly.
|
|
57
73
|
|
|
74
|
+
### Project Cleanup
|
|
75
|
+
|
|
76
|
+
Click the broom icon in the sidebar header to scan for projects whose directories no longer exist on disk. A confirmation modal shows matching projects before removing them.
|
|
77
|
+
|
|
58
78
|
### Auto-Reorganize
|
|
59
79
|
|
|
60
80
|
Press `Cmd+R` or click the dashboard icon in the toolbar to automatically pick the best layout for your current pane count.
|
|
@@ -85,6 +105,8 @@ Paneful checks for newer versions on npm and shows a notification in the sidebar
|
|
|
85
105
|
|
|
86
106
|
| Shortcut | Action |
|
|
87
107
|
| ------------------ | ------------------------------- |
|
|
108
|
+
| `Cmd+P` | Command palette |
|
|
109
|
+
| `Cmd+F` | Search terminal scrollback |
|
|
88
110
|
| `Cmd+N` | New pane (vertical split) |
|
|
89
111
|
| `Cmd+Shift+N` | New pane (horizontal split) |
|
|
90
112
|
| `Cmd+W` | Close focused pane |
|
|
@@ -11,9 +11,24 @@ export class ClaudeMonitor {
|
|
|
11
11
|
this.ptyManager = ptyManager;
|
|
12
12
|
this.onChange = onChange;
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
resume() {
|
|
15
|
+
if (this.destroyed || this.pollTimer)
|
|
16
|
+
return;
|
|
15
17
|
this.pollTimer = setInterval(() => this.poll(), 3000);
|
|
16
18
|
}
|
|
19
|
+
pause() {
|
|
20
|
+
if (this.pollTimer) {
|
|
21
|
+
clearInterval(this.pollTimer);
|
|
22
|
+
this.pollTimer = null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
getStatuses() {
|
|
26
|
+
return { ...this.prevStatuses };
|
|
27
|
+
}
|
|
28
|
+
/** @deprecated Use resume() instead */
|
|
29
|
+
start() {
|
|
30
|
+
this.resume();
|
|
31
|
+
}
|
|
17
32
|
/** Call from the PTY output path to record activity. */
|
|
18
33
|
recordOutput(terminalId) {
|
|
19
34
|
this.lastOutput.set(terminalId, Date.now());
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { spawn, execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { homedir, tmpdir } from 'node:os';
|
|
6
|
+
const EDITOR_PATTERNS = ['cursor', 'code', 'vscode', 'visual studio code', 'zed', 'windsurf', 'electron'];
|
|
7
|
+
// Long-lived osascript fallback — same as before.
|
|
8
|
+
const STREAMING_SCRIPT = `
|
|
9
|
+
repeat
|
|
10
|
+
try
|
|
11
|
+
tell application "System Events"
|
|
12
|
+
set frontApp to name of first application process whose frontmost is true
|
|
13
|
+
set winTitle to ""
|
|
14
|
+
tell process frontApp
|
|
15
|
+
if exists front window then
|
|
16
|
+
set winTitle to name of front window
|
|
17
|
+
end if
|
|
18
|
+
end tell
|
|
19
|
+
end tell
|
|
20
|
+
log frontApp & "\\t" & winTitle
|
|
21
|
+
end try
|
|
22
|
+
delay 0.75
|
|
23
|
+
end repeat
|
|
24
|
+
`;
|
|
25
|
+
// Swift helper: event-driven app switches via NSWorkspace, AX observer for window/title changes.
|
|
26
|
+
const SWIFT_SOURCE = `
|
|
27
|
+
import Cocoa
|
|
28
|
+
|
|
29
|
+
let editorPatterns: [String] = ["cursor", "code", "vscode", "visual studio code", "zed", "windsurf", "electron"]
|
|
30
|
+
|
|
31
|
+
func isEditor(_ name: String) -> Bool {
|
|
32
|
+
let lower = name.lowercased()
|
|
33
|
+
return editorPatterns.contains { lower.contains($0) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func getWindowTitle(pid: pid_t) -> String {
|
|
37
|
+
let app = AXUIElementCreateApplication(pid)
|
|
38
|
+
var value: AnyObject?
|
|
39
|
+
guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &value) == .success else {
|
|
40
|
+
return ""
|
|
41
|
+
}
|
|
42
|
+
var title: AnyObject?
|
|
43
|
+
guard AXUIElementCopyAttributeValue(value as! AXUIElement, kAXTitleAttribute as CFString, &title) == .success else {
|
|
44
|
+
return ""
|
|
45
|
+
}
|
|
46
|
+
return title as? String ?? ""
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
setbuf(stdout, nil)
|
|
50
|
+
setbuf(stderr, nil)
|
|
51
|
+
|
|
52
|
+
let startTime = CFAbsoluteTimeGetCurrent()
|
|
53
|
+
func log(_ msg: String) {
|
|
54
|
+
let elapsed = String(format: "%.3f", CFAbsoluteTimeGetCurrent() - startTime)
|
|
55
|
+
fputs("[swift +\\(elapsed)s] \\(msg)\\n", stderr)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
var lastOutput = ""
|
|
59
|
+
var currentPid: pid_t = 0
|
|
60
|
+
var currentAppName = ""
|
|
61
|
+
var axObserver: AXObserver?
|
|
62
|
+
var observedWindow: AXUIElement?
|
|
63
|
+
var fallbackTimer: Timer?
|
|
64
|
+
|
|
65
|
+
func emit(_ appName: String, _ title: String, source: String) {
|
|
66
|
+
let output = "\\(appName)\\t\\(title)"
|
|
67
|
+
guard output != lastOutput else { return }
|
|
68
|
+
lastOutput = output
|
|
69
|
+
log("emit (\\(source)): \\(appName) | \\(title.isEmpty ? "(no title)" : title)")
|
|
70
|
+
print(output)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func emitCurrentTitle(source: String) {
|
|
74
|
+
let title = getWindowTitle(pid: currentPid)
|
|
75
|
+
emit(currentAppName, title, source: source)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let axCallback: AXObserverCallback = { _, _, notification, _ in
|
|
79
|
+
let name = notification as String
|
|
80
|
+
if name == (kAXFocusedWindowChangedNotification as String) {
|
|
81
|
+
log("AX: focused window changed")
|
|
82
|
+
observeFocusedWindowTitle()
|
|
83
|
+
emitCurrentTitle(source: "ax-window-change")
|
|
84
|
+
} else if name == (kAXTitleChangedNotification as String) {
|
|
85
|
+
log("AX: title changed")
|
|
86
|
+
emitCurrentTitle(source: "ax-title-change")
|
|
87
|
+
} else {
|
|
88
|
+
emitCurrentTitle(source: "ax-\\(name)")
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func observeFocusedWindowTitle() {
|
|
93
|
+
guard let obs = axObserver else { return }
|
|
94
|
+
if let old = observedWindow {
|
|
95
|
+
AXObserverRemoveNotification(obs, old, kAXTitleChangedNotification as CFString)
|
|
96
|
+
observedWindow = nil
|
|
97
|
+
}
|
|
98
|
+
let app = AXUIElementCreateApplication(currentPid)
|
|
99
|
+
var winValue: AnyObject?
|
|
100
|
+
guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &winValue) == .success else { return }
|
|
101
|
+
let window = winValue as! AXUIElement
|
|
102
|
+
AXObserverAddNotification(obs, window, kAXTitleChangedNotification as CFString, nil)
|
|
103
|
+
observedWindow = window
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func startObserving(pid: pid_t) {
|
|
107
|
+
stopObserving()
|
|
108
|
+
emitCurrentTitle(source: "editor-activated")
|
|
109
|
+
|
|
110
|
+
var observer: AXObserver?
|
|
111
|
+
guard AXObserverCreate(pid, axCallback, &observer) == .success, let obs = observer else {
|
|
112
|
+
log("AXObserver failed for pid \\(pid), using poll fallback")
|
|
113
|
+
fallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
|
|
114
|
+
emitCurrentTitle(source: "poll")
|
|
115
|
+
}
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let appElement = AXUIElementCreateApplication(pid)
|
|
120
|
+
AXObserverAddNotification(obs, appElement, kAXFocusedWindowChangedNotification as CFString, nil)
|
|
121
|
+
CFRunLoopAddSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(obs), .defaultMode)
|
|
122
|
+
axObserver = obs
|
|
123
|
+
observeFocusedWindowTitle()
|
|
124
|
+
log("AXObserver active for pid \\(pid)")
|
|
125
|
+
|
|
126
|
+
// AX notifications don't fire for Electron apps (Cursor, VS Code, etc.)
|
|
127
|
+
// so this timer is the primary mechanism for those editors
|
|
128
|
+
fallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
|
|
129
|
+
emitCurrentTitle(source: "poll")
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func stopObserving() {
|
|
134
|
+
if let obs = axObserver {
|
|
135
|
+
CFRunLoopRemoveSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(obs), .defaultMode)
|
|
136
|
+
axObserver = nil
|
|
137
|
+
observedWindow = nil
|
|
138
|
+
}
|
|
139
|
+
fallbackTimer?.invalidate()
|
|
140
|
+
fallbackTimer = nil
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let nc = NSWorkspace.shared.notificationCenter
|
|
144
|
+
nc.addObserver(forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main) { notif in
|
|
145
|
+
guard let app = notif.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
|
|
146
|
+
let name = app.localizedName else { return }
|
|
147
|
+
log("app switch: \\(name)")
|
|
148
|
+
currentAppName = name
|
|
149
|
+
currentPid = app.processIdentifier
|
|
150
|
+
if isEditor(name) {
|
|
151
|
+
startObserving(pid: currentPid)
|
|
152
|
+
} else {
|
|
153
|
+
stopObserving()
|
|
154
|
+
emit(name, "", source: "app-switch")
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if let front = NSWorkspace.shared.frontmostApplication, let name = front.localizedName {
|
|
159
|
+
log("seed: \\(name)")
|
|
160
|
+
currentAppName = name
|
|
161
|
+
currentPid = front.processIdentifier
|
|
162
|
+
if isEditor(name) {
|
|
163
|
+
startObserving(pid: currentPid)
|
|
164
|
+
} else {
|
|
165
|
+
emit(name, "", source: "seed")
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
RunLoop.main.run()
|
|
170
|
+
`;
|
|
171
|
+
export class EditorMonitor {
|
|
172
|
+
onChange;
|
|
173
|
+
proc = null;
|
|
174
|
+
destroyed = false;
|
|
175
|
+
lineBuffer = '';
|
|
176
|
+
cache = { projectName: null };
|
|
177
|
+
restartTimer = null;
|
|
178
|
+
mode = 'osascript';
|
|
179
|
+
compiling = false;
|
|
180
|
+
constructor(onChange) {
|
|
181
|
+
this.onChange = onChange;
|
|
182
|
+
}
|
|
183
|
+
getState() {
|
|
184
|
+
return this.cache;
|
|
185
|
+
}
|
|
186
|
+
get helperPath() {
|
|
187
|
+
return join(homedir(), '.paneful', 'paneful-editor-helper');
|
|
188
|
+
}
|
|
189
|
+
get versionPath() {
|
|
190
|
+
return this.helperPath + '.version';
|
|
191
|
+
}
|
|
192
|
+
sourceHash() {
|
|
193
|
+
return createHash('sha256').update(SWIFT_SOURCE).digest('hex').slice(0, 16);
|
|
194
|
+
}
|
|
195
|
+
isHelperCurrent() {
|
|
196
|
+
if (!existsSync(this.helperPath))
|
|
197
|
+
return false;
|
|
198
|
+
try {
|
|
199
|
+
const stored = readFileSync(this.versionPath, 'utf-8').trim();
|
|
200
|
+
return stored === this.sourceHash();
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async compileHelper() {
|
|
207
|
+
if (this.compiling)
|
|
208
|
+
return;
|
|
209
|
+
this.compiling = true;
|
|
210
|
+
const dir = join(homedir(), '.paneful');
|
|
211
|
+
mkdirSync(dir, { recursive: true });
|
|
212
|
+
const tmpSrc = join(tmpdir(), `paneful-editor-helper-${process.pid}.swift`);
|
|
213
|
+
writeFileSync(tmpSrc, SWIFT_SOURCE);
|
|
214
|
+
try {
|
|
215
|
+
await new Promise((resolve, reject) => {
|
|
216
|
+
const child = execFile('swiftc', [tmpSrc, '-o', this.helperPath, '-framework', 'Cocoa', '-O'], { timeout: 60_000 }, (err) => (err ? reject(err) : resolve()));
|
|
217
|
+
child.unref();
|
|
218
|
+
});
|
|
219
|
+
writeFileSync(this.versionPath, this.sourceHash());
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
this.compiling = false;
|
|
223
|
+
try {
|
|
224
|
+
unlinkSync(tmpSrc);
|
|
225
|
+
}
|
|
226
|
+
catch { }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
resume() {
|
|
230
|
+
if (this.destroyed || this.proc)
|
|
231
|
+
return;
|
|
232
|
+
if (process.platform !== 'darwin')
|
|
233
|
+
return;
|
|
234
|
+
if (this.isHelperCurrent()) {
|
|
235
|
+
this.mode = 'native';
|
|
236
|
+
console.log('[editor-monitor] native helper is current, starting directly');
|
|
237
|
+
this.startProcess();
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
this.mode = 'osascript';
|
|
241
|
+
console.log('[editor-monitor] native helper not found, starting osascript fallback');
|
|
242
|
+
this.startProcess();
|
|
243
|
+
console.log('[editor-monitor] compiling native helper in background...');
|
|
244
|
+
this.compileHelper()
|
|
245
|
+
.then(() => {
|
|
246
|
+
if (this.destroyed)
|
|
247
|
+
return;
|
|
248
|
+
console.log('[editor-monitor] compilation done, hot-swapping to native helper');
|
|
249
|
+
this.stopProcess();
|
|
250
|
+
this.mode = 'native';
|
|
251
|
+
this.startProcess();
|
|
252
|
+
})
|
|
253
|
+
.catch((err) => {
|
|
254
|
+
console.log(`[editor-monitor] compilation failed, keeping osascript: ${err.message}`);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
pause() {
|
|
259
|
+
this.stopProcess();
|
|
260
|
+
}
|
|
261
|
+
destroy() {
|
|
262
|
+
this.destroyed = true;
|
|
263
|
+
this.stopProcess();
|
|
264
|
+
}
|
|
265
|
+
startProcess() {
|
|
266
|
+
if (this.destroyed || this.proc)
|
|
267
|
+
return;
|
|
268
|
+
let proc;
|
|
269
|
+
if (this.mode === 'native') {
|
|
270
|
+
proc = spawn(this.helperPath, [], {
|
|
271
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
272
|
+
});
|
|
273
|
+
this.proc = proc;
|
|
274
|
+
this.lineBuffer = '';
|
|
275
|
+
proc.stdout.on('data', (chunk) => {
|
|
276
|
+
if (this.destroyed)
|
|
277
|
+
return;
|
|
278
|
+
this.feedData(chunk);
|
|
279
|
+
});
|
|
280
|
+
// Forward Swift helper's debug logs to server console
|
|
281
|
+
let stderrBuf = '';
|
|
282
|
+
proc.stderr.on('data', (chunk) => {
|
|
283
|
+
stderrBuf += chunk.toString();
|
|
284
|
+
const lines = stderrBuf.split('\n');
|
|
285
|
+
stderrBuf = lines.pop() ?? '';
|
|
286
|
+
for (const line of lines) {
|
|
287
|
+
if (line.trim())
|
|
288
|
+
console.log(`[editor-monitor] ${line.trim()}`);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
proc = spawn('osascript', ['-e', STREAMING_SCRIPT], {
|
|
294
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
295
|
+
});
|
|
296
|
+
this.proc = proc;
|
|
297
|
+
this.lineBuffer = '';
|
|
298
|
+
proc.stderr.on('data', (chunk) => {
|
|
299
|
+
if (this.destroyed)
|
|
300
|
+
return;
|
|
301
|
+
this.feedData(chunk);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const currentMode = this.mode;
|
|
305
|
+
proc.on('error', (err) => {
|
|
306
|
+
if (this.proc !== proc)
|
|
307
|
+
return;
|
|
308
|
+
const msg = err.message || '';
|
|
309
|
+
const needsAccess = msg.includes('not allowed assistive access') || msg.includes('1719');
|
|
310
|
+
this.cache = { projectName: null, needsAccessibility: needsAccess || undefined };
|
|
311
|
+
this.proc = null;
|
|
312
|
+
if (currentMode === 'native') {
|
|
313
|
+
console.log(`[editor-monitor] native helper error, falling back to osascript: ${msg}`);
|
|
314
|
+
this.mode = 'osascript';
|
|
315
|
+
}
|
|
316
|
+
this.scheduleRestart();
|
|
317
|
+
});
|
|
318
|
+
proc.on('exit', (code) => {
|
|
319
|
+
if (this.proc !== proc)
|
|
320
|
+
return;
|
|
321
|
+
this.proc = null;
|
|
322
|
+
if (currentMode === 'native' && code !== 0) {
|
|
323
|
+
console.log(`[editor-monitor] native helper exited (code ${code}), falling back to osascript`);
|
|
324
|
+
this.mode = 'osascript';
|
|
325
|
+
}
|
|
326
|
+
this.scheduleRestart();
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
feedData(chunk) {
|
|
330
|
+
this.lineBuffer += chunk.toString();
|
|
331
|
+
const lines = this.lineBuffer.split('\n');
|
|
332
|
+
this.lineBuffer = lines.pop() ?? '';
|
|
333
|
+
for (const line of lines) {
|
|
334
|
+
this.handleLine(line.trim());
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
stopProcess() {
|
|
338
|
+
if (this.restartTimer) {
|
|
339
|
+
clearTimeout(this.restartTimer);
|
|
340
|
+
this.restartTimer = null;
|
|
341
|
+
}
|
|
342
|
+
if (this.proc) {
|
|
343
|
+
this.proc.kill();
|
|
344
|
+
this.proc = null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
scheduleRestart() {
|
|
348
|
+
if (this.destroyed)
|
|
349
|
+
return;
|
|
350
|
+
if (this.restartTimer)
|
|
351
|
+
return;
|
|
352
|
+
this.restartTimer = setTimeout(() => {
|
|
353
|
+
this.restartTimer = null;
|
|
354
|
+
if (!this.destroyed && !this.proc) {
|
|
355
|
+
this.startProcess();
|
|
356
|
+
}
|
|
357
|
+
}, 5000);
|
|
358
|
+
}
|
|
359
|
+
handleLine(line) {
|
|
360
|
+
if (!line)
|
|
361
|
+
return;
|
|
362
|
+
const tabIdx = line.indexOf('\t');
|
|
363
|
+
const appName = tabIdx >= 0 ? line.slice(0, tabIdx) : line;
|
|
364
|
+
const title = tabIdx >= 0 ? line.slice(tabIdx + 1) : '';
|
|
365
|
+
const isEditor = EDITOR_PATTERNS.some((pat) => appName.toLowerCase().includes(pat));
|
|
366
|
+
if (!isEditor || !title) {
|
|
367
|
+
this.cache = { projectName: null };
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
let projectName = null;
|
|
371
|
+
const pathMatch = title.match(/^(~?\/[^\s]+)/);
|
|
372
|
+
if (pathMatch) {
|
|
373
|
+
const segments = pathMatch[1].replace(/\/$/, '').split('/');
|
|
374
|
+
projectName = segments[segments.length - 1] || null;
|
|
375
|
+
}
|
|
376
|
+
if (!projectName) {
|
|
377
|
+
const parts = title.split(' \u2014 ');
|
|
378
|
+
if (parts.length >= 3) {
|
|
379
|
+
projectName = parts[parts.length - 2];
|
|
380
|
+
}
|
|
381
|
+
else if (parts.length === 2) {
|
|
382
|
+
projectName = parts[0];
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
projectName = title;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const prev = this.cache.projectName;
|
|
389
|
+
this.cache = { projectName };
|
|
390
|
+
if (projectName && projectName !== prev) {
|
|
391
|
+
this.onChange(projectName);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
export class GitMonitor {
|
|
3
|
+
projectStore;
|
|
4
|
+
onChange;
|
|
5
|
+
branches = new Map();
|
|
6
|
+
pollTimer = null;
|
|
7
|
+
destroyed = false;
|
|
8
|
+
polling = false;
|
|
9
|
+
constructor(projectStore, onChange) {
|
|
10
|
+
this.projectStore = projectStore;
|
|
11
|
+
this.onChange = onChange;
|
|
12
|
+
// Initial poll to have data ready for first client connection
|
|
13
|
+
this.poll();
|
|
14
|
+
}
|
|
15
|
+
resume() {
|
|
16
|
+
if (this.destroyed || this.pollTimer)
|
|
17
|
+
return;
|
|
18
|
+
this.poll();
|
|
19
|
+
this.pollTimer = setInterval(() => this.poll(), 10_000);
|
|
20
|
+
}
|
|
21
|
+
pause() {
|
|
22
|
+
if (this.pollTimer) {
|
|
23
|
+
clearInterval(this.pollTimer);
|
|
24
|
+
this.pollTimer = null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
getBranches() {
|
|
28
|
+
const result = {};
|
|
29
|
+
for (const [id, branch] of this.branches) {
|
|
30
|
+
result[id] = branch;
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
destroy() {
|
|
35
|
+
this.destroyed = true;
|
|
36
|
+
if (this.pollTimer) {
|
|
37
|
+
clearInterval(this.pollTimer);
|
|
38
|
+
this.pollTimer = null;
|
|
39
|
+
}
|
|
40
|
+
this.branches.clear();
|
|
41
|
+
}
|
|
42
|
+
async poll() {
|
|
43
|
+
if (this.destroyed || this.polling)
|
|
44
|
+
return;
|
|
45
|
+
this.polling = true;
|
|
46
|
+
try {
|
|
47
|
+
await this.doPoll();
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
this.polling = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async doPoll() {
|
|
54
|
+
if (this.destroyed)
|
|
55
|
+
return;
|
|
56
|
+
const projects = this.projectStore.list();
|
|
57
|
+
const results = await Promise.all(projects.map((p) => this.getBranch(p.cwd).then((branch) => [p.id, branch])));
|
|
58
|
+
if (this.destroyed)
|
|
59
|
+
return;
|
|
60
|
+
const newBranches = new Map();
|
|
61
|
+
for (const [id, branch] of results) {
|
|
62
|
+
newBranches.set(id, branch);
|
|
63
|
+
}
|
|
64
|
+
// Check if changed
|
|
65
|
+
if (this.mapsEqual(newBranches))
|
|
66
|
+
return;
|
|
67
|
+
this.branches = newBranches;
|
|
68
|
+
this.notify();
|
|
69
|
+
}
|
|
70
|
+
getBranch(cwd) {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
execFile('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeout: 2000 }, (err, stdout) => {
|
|
73
|
+
if (err) {
|
|
74
|
+
resolve(null);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const branch = stdout.trim();
|
|
78
|
+
resolve(branch || null);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
mapsEqual(other) {
|
|
83
|
+
if (other.size !== this.branches.size)
|
|
84
|
+
return false;
|
|
85
|
+
for (const [key, val] of other) {
|
|
86
|
+
if (this.branches.get(key) !== val)
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
notify() {
|
|
92
|
+
const result = {};
|
|
93
|
+
for (const [id, branch] of this.branches) {
|
|
94
|
+
result[id] = branch;
|
|
95
|
+
}
|
|
96
|
+
this.onChange(result);
|
|
97
|
+
}
|
|
98
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -228,6 +228,31 @@ async function startServer(devMode, port) {
|
|
|
228
228
|
const killed = ptyManager.killProject(req.params.id);
|
|
229
229
|
res.json({ killed: killed.length });
|
|
230
230
|
});
|
|
231
|
+
function getStaleProjects() {
|
|
232
|
+
const stale = [];
|
|
233
|
+
for (const project of projectStore.list()) {
|
|
234
|
+
try {
|
|
235
|
+
const stat = fs.statSync(project.cwd);
|
|
236
|
+
if (!stat.isDirectory())
|
|
237
|
+
throw new Error('not a directory');
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
stale.push({ id: project.id, name: project.name, cwd: project.cwd });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return stale;
|
|
244
|
+
}
|
|
245
|
+
app.get('/api/cleanup-projects', (_req, res) => {
|
|
246
|
+
res.json({ stale: getStaleProjects() });
|
|
247
|
+
});
|
|
248
|
+
app.post('/api/cleanup-projects', (_req, res) => {
|
|
249
|
+
const stale = getStaleProjects();
|
|
250
|
+
for (const project of stale) {
|
|
251
|
+
ptyManager.killProject(project.id);
|
|
252
|
+
projectStore.remove(project.id);
|
|
253
|
+
}
|
|
254
|
+
res.json({ removed: stale.map((p) => p.name) });
|
|
255
|
+
});
|
|
231
256
|
app.post('/api/validate-path', (req, res) => {
|
|
232
257
|
const { path: rawPath } = req.body;
|
|
233
258
|
if (!rawPath) {
|
|
@@ -243,71 +268,6 @@ async function startServer(devMode, port) {
|
|
|
243
268
|
res.json({ valid: false });
|
|
244
269
|
}
|
|
245
270
|
});
|
|
246
|
-
// Active editor detection — single AppleScript gets frontmost app + window title
|
|
247
|
-
const editorPatterns = ['cursor', 'code', 'vscode', 'visual studio code', 'zed', 'windsurf', 'electron'];
|
|
248
|
-
let editorCache = { projectName: null };
|
|
249
|
-
const editorScript = `
|
|
250
|
-
tell application "System Events"
|
|
251
|
-
set frontApp to name of first application process whose frontmost is true
|
|
252
|
-
set winTitle to ""
|
|
253
|
-
tell process frontApp
|
|
254
|
-
if exists front window then
|
|
255
|
-
set winTitle to name of front window
|
|
256
|
-
end if
|
|
257
|
-
end tell
|
|
258
|
-
return frontApp & linefeed & winTitle
|
|
259
|
-
end tell
|
|
260
|
-
`;
|
|
261
|
-
function pollActiveEditor() {
|
|
262
|
-
if (process.platform !== 'darwin')
|
|
263
|
-
return;
|
|
264
|
-
execFile('osascript', ['-e', editorScript], { timeout: 2000 }, (err, stdout, stderr) => {
|
|
265
|
-
if (err) {
|
|
266
|
-
const needsAccess = stderr?.includes('not allowed assistive access') || stderr?.includes('1719');
|
|
267
|
-
editorCache = { projectName: null, needsAccessibility: needsAccess || undefined };
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
const lines = stdout.trim().split('\n');
|
|
271
|
-
const appName = (lines[0] || '').trim();
|
|
272
|
-
const title = (lines[1] || '').trim();
|
|
273
|
-
const isEditor = editorPatterns.some((pat) => appName.toLowerCase().includes(pat));
|
|
274
|
-
if (!isEditor || !title) {
|
|
275
|
-
editorCache = { projectName: null };
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
let projectName = null;
|
|
279
|
-
// Try to extract a path from the title (e.g. "~/Documents/source/foo - branch")
|
|
280
|
-
const pathMatch = title.match(/^(~?\/[^\s]+)/);
|
|
281
|
-
if (pathMatch) {
|
|
282
|
-
const segments = pathMatch[1].replace(/\/$/, '').split('/');
|
|
283
|
-
projectName = segments[segments.length - 1] || null;
|
|
284
|
-
}
|
|
285
|
-
// Fallback: default title format "file — project — Editor" or "project — Editor"
|
|
286
|
-
if (!projectName) {
|
|
287
|
-
const parts = title.split(' \u2014 ');
|
|
288
|
-
if (parts.length >= 3) {
|
|
289
|
-
projectName = parts[parts.length - 2];
|
|
290
|
-
}
|
|
291
|
-
else if (parts.length === 2) {
|
|
292
|
-
projectName = parts[0];
|
|
293
|
-
}
|
|
294
|
-
else {
|
|
295
|
-
projectName = title;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
const prev = editorCache.projectName;
|
|
299
|
-
editorCache = { projectName };
|
|
300
|
-
if (projectName && projectName !== prev) {
|
|
301
|
-
wsHandler.send({ type: 'editor:active', projectName });
|
|
302
|
-
}
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
// Poll every 500ms — single osascript call is fast
|
|
306
|
-
pollActiveEditor();
|
|
307
|
-
setInterval(pollActiveEditor, 500);
|
|
308
|
-
app.get('/api/active-editor', (_req, res) => {
|
|
309
|
-
res.json(editorCache);
|
|
310
|
-
});
|
|
311
271
|
// Resolve a dropped file's full path using OS file index (Spotlight on macOS)
|
|
312
272
|
app.post('/api/resolve-path', (req, res) => {
|
|
313
273
|
const { name, size, lastModified } = req.body;
|
|
@@ -390,6 +350,9 @@ async function startServer(devMode, port) {
|
|
|
390
350
|
const server = http.createServer(app);
|
|
391
351
|
// WebSocket handler
|
|
392
352
|
const wsHandler = new WsHandler(server, ptyManager, projectStore, { onIdle: () => shutdown() });
|
|
353
|
+
app.get('/api/active-editor', (_req, res) => {
|
|
354
|
+
res.json(wsHandler.getEditorState());
|
|
355
|
+
});
|
|
393
356
|
// IPC listener
|
|
394
357
|
const ipcServer = startIpcListener(socketPath(), ptyManager, projectStore, wsHandler);
|
|
395
358
|
server.on('error', (err) => {
|