paneful 0.6.0 → 0.6.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 CHANGED
@@ -37,6 +37,40 @@ paneful --list # List all projects
37
37
  paneful --kill my-project # Kill a project by name
38
38
  ```
39
39
 
40
+ ## Features
41
+
42
+ ### Drag & Drop Projects
43
+
44
+ Drag a folder from Finder into the sidebar to create a new project with the path pre-filled.
45
+
46
+ ### Drag Files into Terminals
47
+
48
+ Drag files from Finder or your editor (VS Code, Cursor) into a terminal pane to paste their paths as shell-escaped arguments.
49
+
50
+ ### Favourites
51
+
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. Managed from the star icon in the toolbar and the favourites section in the sidebar.
53
+
54
+ ### Editor Sync
55
+
56
+ Automatically switches the active project based on which editor window is in focus. Works with VS Code, Cursor, Zed, and Windsurf on macOS. Toggle via the monitor icon in the sidebar header.
57
+
58
+ Requires:
59
+ 1. Terminal app added to **System Settings > Privacy & Security > Accessibility**
60
+ 2. Editor window title includes the folder name (default in VS Code/Cursor)
61
+
62
+ ### Resizable Sidebar
63
+
64
+ Drag the right edge of the sidebar to resize it. Width persists across sessions.
65
+
66
+ ### Auto-Reorganize
67
+
68
+ Press `Cmd+R` or click the dashboard icon in the toolbar to automatically pick the best layout for your current pane count.
69
+
70
+ ### Update Notifications
71
+
72
+ Paneful checks for newer versions on npm and shows a notification in the sidebar when an update is available.
73
+
40
74
  ## Keyboard Shortcuts
41
75
 
42
76
  | Shortcut | Action |
@@ -219,79 +219,65 @@ function startServer(devMode, port) {
219
219
  res.json({ valid: false });
220
220
  }
221
221
  });
222
- // Active editor detection — polls server-side so the client gets an instant cached response
222
+ // Active editor detection — single AppleScript gets frontmost app + window title
223
223
  const editorPatterns = ['cursor', 'code', 'vscode', 'visual studio code', 'zed', 'windsurf'];
224
224
  let editorCache = { projectName: null };
225
+ const editorScript = `
226
+ tell application "System Events"
227
+ set frontApp to name of first application process whose frontmost is true
228
+ set winTitle to ""
229
+ tell process frontApp
230
+ if exists front window then
231
+ set winTitle to name of front window
232
+ end if
233
+ end tell
234
+ return frontApp & linefeed & winTitle
235
+ end tell
236
+ `;
225
237
  function pollActiveEditor() {
226
238
  if (process.platform !== 'darwin')
227
239
  return;
228
- const findScript = `
229
- tell application "System Events"
230
- set procNames to name of every process whose background only is false
231
- set output to ""
232
- repeat with p in procNames
233
- set output to output & p & linefeed
234
- end repeat
235
- return output
236
- end tell
237
- `;
238
- execFile('osascript', ['-e', findScript], { timeout: 2000 }, (err, stdout, stderr) => {
240
+ execFile('osascript', ['-e', editorScript], { timeout: 2000 }, (err, stdout, stderr) => {
239
241
  if (err) {
240
242
  const needsAccess = stderr?.includes('not allowed assistive access') || stderr?.includes('1719');
241
243
  editorCache = { projectName: null, needsAccessibility: needsAccess || undefined };
242
244
  return;
243
245
  }
244
- const processes = stdout.trim().split('\n').map((p) => p.trim()).filter(Boolean);
245
- const editorProcess = processes.find((p) => editorPatterns.some((pat) => p.toLowerCase().includes(pat)));
246
- if (!editorProcess) {
246
+ const lines = stdout.trim().split('\n');
247
+ const appName = (lines[0] || '').trim();
248
+ const title = (lines[1] || '').trim();
249
+ const isEditor = editorPatterns.some((pat) => appName.toLowerCase().includes(pat));
250
+ if (!isEditor || !title) {
247
251
  editorCache = { projectName: null };
248
252
  return;
249
253
  }
250
- const titleScript = `
251
- tell application "System Events"
252
- tell process "${editorProcess.replace(/"/g, '\\"')}"
253
- if exists front window then
254
- return name of front window
255
- end if
256
- end tell
257
- end tell
258
- return ""
259
- `;
260
- execFile('osascript', ['-e', titleScript], { timeout: 2000 }, (err2, stdout2) => {
261
- if (err2 || !stdout2.trim()) {
262
- editorCache = { projectName: null };
263
- return;
264
- }
265
- const title = stdout2.trim();
266
- let projectName = null;
267
- // Try to extract a path from the title (e.g. "~/Documents/source/foo - branch")
268
- const pathMatch = title.match(/^(~?\/[^\s]+)/);
269
- if (pathMatch) {
270
- const segments = pathMatch[1].replace(/\/$/, '').split('/');
271
- projectName = segments[segments.length - 1] || null;
272
- }
273
- // Fallback: default title format "file — project — Editor" or "project — Editor"
274
- if (!projectName) {
275
- const parts = title.split(' \u2014 ');
276
- if (parts.length >= 3) {
277
- projectName = parts[parts.length - 2];
278
- }
279
- else if (parts.length === 2) {
280
- projectName = parts[0];
281
- }
254
+ let projectName = null;
255
+ // Try to extract a path from the title (e.g. "~/Documents/source/foo - branch")
256
+ const pathMatch = title.match(/^(~?\/[^\s]+)/);
257
+ if (pathMatch) {
258
+ const segments = pathMatch[1].replace(/\/$/, '').split('/');
259
+ projectName = segments[segments.length - 1] || null;
260
+ }
261
+ // Fallback: default title format "file — project — Editor" or "project — Editor"
262
+ if (!projectName) {
263
+ const parts = title.split(' \u2014 ');
264
+ if (parts.length >= 3) {
265
+ projectName = parts[parts.length - 2];
282
266
  }
283
- const prev = editorCache.projectName;
284
- editorCache = { projectName };
285
- // Push change to client over WebSocket
286
- if (projectName && projectName !== prev) {
287
- wsHandler.send({ type: 'editor:active', projectName });
267
+ else if (parts.length === 2) {
268
+ projectName = parts[0];
288
269
  }
289
- });
270
+ }
271
+ const prev = editorCache.projectName;
272
+ editorCache = { projectName };
273
+ if (projectName && projectName !== prev) {
274
+ wsHandler.send({ type: 'editor:active', projectName });
275
+ }
290
276
  });
291
277
  }
292
- // Poll every 2 seconds server-side
278
+ // Poll every 500ms single osascript call is fast
293
279
  pollActiveEditor();
294
- setInterval(pollActiveEditor, 2000);
280
+ setInterval(pollActiveEditor, 500);
295
281
  app.get('/api/active-editor', (_req, res) => {
296
282
  res.json(editorCache);
297
283
  });
@@ -41,10 +41,10 @@ export function startIpcListener(socketPath, ptyManager, projectStore, wsHandler
41
41
  function handleIpcRequest(request, ptyManager, projectStore, wsHandler) {
42
42
  switch (request.command) {
43
43
  case 'spawn': {
44
+ // Always send to frontend — it deduplicates by cwd
44
45
  const id = uuidv4();
45
46
  const project = newProject(id, request.name, request.cwd);
46
47
  projectStore.create(project);
47
- // Notify the frontend
48
48
  wsHandler.send({
49
49
  type: 'project:spawned',
50
50
  projectId: id,
@@ -44,6 +44,13 @@ export class ProjectStore {
44
44
  }
45
45
  return undefined;
46
46
  }
47
+ findByCwd(cwd) {
48
+ for (const p of this.projects.values()) {
49
+ if (p.cwd === cwd)
50
+ return p;
51
+ }
52
+ return undefined;
53
+ }
47
54
  addTerminal(projectId, terminalId) {
48
55
  const project = this.projects.get(projectId);
49
56
  if (project && !project.terminal_ids.includes(terminalId)) {