paneful 0.1.0
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 +93 -0
- package/dist/server/browser.js +61 -0
- package/dist/server/index.js +297 -0
- package/dist/server/ipc.js +96 -0
- package/dist/server/project-store.js +80 -0
- package/dist/server/pty-manager.js +75 -0
- package/dist/server/ws-handler.js +103 -0
- package/dist/web/assets/index-9zLlqJdm.js +319 -0
- package/dist/web/assets/index-AkfqQMma.js +319 -0
- package/dist/web/assets/index-B0I50Zha.css +32 -0
- package/dist/web/assets/index-BAI_7nK5.js +319 -0
- package/dist/web/assets/index-BB4t5H5c.js +320 -0
- package/dist/web/assets/index-BMmso-J4.js +319 -0
- package/dist/web/assets/index-COBDvRbb.css +32 -0
- package/dist/web/assets/index-CXt9iydD.js +318 -0
- package/dist/web/assets/index-Cmg2EmLe.css +32 -0
- package/dist/web/assets/index-CwfjFhaX.js +319 -0
- package/dist/web/assets/index-D4OYMkeO.js +319 -0
- package/dist/web/assets/index-DW2crjn_.js +320 -0
- package/dist/web/assets/index-ght3d7Gp.js +319 -0
- package/dist/web/assets/index-pmRkCEqP.js +319 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/index.html +17 -0
- package/dist/web/manifest.json +21 -0
- package/package.json +60 -0
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Paneful
|
|
2
|
+
|
|
3
|
+
A browser-based terminal multiplexer. Tmux-style pane management, project workspaces, and a CLI that spawns into a running instance.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i -g paneful
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or run without installing:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx paneful
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Start the server
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
paneful # Start server and open browser
|
|
23
|
+
paneful --port 8080 # Use a specific port
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Spawn projects from anywhere
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
cd ~/my-project
|
|
30
|
+
paneful --spawn # Adds project to running instance
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Manage projects
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
paneful --list # List all projects
|
|
37
|
+
paneful --kill my-project # Kill a project by name
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Keyboard Shortcuts
|
|
41
|
+
|
|
42
|
+
| Shortcut | Action |
|
|
43
|
+
| ----------------- | ------------------------------- |
|
|
44
|
+
| `Cmd+N` | New pane (vertical split) |
|
|
45
|
+
| `Cmd+Shift+N` | New pane (horizontal split) |
|
|
46
|
+
| `Cmd+W` | Close focused pane |
|
|
47
|
+
| `Cmd+1-9` | Focus pane by index |
|
|
48
|
+
| `Cmd+Arrow` | Move focus to adjacent pane |
|
|
49
|
+
| `Cmd+Shift+Arrow` | Swap focused pane with adjacent |
|
|
50
|
+
| `Cmd+D` | Toggle sidebar |
|
|
51
|
+
| `Cmd+T` | Cycle through layout presets |
|
|
52
|
+
| `Cmd+R` | Auto reorganize panes |
|
|
53
|
+
|
|
54
|
+
## Layout Presets
|
|
55
|
+
|
|
56
|
+
- **Columns** — side by side, equal widths
|
|
57
|
+
- **Rows** — stacked, equal heights
|
|
58
|
+
- **Main + Stack** — 60% left, rest stacked right
|
|
59
|
+
- **Main + Row** — 60% top, rest side by side bottom
|
|
60
|
+
- **Grid** — approximate square grid
|
|
61
|
+
|
|
62
|
+
## Development
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm install && cd web && pnpm install && cd ..
|
|
66
|
+
|
|
67
|
+
# Dev server (Vite frontend + Node.js backend, hot reload)
|
|
68
|
+
npm run dev
|
|
69
|
+
|
|
70
|
+
# Production build
|
|
71
|
+
npm run build
|
|
72
|
+
|
|
73
|
+
# Run locally
|
|
74
|
+
npm start
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
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:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --app=http://localhost:5173
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Architecture
|
|
84
|
+
|
|
85
|
+
- **Backend**: Node.js (Express + node-pty + ws)
|
|
86
|
+
- **Frontend**: React + TypeScript + xterm.js + Zustand + Tailwind CSS
|
|
87
|
+
- **Protocol**: JSON over a single WebSocket connection
|
|
88
|
+
- **Distribution**: npm package (`npx paneful`)
|
|
89
|
+
|
|
90
|
+
## Requirements
|
|
91
|
+
|
|
92
|
+
- Node.js 18+
|
|
93
|
+
- macOS or Linux
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
export function openBrowser(port) {
|
|
4
|
+
const url = `http://localhost:${port}`;
|
|
5
|
+
console.log(`Opening browser at ${url}`);
|
|
6
|
+
if (tryChromeAppMode(url))
|
|
7
|
+
return;
|
|
8
|
+
// Fallback: open normally
|
|
9
|
+
console.log('No Chromium-based browser found for app mode, falling back to default browser');
|
|
10
|
+
import('open').then(({ default: open }) => {
|
|
11
|
+
open(url).catch((e) => {
|
|
12
|
+
console.warn('Failed to open browser:', e.message);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
console.error("Tip: Install as a PWA (browser menu > 'Install Paneful') for full keyboard shortcut support");
|
|
16
|
+
}
|
|
17
|
+
function tryChromeAppMode(url) {
|
|
18
|
+
const appArg = `--app=${url}`;
|
|
19
|
+
if (process.platform === 'darwin') {
|
|
20
|
+
const browsers = [
|
|
21
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
22
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
23
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
24
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
25
|
+
'/Applications/Arc.app/Contents/MacOS/Arc',
|
|
26
|
+
];
|
|
27
|
+
for (const browser of browsers) {
|
|
28
|
+
if (fs.existsSync(browser)) {
|
|
29
|
+
execFile(browser, [appArg], (err) => {
|
|
30
|
+
if (err)
|
|
31
|
+
console.warn(`Failed to launch ${browser}:`, err.message);
|
|
32
|
+
});
|
|
33
|
+
console.log(`Opened in app mode via ${browser}`);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else if (process.platform === 'linux') {
|
|
39
|
+
const browsers = [
|
|
40
|
+
'google-chrome',
|
|
41
|
+
'google-chrome-stable',
|
|
42
|
+
'chromium',
|
|
43
|
+
'chromium-browser',
|
|
44
|
+
'microsoft-edge',
|
|
45
|
+
'brave-browser',
|
|
46
|
+
];
|
|
47
|
+
for (const browser of browsers) {
|
|
48
|
+
try {
|
|
49
|
+
execFile(browser, [appArg], (err) => {
|
|
50
|
+
if (err) { /* browser not found or failed */ }
|
|
51
|
+
});
|
|
52
|
+
console.log(`Opened in app mode via ${browser}`);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from 'commander';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import express from 'express';
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
10
|
+
import { PtyManager } from './pty-manager.js';
|
|
11
|
+
import { ProjectStore } from './project-store.js';
|
|
12
|
+
import { WsHandler } from './ws-handler.js';
|
|
13
|
+
import { startIpcListener, sendIpcCommand } from './ipc.js';
|
|
14
|
+
import { openBrowser } from './browser.js';
|
|
15
|
+
// ── Paths ──
|
|
16
|
+
function dataDir() {
|
|
17
|
+
const dir = path.join(os.homedir(), '.paneful');
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
function lockfilePath() {
|
|
22
|
+
return path.join(dataDir(), 'paneful.lock');
|
|
23
|
+
}
|
|
24
|
+
function socketPath() {
|
|
25
|
+
return path.join(dataDir(), 'paneful.sock');
|
|
26
|
+
}
|
|
27
|
+
function readLockfile() {
|
|
28
|
+
const p = lockfilePath();
|
|
29
|
+
if (!fs.existsSync(p))
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
const content = fs.readFileSync(p, 'utf-8');
|
|
33
|
+
const lines = content.trim().split('\n');
|
|
34
|
+
if (lines.length < 2)
|
|
35
|
+
return null;
|
|
36
|
+
const pid = parseInt(lines[0], 10);
|
|
37
|
+
const port = parseInt(lines[1], 10);
|
|
38
|
+
if (isNaN(pid) || isNaN(port))
|
|
39
|
+
return null;
|
|
40
|
+
return { pid, port };
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function writeLockfile(pid, port) {
|
|
47
|
+
fs.writeFileSync(lockfilePath(), `${pid}\n${port}`);
|
|
48
|
+
}
|
|
49
|
+
function removeLockfile() {
|
|
50
|
+
try {
|
|
51
|
+
fs.unlinkSync(lockfilePath());
|
|
52
|
+
}
|
|
53
|
+
catch { /* ok */ }
|
|
54
|
+
try {
|
|
55
|
+
fs.unlinkSync(socketPath());
|
|
56
|
+
}
|
|
57
|
+
catch { /* ok */ }
|
|
58
|
+
}
|
|
59
|
+
function isProcessAlive(pid) {
|
|
60
|
+
try {
|
|
61
|
+
process.kill(pid, 0);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// ── CLI handlers ──
|
|
69
|
+
async function handleSpawn() {
|
|
70
|
+
const cwd = process.cwd();
|
|
71
|
+
const name = path.basename(cwd) || 'project';
|
|
72
|
+
const lock = readLockfile();
|
|
73
|
+
if (lock && isProcessAlive(lock.pid)) {
|
|
74
|
+
try {
|
|
75
|
+
const resp = await sendIpcCommand(socketPath(), { command: 'spawn', cwd, name });
|
|
76
|
+
if (resp.status === 'ok') {
|
|
77
|
+
console.log(`Project '${name}' spawned in paneful`);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
console.error('Error:', resp.message);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
console.error('Failed to connect:', e.message);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
console.error('Paneful is not running. Start it with: paneful');
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function handleList() {
|
|
95
|
+
const lock = readLockfile();
|
|
96
|
+
if (lock && isProcessAlive(lock.pid)) {
|
|
97
|
+
try {
|
|
98
|
+
const resp = await sendIpcCommand(socketPath(), { command: 'list' });
|
|
99
|
+
if (resp.status === 'ok') {
|
|
100
|
+
const data = resp.data;
|
|
101
|
+
if (data && data.length > 0) {
|
|
102
|
+
console.log(data);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
console.log('No projects');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
console.error('Error:', resp.message);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
console.error('Failed to connect:', e.message);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
console.log('Paneful is not running');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function handleKill(name) {
|
|
123
|
+
const lock = readLockfile();
|
|
124
|
+
if (lock && isProcessAlive(lock.pid)) {
|
|
125
|
+
try {
|
|
126
|
+
const resp = await sendIpcCommand(socketPath(), { command: 'kill', name });
|
|
127
|
+
if (resp.status === 'ok') {
|
|
128
|
+
console.log(`Project '${name}' killed`);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
console.error('Error:', resp.message);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
console.error('Failed to connect:', e.message);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
console.error('Paneful is not running');
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// ── Server ──
|
|
146
|
+
function startServer(devMode, port) {
|
|
147
|
+
const app = express();
|
|
148
|
+
app.use(express.json());
|
|
149
|
+
const ptyManager = new PtyManager();
|
|
150
|
+
const projectStore = new ProjectStore(dataDir());
|
|
151
|
+
// API routes
|
|
152
|
+
app.get('/api/projects', (_req, res) => {
|
|
153
|
+
res.json(projectStore.list());
|
|
154
|
+
});
|
|
155
|
+
app.post('/api/projects', (req, res) => {
|
|
156
|
+
const { id, name = 'Unnamed', cwd = '/' } = req.body;
|
|
157
|
+
const projectId = id || uuidv4();
|
|
158
|
+
const project = { id: projectId, name, cwd, terminal_ids: [] };
|
|
159
|
+
projectStore.create(project);
|
|
160
|
+
res.status(201).json(project);
|
|
161
|
+
});
|
|
162
|
+
app.delete('/api/projects/:id', (req, res) => {
|
|
163
|
+
ptyManager.killProject(req.params.id);
|
|
164
|
+
projectStore.remove(req.params.id);
|
|
165
|
+
res.status(204).end();
|
|
166
|
+
});
|
|
167
|
+
app.post('/api/projects/:id/kill', (req, res) => {
|
|
168
|
+
const killed = ptyManager.killProject(req.params.id);
|
|
169
|
+
res.json({ killed: killed.length });
|
|
170
|
+
});
|
|
171
|
+
// Resolve a dropped file's full path using OS file index (Spotlight on macOS)
|
|
172
|
+
app.post('/api/resolve-path', (req, res) => {
|
|
173
|
+
const { name, size, lastModified } = req.body;
|
|
174
|
+
if (!name) {
|
|
175
|
+
res.status(400).json({ error: 'name required' });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const findBest = (candidates) => {
|
|
179
|
+
if (candidates.length === 0)
|
|
180
|
+
return null;
|
|
181
|
+
if (candidates.length === 1)
|
|
182
|
+
return candidates[0];
|
|
183
|
+
// Score candidates: exact size + mtime match wins, then size-only, then most recent
|
|
184
|
+
let best = null;
|
|
185
|
+
for (const candidate of candidates) {
|
|
186
|
+
try {
|
|
187
|
+
const stat = fs.statSync(candidate);
|
|
188
|
+
let score = 0;
|
|
189
|
+
if (size && stat.size === size)
|
|
190
|
+
score += 10;
|
|
191
|
+
if (lastModified && Math.abs(stat.mtimeMs - lastModified) < 2000)
|
|
192
|
+
score += 5;
|
|
193
|
+
// Exclude node_modules and hidden dirs to prefer "real" files
|
|
194
|
+
if (!candidate.includes('node_modules') && !candidate.includes('/.'))
|
|
195
|
+
score += 1;
|
|
196
|
+
if (!best || score > best.score || (score === best.score && stat.mtimeMs > best.mtime)) {
|
|
197
|
+
best = { path: candidate, score, mtime: stat.mtimeMs };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch { /* skip inaccessible */ }
|
|
201
|
+
}
|
|
202
|
+
return best?.path ?? candidates[0];
|
|
203
|
+
};
|
|
204
|
+
if (process.platform === 'darwin') {
|
|
205
|
+
execFile('mdfind', [`kMDItemFSName == '${name.replace(/'/g, "\\'")}'`], (err, stdout) => {
|
|
206
|
+
if (err) {
|
|
207
|
+
res.json({ path: null });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const candidates = stdout.trim().split('\n').filter(Boolean);
|
|
211
|
+
res.json({ path: findBest(candidates) });
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
execFile('locate', ['-l', '20', '-b', `\\${name}`], (err, stdout) => {
|
|
216
|
+
if (err) {
|
|
217
|
+
res.json({ path: null });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const candidates = stdout.trim().split('\n').filter(Boolean);
|
|
221
|
+
res.json({ path: findBest(candidates) });
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
// Serve static frontend (production only)
|
|
226
|
+
if (!devMode) {
|
|
227
|
+
// In production dist: dist/server/index.js -> dist/web/ is sibling
|
|
228
|
+
const webDir = path.resolve(import.meta.dirname, '..', 'web');
|
|
229
|
+
if (fs.existsSync(webDir)) {
|
|
230
|
+
app.use(express.static(webDir));
|
|
231
|
+
// SPA fallback
|
|
232
|
+
app.get('*', (_req, res) => {
|
|
233
|
+
res.sendFile(path.join(webDir, 'index.html'));
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const server = http.createServer(app);
|
|
238
|
+
// WebSocket handler
|
|
239
|
+
const wsHandler = new WsHandler(server, ptyManager, projectStore);
|
|
240
|
+
// IPC listener
|
|
241
|
+
const ipcServer = startIpcListener(socketPath(), ptyManager, projectStore, wsHandler);
|
|
242
|
+
server.listen(port, '127.0.0.1', () => {
|
|
243
|
+
const addr = server.address();
|
|
244
|
+
const actualPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
245
|
+
writeLockfile(process.pid, actualPort);
|
|
246
|
+
console.log(`Paneful running on http://localhost:${actualPort}`);
|
|
247
|
+
if (!devMode) {
|
|
248
|
+
openBrowser(actualPort);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
// Graceful shutdown
|
|
252
|
+
const shutdown = () => {
|
|
253
|
+
console.log('Shutting down...');
|
|
254
|
+
ptyManager.killAll();
|
|
255
|
+
removeLockfile();
|
|
256
|
+
ipcServer.close();
|
|
257
|
+
server.close();
|
|
258
|
+
process.exit(0);
|
|
259
|
+
};
|
|
260
|
+
process.on('SIGINT', shutdown);
|
|
261
|
+
process.on('SIGTERM', shutdown);
|
|
262
|
+
}
|
|
263
|
+
// ── CLI ──
|
|
264
|
+
program
|
|
265
|
+
.name('paneful')
|
|
266
|
+
.description('Browser-based terminal multiplexer')
|
|
267
|
+
.option('--spawn', 'Spawn a new project in the current directory')
|
|
268
|
+
.option('--list', 'List all projects')
|
|
269
|
+
.option('--kill <name>', 'Kill a project by name')
|
|
270
|
+
.option('--dev', 'Run in development mode (proxy to Vite dev server)')
|
|
271
|
+
.option('--port <number>', 'Port to listen on (default: random available)', parseInt)
|
|
272
|
+
.action(async (opts) => {
|
|
273
|
+
if (opts.list) {
|
|
274
|
+
await handleList();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (opts.kill) {
|
|
278
|
+
await handleKill(opts.kill);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (opts.spawn) {
|
|
282
|
+
await handleSpawn();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// Default: start server (or open browser if already running)
|
|
286
|
+
const lock = readLockfile();
|
|
287
|
+
if (lock && isProcessAlive(lock.pid)) {
|
|
288
|
+
console.log(`Paneful already running on port ${lock.port}`);
|
|
289
|
+
openBrowser(lock.port);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (lock) {
|
|
293
|
+
removeLockfile();
|
|
294
|
+
}
|
|
295
|
+
startServer(opts.dev || false, opts.port || 0);
|
|
296
|
+
});
|
|
297
|
+
program.parse();
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
4
|
+
import { newProject } from './project-store.js';
|
|
5
|
+
export function startIpcListener(socketPath, ptyManager, projectStore, wsHandler) {
|
|
6
|
+
// Remove stale socket file
|
|
7
|
+
try {
|
|
8
|
+
fs.unlinkSync(socketPath);
|
|
9
|
+
}
|
|
10
|
+
catch { /* doesn't exist */ }
|
|
11
|
+
const server = net.createServer((conn) => {
|
|
12
|
+
let buffer = '';
|
|
13
|
+
conn.on('data', (chunk) => {
|
|
14
|
+
buffer += chunk.toString();
|
|
15
|
+
const newlineIdx = buffer.indexOf('\n');
|
|
16
|
+
if (newlineIdx === -1)
|
|
17
|
+
return;
|
|
18
|
+
const line = buffer.slice(0, newlineIdx).trim();
|
|
19
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
20
|
+
let request;
|
|
21
|
+
try {
|
|
22
|
+
request = JSON.parse(line);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
const resp = { status: 'error', message: 'Invalid request' };
|
|
26
|
+
conn.write(JSON.stringify(resp) + '\n');
|
|
27
|
+
conn.end();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const response = handleIpcRequest(request, ptyManager, projectStore, wsHandler);
|
|
31
|
+
conn.write(JSON.stringify(response) + '\n');
|
|
32
|
+
conn.end();
|
|
33
|
+
});
|
|
34
|
+
conn.on('error', () => { });
|
|
35
|
+
});
|
|
36
|
+
server.listen(socketPath, () => {
|
|
37
|
+
console.log(`IPC listener started at ${socketPath}`);
|
|
38
|
+
});
|
|
39
|
+
return server;
|
|
40
|
+
}
|
|
41
|
+
function handleIpcRequest(request, ptyManager, projectStore, wsHandler) {
|
|
42
|
+
switch (request.command) {
|
|
43
|
+
case 'spawn': {
|
|
44
|
+
const id = uuidv4();
|
|
45
|
+
const project = newProject(id, request.name, request.cwd);
|
|
46
|
+
projectStore.create(project);
|
|
47
|
+
// Notify the frontend
|
|
48
|
+
wsHandler.send({
|
|
49
|
+
type: 'project:spawned',
|
|
50
|
+
projectId: id,
|
|
51
|
+
name: request.name,
|
|
52
|
+
cwd: request.cwd,
|
|
53
|
+
});
|
|
54
|
+
return { status: 'ok' };
|
|
55
|
+
}
|
|
56
|
+
case 'list': {
|
|
57
|
+
const projects = projectStore.list();
|
|
58
|
+
const lines = projects.map((p) => `${p.name} (${p.cwd}) - ${p.terminal_ids.length} terminals`);
|
|
59
|
+
return { status: 'ok', data: lines.join('\n') };
|
|
60
|
+
}
|
|
61
|
+
case 'kill': {
|
|
62
|
+
const project = projectStore.findByName(request.name);
|
|
63
|
+
if (project) {
|
|
64
|
+
ptyManager.killProject(project.id);
|
|
65
|
+
projectStore.remove(project.id);
|
|
66
|
+
return { status: 'ok' };
|
|
67
|
+
}
|
|
68
|
+
return { status: 'error', message: `Project '${request.name}' not found` };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export async function sendIpcCommand(socketPath, request) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const client = net.createConnection(socketPath, () => {
|
|
75
|
+
client.write(JSON.stringify(request) + '\n');
|
|
76
|
+
});
|
|
77
|
+
let buffer = '';
|
|
78
|
+
client.on('data', (chunk) => {
|
|
79
|
+
buffer += chunk.toString();
|
|
80
|
+
const newlineIdx = buffer.indexOf('\n');
|
|
81
|
+
if (newlineIdx !== -1) {
|
|
82
|
+
const line = buffer.slice(0, newlineIdx).trim();
|
|
83
|
+
try {
|
|
84
|
+
resolve(JSON.parse(line));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
reject(new Error('Invalid IPC response'));
|
|
88
|
+
}
|
|
89
|
+
client.end();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
client.on('error', (err) => {
|
|
93
|
+
reject(new Error(`Failed to connect to paneful: ${err.message}`));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export class ProjectStore {
|
|
4
|
+
projects;
|
|
5
|
+
filePath;
|
|
6
|
+
constructor(dataDir) {
|
|
7
|
+
this.filePath = path.join(dataDir, 'projects.json');
|
|
8
|
+
this.projects = new Map();
|
|
9
|
+
if (fs.existsSync(this.filePath)) {
|
|
10
|
+
try {
|
|
11
|
+
const contents = fs.readFileSync(this.filePath, 'utf-8');
|
|
12
|
+
const list = JSON.parse(contents);
|
|
13
|
+
for (const p of list) {
|
|
14
|
+
this.projects.set(p.id, p);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// Corrupted file, start fresh
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
create(project) {
|
|
23
|
+
this.projects.set(project.id, project);
|
|
24
|
+
this.persist();
|
|
25
|
+
}
|
|
26
|
+
remove(projectId) {
|
|
27
|
+
const project = this.projects.get(projectId);
|
|
28
|
+
if (project) {
|
|
29
|
+
this.projects.delete(projectId);
|
|
30
|
+
this.persist();
|
|
31
|
+
}
|
|
32
|
+
return project;
|
|
33
|
+
}
|
|
34
|
+
get(projectId) {
|
|
35
|
+
return this.projects.get(projectId);
|
|
36
|
+
}
|
|
37
|
+
list() {
|
|
38
|
+
return Array.from(this.projects.values());
|
|
39
|
+
}
|
|
40
|
+
findByName(name) {
|
|
41
|
+
for (const p of this.projects.values()) {
|
|
42
|
+
if (p.name === name)
|
|
43
|
+
return p;
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
addTerminal(projectId, terminalId) {
|
|
48
|
+
const project = this.projects.get(projectId);
|
|
49
|
+
if (project && !project.terminal_ids.includes(terminalId)) {
|
|
50
|
+
project.terminal_ids.push(terminalId);
|
|
51
|
+
this.persist();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
removeTerminal(projectId, terminalId) {
|
|
55
|
+
const project = this.projects.get(projectId);
|
|
56
|
+
if (project) {
|
|
57
|
+
project.terminal_ids = project.terminal_ids.filter(id => id !== terminalId);
|
|
58
|
+
this.persist();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
getTerminalIds(projectId) {
|
|
62
|
+
return this.projects.get(projectId)?.terminal_ids ?? [];
|
|
63
|
+
}
|
|
64
|
+
persist() {
|
|
65
|
+
const list = Array.from(this.projects.values());
|
|
66
|
+
try {
|
|
67
|
+
const dir = path.dirname(this.filePath);
|
|
68
|
+
if (!fs.existsSync(dir)) {
|
|
69
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
fs.writeFileSync(this.filePath, JSON.stringify(list, null, 2));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
console.error('Failed to persist projects');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function newProject(id, name, cwd) {
|
|
79
|
+
return { id, name, cwd, terminal_ids: [] };
|
|
80
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as pty from 'node-pty';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
export class PtyManager {
|
|
4
|
+
sessions = new Map();
|
|
5
|
+
spawn(terminalId, projectId, cwd, onOutput, onExit) {
|
|
6
|
+
const shell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : '/bin/bash');
|
|
7
|
+
// Filter out undefined values from process.env before spreading
|
|
8
|
+
const env = {};
|
|
9
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
10
|
+
if (v !== undefined)
|
|
11
|
+
env[k] = v;
|
|
12
|
+
}
|
|
13
|
+
env.TERM = 'xterm-256color';
|
|
14
|
+
env.LANG = 'en_US.UTF-8';
|
|
15
|
+
env.LC_ALL = 'en_US.UTF-8';
|
|
16
|
+
const proc = pty.spawn(shell, [], {
|
|
17
|
+
name: 'xterm-256color',
|
|
18
|
+
cols: 80,
|
|
19
|
+
rows: 24,
|
|
20
|
+
cwd,
|
|
21
|
+
env,
|
|
22
|
+
});
|
|
23
|
+
proc.onData((data) => {
|
|
24
|
+
onOutput(terminalId, data);
|
|
25
|
+
});
|
|
26
|
+
proc.onExit(({ exitCode }) => {
|
|
27
|
+
this.sessions.delete(terminalId);
|
|
28
|
+
onExit(terminalId, exitCode);
|
|
29
|
+
});
|
|
30
|
+
this.sessions.set(terminalId, { process: proc, projectId });
|
|
31
|
+
}
|
|
32
|
+
write(terminalId, data) {
|
|
33
|
+
const managed = this.sessions.get(terminalId);
|
|
34
|
+
if (managed) {
|
|
35
|
+
managed.process.write(data);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
resize(terminalId, cols, rows) {
|
|
39
|
+
const managed = this.sessions.get(terminalId);
|
|
40
|
+
if (managed) {
|
|
41
|
+
managed.process.resize(cols, rows);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
kill(terminalId) {
|
|
45
|
+
const managed = this.sessions.get(terminalId);
|
|
46
|
+
if (managed) {
|
|
47
|
+
managed.process.kill();
|
|
48
|
+
this.sessions.delete(terminalId);
|
|
49
|
+
return managed.projectId;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
killProject(projectId) {
|
|
54
|
+
const killed = [];
|
|
55
|
+
for (const [id, managed] of this.sessions) {
|
|
56
|
+
if (managed.projectId === projectId) {
|
|
57
|
+
managed.process.kill();
|
|
58
|
+
killed.push(id);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const id of killed) {
|
|
62
|
+
this.sessions.delete(id);
|
|
63
|
+
}
|
|
64
|
+
return killed;
|
|
65
|
+
}
|
|
66
|
+
killAll() {
|
|
67
|
+
for (const [, managed] of this.sessions) {
|
|
68
|
+
managed.process.kill();
|
|
69
|
+
}
|
|
70
|
+
this.sessions.clear();
|
|
71
|
+
}
|
|
72
|
+
terminalExists(terminalId) {
|
|
73
|
+
return this.sessions.has(terminalId);
|
|
74
|
+
}
|
|
75
|
+
}
|