@bakapiano/ccsm 0.11.0 → 0.13.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/lib/atomicJson.js +48 -0
- package/lib/config.js +8 -5
- package/lib/folders.js +52 -43
- package/lib/jsonStore.js +15 -10
- package/lib/persistedSessions.js +42 -34
- package/lib/webTerminal.js +17 -1
- package/package.json +2 -2
- package/public/css/sidebar.css +20 -28
- package/public/css/widgets.css +32 -0
- package/public/js/api.js +12 -4
- package/public/js/components/EntityFormModal.js +37 -1
- package/public/js/components/Sidebar.js +78 -49
- package/public/js/components/TerminalView.js +16 -0
- package/public/js/main.js +5 -13
- package/public/js/pages/ConfigurePage.js +4 -4
- package/scripts/dev.js +59 -0
- package/server.js +109 -3
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Atomic JSON-file writes + per-file serialization.
|
|
4
|
+
//
|
|
5
|
+
// The naive pattern (`fs.writeFile(path, JSON.stringify(...))`) has two
|
|
6
|
+
// bugs under concurrent callers:
|
|
7
|
+
//
|
|
8
|
+
// 1. fs.writeFile overwrites byte-by-byte but does NOT pre-truncate.
|
|
9
|
+
// If writer A's serialization is longer than writer B's, and B
|
|
10
|
+
// finishes second, B writes only its own bytes — A's trailing
|
|
11
|
+
// bytes stay on disk. Result: `] }\n]` style JSON corruption.
|
|
12
|
+
//
|
|
13
|
+
// 2. Even with atomic writes, concurrent `load → mutate → save`
|
|
14
|
+
// sequences lose updates: A and B both read state v0, both write
|
|
15
|
+
// their own v1 — the later writer wins, the earlier one's edits
|
|
16
|
+
// vanish.
|
|
17
|
+
//
|
|
18
|
+
// Fixes:
|
|
19
|
+
//
|
|
20
|
+
// - atomicWriteJson: write to a sibling tmp file, then rename onto
|
|
21
|
+
// the target. rename is atomic on the same volume (NTFS / POSIX),
|
|
22
|
+
// so readers see either the old complete file or the new complete
|
|
23
|
+
// file. No truncation problem.
|
|
24
|
+
//
|
|
25
|
+
// - withFileLock: serialize all mutators of a given file through a
|
|
26
|
+
// per-path promise chain. Callers wrap their whole load/mutate/save
|
|
27
|
+
// in withFileLock(path, fn) and are guaranteed exclusivity.
|
|
28
|
+
|
|
29
|
+
const fs = require('node:fs/promises');
|
|
30
|
+
|
|
31
|
+
async function atomicWriteJson(filePath, data) {
|
|
32
|
+
const tmp = `${filePath}.tmp.${process.pid}.${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
33
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2));
|
|
34
|
+
await fs.rename(tmp, filePath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const locks = new Map();
|
|
38
|
+
function withFileLock(filePath, fn) {
|
|
39
|
+
const prev = locks.get(filePath) || Promise.resolve();
|
|
40
|
+
const next = prev.then(fn, fn);
|
|
41
|
+
// Swallow rejections in the chain holder so a single failed mutator
|
|
42
|
+
// doesn't poison every subsequent caller. The returned `next` still
|
|
43
|
+
// rejects for THIS caller — only the stored chain is sanitized.
|
|
44
|
+
locks.set(filePath, next.catch(() => {}));
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { atomicWriteJson, withFileLock };
|
package/lib/config.js
CHANGED
|
@@ -4,6 +4,7 @@ const fs = require('node:fs/promises');
|
|
|
4
4
|
const fsSync = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const os = require('node:os');
|
|
7
|
+
const { atomicWriteJson, withFileLock } = require('./atomicJson');
|
|
7
8
|
|
|
8
9
|
// Data dir lives under ~/.ccsm by default so config survives across upgrades
|
|
9
10
|
// (incl. running from a new npx checkout). Override with CCSM_HOME if you
|
|
@@ -159,7 +160,7 @@ async function loadConfig() {
|
|
|
159
160
|
} catch (e) {
|
|
160
161
|
if (e.code === 'ENOENT') {
|
|
161
162
|
const cfg = { ...DEFAULTS };
|
|
162
|
-
await
|
|
163
|
+
await atomicWriteJson(CONFIG_PATH, cfg);
|
|
163
164
|
return cfg;
|
|
164
165
|
}
|
|
165
166
|
throw e;
|
|
@@ -168,10 +169,12 @@ async function loadConfig() {
|
|
|
168
169
|
|
|
169
170
|
async function saveConfig(partial) {
|
|
170
171
|
ensureDataDir();
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
return withFileLock(CONFIG_PATH, async () => {
|
|
173
|
+
const current = await loadConfig();
|
|
174
|
+
const next = mergeWithDefaults({ ...current, ...partial });
|
|
175
|
+
await atomicWriteJson(CONFIG_PATH, next);
|
|
176
|
+
return next;
|
|
177
|
+
});
|
|
175
178
|
}
|
|
176
179
|
|
|
177
180
|
module.exports = {
|
package/lib/folders.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
const path = require('node:path');
|
|
13
13
|
const fs = require('node:fs/promises');
|
|
14
14
|
const { DATA_DIR } = require('./config');
|
|
15
|
+
const { atomicWriteJson, withFileLock } = require('./atomicJson');
|
|
15
16
|
|
|
16
17
|
const FILE = path.join(DATA_DIR, 'folders.json');
|
|
17
18
|
|
|
@@ -27,7 +28,7 @@ async function loadAll() {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
async function saveAll(list) {
|
|
30
|
-
await
|
|
31
|
+
await atomicWriteJson(FILE, list);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
function genId() {
|
|
@@ -36,61 +37,69 @@ function genId() {
|
|
|
36
37
|
|
|
37
38
|
async function create({ name }) {
|
|
38
39
|
if (!name || typeof name !== 'string') throw new Error('name required');
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
40
|
+
return withFileLock(FILE, async () => {
|
|
41
|
+
const list = await loadAll();
|
|
42
|
+
const entry = {
|
|
43
|
+
id: genId(),
|
|
44
|
+
name: name.trim(),
|
|
45
|
+
order: list.length,
|
|
46
|
+
createdAt: Date.now(),
|
|
47
|
+
};
|
|
48
|
+
list.push(entry);
|
|
49
|
+
await saveAll(list);
|
|
50
|
+
return entry;
|
|
51
|
+
});
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
async function update(id, patch) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
return withFileLock(FILE, async () => {
|
|
56
|
+
const list = await loadAll();
|
|
57
|
+
const idx = list.findIndex((f) => f.id === id);
|
|
58
|
+
if (idx < 0) return null;
|
|
59
|
+
// Allow rename + reorder, ignore other keys.
|
|
60
|
+
const allowed = {};
|
|
61
|
+
if (typeof patch.name === 'string') allowed.name = patch.name.trim();
|
|
62
|
+
if (typeof patch.order === 'number') allowed.order = patch.order;
|
|
63
|
+
list[idx] = { ...list[idx], ...allowed };
|
|
64
|
+
await saveAll(list);
|
|
65
|
+
return list[idx];
|
|
66
|
+
});
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
async function remove(id) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
return withFileLock(FILE, async () => {
|
|
71
|
+
const list = await loadAll();
|
|
72
|
+
const idx = list.findIndex((f) => f.id === id);
|
|
73
|
+
if (idx < 0) return false;
|
|
74
|
+
list.splice(idx, 1);
|
|
75
|
+
await saveAll(list);
|
|
76
|
+
return true;
|
|
77
|
+
});
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
async function reorder(idsInOrder) {
|
|
74
81
|
if (!Array.isArray(idsInOrder)) throw new Error('idsInOrder must be array');
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
f
|
|
82
|
+
return withFileLock(FILE, async () => {
|
|
83
|
+
const list = await loadAll();
|
|
84
|
+
const byId = new Map(list.map((f) => [f.id, f]));
|
|
85
|
+
const next = [];
|
|
86
|
+
idsInOrder.forEach((id, i) => {
|
|
87
|
+
const f = byId.get(id);
|
|
88
|
+
if (f) {
|
|
89
|
+
f.order = i;
|
|
90
|
+
next.push(f);
|
|
91
|
+
byId.delete(id);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
// Append any folders not mentioned in the new order, preserving original
|
|
95
|
+
// relative order. Prevents accidentally dropping folders.
|
|
96
|
+
for (const f of byId.values()) {
|
|
97
|
+
f.order = next.length;
|
|
82
98
|
next.push(f);
|
|
83
|
-
byId.delete(id);
|
|
84
99
|
}
|
|
100
|
+
await saveAll(next);
|
|
101
|
+
return next;
|
|
85
102
|
});
|
|
86
|
-
// Append any folders not mentioned in the new order, preserving original
|
|
87
|
-
// relative order. Prevents accidentally dropping folders.
|
|
88
|
-
for (const f of byId.values()) {
|
|
89
|
-
f.order = next.length;
|
|
90
|
-
next.push(f);
|
|
91
|
-
}
|
|
92
|
-
await saveAll(next);
|
|
93
|
-
return next;
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
module.exports = { loadAll, create, update, remove, reorder, FILE };
|
package/lib/jsonStore.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
const fs = require('node:fs/promises');
|
|
14
14
|
const path = require('node:path');
|
|
15
|
+
const { atomicWriteJson, withFileLock } = require('./atomicJson');
|
|
15
16
|
|
|
16
17
|
function createKeyedJsonStore({ dataDir, filename, transformValue = (v) => v }) {
|
|
17
18
|
const filePath = path.join(dataDir, filename);
|
|
@@ -28,25 +29,29 @@ function createKeyedJsonStore({ dataDir, filename, transformValue = (v) => v })
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
async function save(map) {
|
|
31
|
-
await
|
|
32
|
+
await atomicWriteJson(filePath, map);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
async function set(key, value) {
|
|
35
36
|
if (!key) throw new Error('set: key required');
|
|
36
37
|
const next = transformValue(value, key);
|
|
37
38
|
if (next == null) return remove(key);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
return withFileLock(filePath, async () => {
|
|
40
|
+
const map = await load();
|
|
41
|
+
map[key] = next;
|
|
42
|
+
await save(map);
|
|
43
|
+
return next;
|
|
44
|
+
});
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
async function remove(key) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
return withFileLock(filePath, async () => {
|
|
49
|
+
const map = await load();
|
|
50
|
+
if (!(key in map)) return false;
|
|
51
|
+
delete map[key];
|
|
52
|
+
await save(map);
|
|
53
|
+
return true;
|
|
54
|
+
});
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
async function list() {
|
package/lib/persistedSessions.js
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
const path = require('node:path');
|
|
30
30
|
const fs = require('node:fs/promises');
|
|
31
31
|
const { DATA_DIR } = require('./config');
|
|
32
|
+
const { atomicWriteJson, withFileLock } = require('./atomicJson');
|
|
32
33
|
|
|
33
34
|
const FILE = path.join(DATA_DIR, 'sessions.json');
|
|
34
35
|
|
|
@@ -44,34 +45,37 @@ async function loadAll() {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
async function saveAll(list) {
|
|
47
|
-
await
|
|
48
|
+
await atomicWriteJson(FILE, list);
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
function genId() {
|
|
51
52
|
return 'sess-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
async function create(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
55
|
+
async function create(opts) {
|
|
56
|
+
return withFileLock(FILE, async () => {
|
|
57
|
+
const { cliId, cwd, workspace, repos = [], folderId = null, title = '', status = 'running', cliSessionId = null } = opts;
|
|
58
|
+
const list = await loadAll();
|
|
59
|
+
const entry = {
|
|
60
|
+
id: genId(),
|
|
61
|
+
cliId,
|
|
62
|
+
cwd,
|
|
63
|
+
workspace,
|
|
64
|
+
title,
|
|
65
|
+
folderId,
|
|
66
|
+
repos,
|
|
67
|
+
createdAt: Date.now(),
|
|
68
|
+
lastActiveAt: Date.now(),
|
|
69
|
+
status,
|
|
70
|
+
exitedAt: status === 'exited' ? Date.now() : null,
|
|
71
|
+
exitCode: null,
|
|
72
|
+
pid: null,
|
|
73
|
+
cliSessionId,
|
|
74
|
+
};
|
|
75
|
+
list.push(entry);
|
|
76
|
+
await saveAll(list);
|
|
77
|
+
return entry;
|
|
78
|
+
});
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
async function get(id) {
|
|
@@ -80,21 +84,25 @@ async function get(id) {
|
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
async function update(id, patch) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
return withFileLock(FILE, async () => {
|
|
88
|
+
const list = await loadAll();
|
|
89
|
+
const idx = list.findIndex((s) => s.id === id);
|
|
90
|
+
if (idx < 0) return null;
|
|
91
|
+
list[idx] = { ...list[idx], ...patch };
|
|
92
|
+
await saveAll(list);
|
|
93
|
+
return list[idx];
|
|
94
|
+
});
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
async function remove(id) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
return withFileLock(FILE, async () => {
|
|
99
|
+
const list = await loadAll();
|
|
100
|
+
const idx = list.findIndex((s) => s.id === id);
|
|
101
|
+
if (idx < 0) return false;
|
|
102
|
+
list.splice(idx, 1);
|
|
103
|
+
await saveAll(list);
|
|
104
|
+
return true;
|
|
105
|
+
});
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
// Convenience helpers used at runtime so callers don't have to do
|
package/lib/webTerminal.js
CHANGED
|
@@ -84,6 +84,12 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {},
|
|
|
84
84
|
if (entry.onDataExtra) { try { entry.onDataExtra(data); } catch {} }
|
|
85
85
|
});
|
|
86
86
|
proc.onExit(({ exitCode, signal }) => {
|
|
87
|
+
// If a respawn replaced us in the pool (same entryId, new entry
|
|
88
|
+
// object), do not touch persistedSessions or schedule a delete —
|
|
89
|
+
// those belong to the new entry now. Without this guard, a slow-
|
|
90
|
+
// dying old PTY would fire markExited on the same sessionId and
|
|
91
|
+
// clobber the new spawn's markRunning state.
|
|
92
|
+
if (sessions.get(entryId) !== entry) return;
|
|
87
93
|
entry.exitCode = exitCode;
|
|
88
94
|
entry.exitedAt = Date.now();
|
|
89
95
|
const frame = JSON.stringify({ type: 'exit', code: exitCode, signal });
|
|
@@ -91,8 +97,18 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {},
|
|
|
91
97
|
try { ws.send(frame); } catch {}
|
|
92
98
|
}
|
|
93
99
|
if (entry.onExitExtra) { try { entry.onExitExtra({ exitCode, signal }); } catch {} }
|
|
94
|
-
setTimeout(() =>
|
|
100
|
+
setTimeout(() => {
|
|
101
|
+
if (sessions.get(entryId) === entry) sessions.delete(entryId);
|
|
102
|
+
}, 30_000);
|
|
95
103
|
});
|
|
104
|
+
// If a previous entry exists under the same id (respawn), kill its
|
|
105
|
+
// pty so we don't have zombie claude.exe processes hanging on. The
|
|
106
|
+
// onExit guard above ensures its callback no-ops once we've taken
|
|
107
|
+
// over the slot.
|
|
108
|
+
const prev = sessions.get(entryId);
|
|
109
|
+
if (prev && !prev.exitedAt) {
|
|
110
|
+
try { prev.pty.kill(); } catch {}
|
|
111
|
+
}
|
|
96
112
|
sessions.set(entryId, entry);
|
|
97
113
|
return entry;
|
|
98
114
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
20
|
"start": "node server.js",
|
|
21
|
-
"dev": "node
|
|
21
|
+
"dev": "node scripts/dev.js",
|
|
22
22
|
"postinstall": "node scripts/install.js",
|
|
23
23
|
"preuninstall": "node scripts/uninstall.js"
|
|
24
24
|
},
|
package/public/css/sidebar.css
CHANGED
|
@@ -405,10 +405,12 @@ body.is-resizing-sidebar * {
|
|
|
405
405
|
color: var(--ink);
|
|
406
406
|
display: inline-flex;
|
|
407
407
|
align-items: center;
|
|
408
|
-
opacity: 0
|
|
408
|
+
opacity: 0;
|
|
409
409
|
transition: opacity .12s, background .12s;
|
|
410
410
|
}
|
|
411
|
-
.tree-head
|
|
411
|
+
.tree-head:hover .tree-head-action,
|
|
412
|
+
.tree-head-action:focus-visible { opacity: 0.7; }
|
|
413
|
+
.tree-head:hover .tree-head-action:hover { opacity: 1; background: var(--sidebar-hover); }
|
|
412
414
|
.tree-head-action svg { width: 14px; height: 14px; }
|
|
413
415
|
|
|
414
416
|
/* Folder grouping. Chevron rotates on expand. */
|
|
@@ -419,6 +421,17 @@ body.is-resizing-sidebar * {
|
|
|
419
421
|
.tree-folder[data-dnd-over="true"] > .tree-folder-head {
|
|
420
422
|
box-shadow: 0 -2px 0 var(--accent) inset;
|
|
421
423
|
}
|
|
424
|
+
/* Session being dragged → folder is a drop target. Tint the folder
|
|
425
|
+
head + outline the whole folder so the user knows where it'll land,
|
|
426
|
+
independent of whether the folder is expanded or collapsed. */
|
|
427
|
+
.tree-folder.is-session-drop-target {
|
|
428
|
+
border-radius: 6px;
|
|
429
|
+
outline: 1px dashed var(--ink-mid);
|
|
430
|
+
outline-offset: -2px;
|
|
431
|
+
background: var(--sidebar-hover);
|
|
432
|
+
}
|
|
433
|
+
.tree-session[draggable="true"] { cursor: pointer; }
|
|
434
|
+
.tree-session[draggable="true"]:active { cursor: grabbing; }
|
|
422
435
|
.tree-folder-head[draggable="true"] {
|
|
423
436
|
cursor: grab;
|
|
424
437
|
}
|
|
@@ -532,6 +545,11 @@ body.is-resizing-sidebar * {
|
|
|
532
545
|
background: var(--sidebar-active);
|
|
533
546
|
font-weight: 500;
|
|
534
547
|
}
|
|
548
|
+
/* Status dot · deliberately understated. The earlier version had a
|
|
549
|
+
green dot + soft glow + expanding halo pulse; in a sidebar with
|
|
550
|
+
eight running sessions it read as a row of strobing alerts. Now:
|
|
551
|
+
one 5px dot, no halo, no shadow, no animation. Color alone carries
|
|
552
|
+
running vs stopped. */
|
|
535
553
|
.tree-dot {
|
|
536
554
|
width: 14px;
|
|
537
555
|
height: 14px;
|
|
@@ -539,7 +557,6 @@ body.is-resizing-sidebar * {
|
|
|
539
557
|
display: inline-flex;
|
|
540
558
|
align-items: center;
|
|
541
559
|
justify-content: center;
|
|
542
|
-
position: relative;
|
|
543
560
|
}
|
|
544
561
|
.tree-dot::after {
|
|
545
562
|
content: "";
|
|
@@ -551,31 +568,6 @@ body.is-resizing-sidebar * {
|
|
|
551
568
|
}
|
|
552
569
|
.tree-session.is-running .tree-dot::after {
|
|
553
570
|
background: var(--green);
|
|
554
|
-
box-shadow: 0 0 0 3px rgba(74, 138, 74, 0.18);
|
|
555
|
-
}
|
|
556
|
-
/* Pulse halo for running sessions — implemented on .tree-dot (the
|
|
557
|
-
wrapper) via a second pseudo-element animating opacity + transform,
|
|
558
|
-
not box-shadow. opacity is composited; box-shadow forces paint every
|
|
559
|
-
frame, and with N running sessions in the sidebar that adds up. */
|
|
560
|
-
.tree-session.is-running .tree-dot::before {
|
|
561
|
-
content: "";
|
|
562
|
-
position: absolute;
|
|
563
|
-
left: 50%; top: 50%;
|
|
564
|
-
width: 7px; height: 7px;
|
|
565
|
-
margin: -3.5px 0 0 -3.5px;
|
|
566
|
-
border-radius: 50%;
|
|
567
|
-
background: var(--green);
|
|
568
|
-
opacity: 0.45;
|
|
569
|
-
animation: tree-dot-pulse 1.8s ease-in-out infinite;
|
|
570
|
-
pointer-events: none;
|
|
571
|
-
}
|
|
572
|
-
.tree-session.is-stopped .tree-dot::after {
|
|
573
|
-
background: var(--ink-faint);
|
|
574
|
-
box-shadow: none;
|
|
575
|
-
}
|
|
576
|
-
@keyframes tree-dot-pulse {
|
|
577
|
-
0%, 100% { opacity: 0.45; transform: scale(1); }
|
|
578
|
-
50% { opacity: 0; transform: scale(2.4); }
|
|
579
571
|
}
|
|
580
572
|
.tree-label {
|
|
581
573
|
flex: 1;
|
package/public/css/widgets.css
CHANGED
|
@@ -980,6 +980,38 @@
|
|
|
980
980
|
gap: 6px;
|
|
981
981
|
margin-top: 4px;
|
|
982
982
|
}
|
|
983
|
+
.entity-test-button { margin-right: auto; }
|
|
984
|
+
|
|
985
|
+
.entity-test-result {
|
|
986
|
+
margin-top: 4px;
|
|
987
|
+
padding: 8px 10px;
|
|
988
|
+
border-radius: 6px;
|
|
989
|
+
border: 1px solid var(--border);
|
|
990
|
+
background: var(--bg);
|
|
991
|
+
display: flex;
|
|
992
|
+
flex-direction: column;
|
|
993
|
+
gap: 6px;
|
|
994
|
+
font-size: 12.5px;
|
|
995
|
+
}
|
|
996
|
+
.entity-test-result.is-ok { border-color: rgba(74, 138, 74, 0.4); background: rgba(74, 138, 74, 0.06); }
|
|
997
|
+
.entity-test-result.is-fail { border-color: rgba(183, 63, 63, 0.4); background: rgba(183, 63, 63, 0.06); }
|
|
998
|
+
.entity-test-summary {
|
|
999
|
+
font-family: var(--body);
|
|
1000
|
+
font-weight: 500;
|
|
1001
|
+
color: var(--ink);
|
|
1002
|
+
}
|
|
1003
|
+
.entity-test-result.is-fail .entity-test-summary { color: var(--red); }
|
|
1004
|
+
.entity-test-out {
|
|
1005
|
+
margin: 0;
|
|
1006
|
+
font-family: var(--mono);
|
|
1007
|
+
font-size: 11.5px;
|
|
1008
|
+
color: var(--ink-mid);
|
|
1009
|
+
white-space: pre-wrap;
|
|
1010
|
+
word-break: break-word;
|
|
1011
|
+
max-height: 120px;
|
|
1012
|
+
overflow-y: auto;
|
|
1013
|
+
}
|
|
1014
|
+
.entity-test-out.is-stderr { color: var(--ink-muted); border-top: 1px dashed var(--border); padding-top: 4px; }
|
|
983
1015
|
|
|
984
1016
|
.icon-radio {
|
|
985
1017
|
display: grid;
|
package/public/js/api.js
CHANGED
|
@@ -28,11 +28,11 @@ export async function loadConfig() {
|
|
|
28
28
|
export async function updateCli(id, patch) {
|
|
29
29
|
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
30
30
|
const target = (cfg.clis || []).find((c) => c.id === id);
|
|
31
|
-
// Built-in CLIs lock down
|
|
32
|
-
//
|
|
33
|
-
//
|
|
31
|
+
// Built-in CLIs lock down structural fields (id + builtin flag) but
|
|
32
|
+
// allow command edits — users routinely need to point at an absolute
|
|
33
|
+
// path (e.g. C:\Users\you\.local\bin\claude.exe) or a wrapper script
|
|
34
|
+
// when the bare name isn't on the spawn-time PATH.
|
|
34
35
|
if (target?.builtin) {
|
|
35
|
-
delete patch.command;
|
|
36
36
|
delete patch.id;
|
|
37
37
|
delete patch.builtin;
|
|
38
38
|
}
|
|
@@ -52,6 +52,14 @@ export async function updateCli(id, patch) {
|
|
|
52
52
|
return id;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// Probe a (possibly-unsaved) CLI config: spawn its command with
|
|
56
|
+
// `--version`, capture output, see if it looks like the claimed type.
|
|
57
|
+
// `args` is intentionally ignored server-side — runtime flags can
|
|
58
|
+
// disturb a quick probe.
|
|
59
|
+
export async function testCli({ command, shell, type }) {
|
|
60
|
+
return api('POST', '/api/clis/test', { command, shell, type });
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
export async function deleteCli(id) {
|
|
56
64
|
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
57
65
|
const target = (cfg.clis || []).find((c) => c.id === id);
|
|
@@ -18,10 +18,13 @@ import { Modal } from './Modal.js';
|
|
|
18
18
|
export function EntityFormModal({
|
|
19
19
|
title, fields, initial = {}, submitLabel = 'Save',
|
|
20
20
|
readOnlyKeys = [],
|
|
21
|
-
onSubmit, onClose,
|
|
21
|
+
onSubmit, onClose, onTest, testLabel = 'Test',
|
|
22
|
+
danger,
|
|
22
23
|
}) {
|
|
23
24
|
const [draft, setDraft] = useState(() => ({ ...initialFrom(fields), ...initial }));
|
|
24
25
|
const [saving, setSaving] = useState(false);
|
|
26
|
+
const [testing, setTesting] = useState(false);
|
|
27
|
+
const [testResult, setTestResult] = useState(null);
|
|
25
28
|
|
|
26
29
|
const isReadOnly = (key) => readOnlyKeys.includes(key);
|
|
27
30
|
|
|
@@ -36,6 +39,20 @@ export function EntityFormModal({
|
|
|
36
39
|
finally { setSaving(false); }
|
|
37
40
|
};
|
|
38
41
|
|
|
42
|
+
const runTest = async () => {
|
|
43
|
+
if (!onTest) return;
|
|
44
|
+
setTesting(true);
|
|
45
|
+
setTestResult(null);
|
|
46
|
+
try {
|
|
47
|
+
const r = await onTest(draft);
|
|
48
|
+
setTestResult(r);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
setTestResult({ ok: false, spawnError: String(e?.message || e) });
|
|
51
|
+
} finally {
|
|
52
|
+
setTesting(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
39
56
|
return html`
|
|
40
57
|
<${Modal} title=${title} onClose=${onClose} width=${440}>
|
|
41
58
|
<form class="entity-form" onSubmit=${submit}>
|
|
@@ -87,7 +104,26 @@ export function EntityFormModal({
|
|
|
87
104
|
${f.hint && f.type !== 'checkbox' ? html`
|
|
88
105
|
<span class="entity-field-hint">${f.hint}</span>` : null}
|
|
89
106
|
</label>`)}
|
|
107
|
+
${testResult ? html`
|
|
108
|
+
<div class=${`entity-test-result ${testResult.ok ? 'is-ok' : 'is-fail'}`}>
|
|
109
|
+
<div class="entity-test-summary">
|
|
110
|
+
${testResult.ok ? '✓' : '✗'} ${testResult.ok ? 'works' : 'failed'}
|
|
111
|
+
${typeof testResult.exitCode === 'number' ? html` · exit ${testResult.exitCode}` : null}
|
|
112
|
+
${typeof testResult.durationMs === 'number' ? html` · ${testResult.durationMs}ms` : null}
|
|
113
|
+
${testResult.timedOut ? html` · timed out` : null}
|
|
114
|
+
${testResult.matchedType === true ? html` · type matches ${testResult.expectedType}` : null}
|
|
115
|
+
${testResult.matchedType === false ? html` · type mismatch (expected ${testResult.expectedType})` : null}
|
|
116
|
+
</div>
|
|
117
|
+
${testResult.spawnError ? html`<pre class="entity-test-out">${testResult.spawnError}</pre>` : null}
|
|
118
|
+
${testResult.stdout ? html`<pre class="entity-test-out">${testResult.stdout}</pre>` : null}
|
|
119
|
+
${testResult.stderr ? html`<pre class="entity-test-out is-stderr">${testResult.stderr}</pre>` : null}
|
|
120
|
+
</div>` : null}
|
|
90
121
|
<div class="entity-form-actions">
|
|
122
|
+
${onTest ? html`
|
|
123
|
+
<button type="button" class="action small subtle entity-test-button"
|
|
124
|
+
disabled=${testing} onClick=${runTest}>
|
|
125
|
+
${testing ? 'Testing…' : testLabel}
|
|
126
|
+
</button>` : null}
|
|
91
127
|
<button type="button" class="action small subtle" onClick=${onClose}>Cancel</button>
|
|
92
128
|
<button type="submit" class=${`action small ${danger ? 'danger' : 'primary'}`}
|
|
93
129
|
disabled=${saving}>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { html } from '../html.js';
|
|
2
|
+
import { signal } from '@preact/signals';
|
|
2
3
|
import {
|
|
3
|
-
activeTab, sidebarCollapsed, sidebarForcedCollapsed, configDirty, capabilities,
|
|
4
|
+
activeTab, sidebarCollapsed, sidebarForcedCollapsed, configDirty, capabilities,
|
|
4
5
|
sessions, folders, sessionsByFolder, foldersCollapsed, activeSessionId,
|
|
5
6
|
selectTab, selectSession, toggleSidebar, toggleFolder, setSidebarWidth,
|
|
6
7
|
} from '../state.js';
|
|
@@ -12,9 +13,18 @@ import { clockTick } from '../state.js';
|
|
|
12
13
|
import { useDragSort } from './useDragSort.js';
|
|
13
14
|
import {
|
|
14
15
|
IconLaunch, IconConfigure,
|
|
15
|
-
IconSidebarToggle, IconPencil, IconClose, IconFolder, IconFolderOpen, BrandMark,
|
|
16
|
+
IconSidebarToggle, IconPencil, IconClose, IconFolder, IconFolderOpen, IconPlus, BrandMark,
|
|
16
17
|
} from '../icons.js';
|
|
17
18
|
|
|
19
|
+
// Module-level drag state for session → folder moves. Lives outside the
|
|
20
|
+
// useDragSort hook (which handles same-list folder reorder) so the two
|
|
21
|
+
// don't interfere — session drags and folder drags use disjoint state.
|
|
22
|
+
// Folder key: folder.id for real folders, the literal string 'unsorted'
|
|
23
|
+
// for the implicit top-level Unsorted bucket.
|
|
24
|
+
const draggingSessionId = signal(null);
|
|
25
|
+
const dragOverFolderKey = signal(null);
|
|
26
|
+
const folderKey = (folder) => folder ? folder.id : 'unsorted';
|
|
27
|
+
|
|
18
28
|
function NavItem({ tab, icon, label, dirty }) {
|
|
19
29
|
const selected = activeTab.value === tab;
|
|
20
30
|
return html`
|
|
@@ -46,47 +56,6 @@ function SessionRow({ s }) {
|
|
|
46
56
|
}
|
|
47
57
|
};
|
|
48
58
|
|
|
49
|
-
const onContext = async (ev) => {
|
|
50
|
-
ev.preventDefault();
|
|
51
|
-
ev.stopPropagation();
|
|
52
|
-
// Quick menu: Rename / Move / Delete. We use sequential prompts
|
|
53
|
-
// to avoid building a real context-menu component for now.
|
|
54
|
-
const action = await ccsmPrompt(
|
|
55
|
-
`${title} · ${running ? 'running' : 'stopped'}\nType: rename / move / delete / resume / cancel`,
|
|
56
|
-
'cancel', { title: s.id, okLabel: 'OK' });
|
|
57
|
-
if (!action) return;
|
|
58
|
-
const verb = action.trim().toLowerCase();
|
|
59
|
-
if (verb === 'rename') {
|
|
60
|
-
const next = await ccsmPrompt('New title', title, { title: 'Rename session', okLabel: 'Save' });
|
|
61
|
-
if (next === null) return;
|
|
62
|
-
try { await setSessionTitle(s.id, next.trim()); setToast('renamed'); }
|
|
63
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
64
|
-
} else if (verb === 'move') {
|
|
65
|
-
// Move to a folder by name
|
|
66
|
-
const folderNames = folders.value.map((f) => f.name).join(', ');
|
|
67
|
-
const target = await ccsmPrompt(
|
|
68
|
-
`Move to which folder? (empty = Unsorted)\nExisting: ${folderNames || '(none)'}`,
|
|
69
|
-
'', { title: 'Move', okLabel: 'Move' });
|
|
70
|
-
if (target === null) return;
|
|
71
|
-
const t = target.trim();
|
|
72
|
-
const folder = t ? folders.value.find((f) => f.name.toLowerCase() === t.toLowerCase()) : null;
|
|
73
|
-
if (t && !folder) { setToast(`no folder named "${t}"`, 'error'); return; }
|
|
74
|
-
try { await setSessionFolder(s.id, folder ? folder.id : null); setToast('moved'); }
|
|
75
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
76
|
-
} else if (verb === 'delete') {
|
|
77
|
-
const ok = await ccsmConfirm(`Delete session ${title}? PTY will be killed if alive.`, {
|
|
78
|
-
title: 'Delete session', okLabel: 'Delete', danger: true });
|
|
79
|
-
if (!ok) return;
|
|
80
|
-
try {
|
|
81
|
-
await deleteSession(s.id);
|
|
82
|
-
if (activeSessionId.value === s.id) activeSessionId.value = null;
|
|
83
|
-
} catch (e) { setToast(e.message, 'error'); }
|
|
84
|
-
} else if (verb === 'resume' && !running) {
|
|
85
|
-
try { await resumeSession(s.id); selectSession(s.id); }
|
|
86
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
87
|
-
}
|
|
88
|
-
};
|
|
89
|
-
|
|
90
59
|
const onRenameClick = async (ev) => {
|
|
91
60
|
ev.preventDefault();
|
|
92
61
|
ev.stopPropagation();
|
|
@@ -108,10 +77,23 @@ function SessionRow({ s }) {
|
|
|
108
77
|
} catch (e) { setToast(e.message, 'error'); }
|
|
109
78
|
};
|
|
110
79
|
|
|
80
|
+
const onDragStart = (ev) => {
|
|
81
|
+
draggingSessionId.value = s.id;
|
|
82
|
+
ev.dataTransfer.effectAllowed = 'move';
|
|
83
|
+
// Firefox refuses to start a drag without some data set.
|
|
84
|
+
try { ev.dataTransfer.setData('text/plain', s.id); } catch {}
|
|
85
|
+
};
|
|
86
|
+
const onDragEnd = () => {
|
|
87
|
+
draggingSessionId.value = null;
|
|
88
|
+
dragOverFolderKey.value = null;
|
|
89
|
+
};
|
|
90
|
+
|
|
111
91
|
return html`
|
|
112
92
|
<div class=${`tree-session${isActive ? ' is-active' : ''}${running ? ' is-running' : ' is-stopped'}`}
|
|
93
|
+
draggable=${true}
|
|
94
|
+
onDragStart=${onDragStart}
|
|
95
|
+
onDragEnd=${onDragEnd}
|
|
113
96
|
onClick=${onClick}
|
|
114
|
-
onContextMenu=${onContext}
|
|
115
97
|
title=${`${title}\n${s.cwd}\n${running ? 'running' : 'stopped'} · ${s.cliId}`}>
|
|
116
98
|
<span class=${`tree-dot ${running ? 'is-running' : 'is-stopped'}`}></span>
|
|
117
99
|
<span class="tree-label">${title}</span>
|
|
@@ -124,7 +106,7 @@ function SessionRow({ s }) {
|
|
|
124
106
|
}
|
|
125
107
|
|
|
126
108
|
function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
|
|
127
|
-
const key = folder
|
|
109
|
+
const key = folderKey(folder);
|
|
128
110
|
const collapsed = !!foldersCollapsed.value[key];
|
|
129
111
|
const name = folder ? folder.name : 'Unsorted';
|
|
130
112
|
const onToggle = () => toggleFolder(folder ? folder.id : null);
|
|
@@ -148,8 +130,55 @@ function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
|
|
|
148
130
|
catch (e) { setToast(e.message, 'error'); }
|
|
149
131
|
};
|
|
150
132
|
|
|
133
|
+
// Session-into-folder drop target. We don't go through useDragSort
|
|
134
|
+
// because that one is wired for folder-reorder. Folder reorder's
|
|
135
|
+
// handlers (in dndRow) short-circuit when no folder is being dragged,
|
|
136
|
+
// and our handlers below short-circuit when no session is being
|
|
137
|
+
// dragged — so composing both is safe.
|
|
138
|
+
const draggedSession = draggingSessionId.value
|
|
139
|
+
? sessions.value.find((s) => s.id === draggingSessionId.value)
|
|
140
|
+
: null;
|
|
141
|
+
const sameFolder = draggedSession
|
|
142
|
+
&& (draggedSession.folderId || null) === (folder ? folder.id : null);
|
|
143
|
+
const isOver = !sameFolder && dragOverFolderKey.value === key;
|
|
144
|
+
|
|
145
|
+
const onSessionDragOver = (ev) => {
|
|
146
|
+
if (!draggingSessionId.value || sameFolder) return;
|
|
147
|
+
ev.preventDefault();
|
|
148
|
+
ev.dataTransfer.dropEffect = 'move';
|
|
149
|
+
if (dragOverFolderKey.value !== key) dragOverFolderKey.value = key;
|
|
150
|
+
};
|
|
151
|
+
const onSessionDragLeave = (ev) => {
|
|
152
|
+
if (!draggingSessionId.value) return;
|
|
153
|
+
const rt = ev.relatedTarget;
|
|
154
|
+
if (rt && ev.currentTarget.contains(rt)) return;
|
|
155
|
+
if (dragOverFolderKey.value === key) dragOverFolderKey.value = null;
|
|
156
|
+
};
|
|
157
|
+
const onSessionDrop = (ev) => {
|
|
158
|
+
const sid = draggingSessionId.value;
|
|
159
|
+
draggingSessionId.value = null;
|
|
160
|
+
dragOverFolderKey.value = null;
|
|
161
|
+
if (!sid || sameFolder) return;
|
|
162
|
+
ev.preventDefault();
|
|
163
|
+
ev.stopPropagation();
|
|
164
|
+
setSessionFolder(sid, folder ? folder.id : null)
|
|
165
|
+
.then(() => setToast(folder ? `moved to ${folder.name}` : 'moved to Unsorted'))
|
|
166
|
+
.catch((e) => setToast(e.message, 'error'));
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Spread folder-reorder row handlers first, then compose our
|
|
170
|
+
// session-drop handlers on top so both fire.
|
|
171
|
+
const { onDragOver: rowOver, onDragLeave: rowLeave, onDrop: rowDrop, ...rowAttrs } = dndRow || {};
|
|
172
|
+
const composedOver = (ev) => { onSessionDragOver(ev); rowOver?.(ev); };
|
|
173
|
+
const composedLeave = (ev) => { onSessionDragLeave(ev); rowLeave?.(ev); };
|
|
174
|
+
const composedDrop = (ev) => { onSessionDrop(ev); rowDrop?.(ev); };
|
|
175
|
+
|
|
151
176
|
return html`
|
|
152
|
-
<div class
|
|
177
|
+
<div class=${`tree-folder${isOver ? ' is-session-drop-target' : ''}`}
|
|
178
|
+
...${rowAttrs}
|
|
179
|
+
onDragOver=${composedOver}
|
|
180
|
+
onDragLeave=${composedLeave}
|
|
181
|
+
onDrop=${composedDrop}>
|
|
153
182
|
<button class=${`tree-folder-head${collapsed ? '' : ' is-open'}`} onClick=${onToggle}
|
|
154
183
|
...${dndHandle || {}}>
|
|
155
184
|
<span class="tree-folder-icon">
|
|
@@ -194,6 +223,9 @@ function SessionTree() {
|
|
|
194
223
|
<div class="tree">
|
|
195
224
|
<div class="tree-head">
|
|
196
225
|
<span class="tree-head-label">Sessions</span>
|
|
226
|
+
<button class="tree-head-action" title="New folder" onClick=${onNewFolder}>
|
|
227
|
+
<${IconPlus} />
|
|
228
|
+
</button>
|
|
197
229
|
</div>
|
|
198
230
|
${orderedFolders.map((f) => html`
|
|
199
231
|
<${FolderGroup} key=${f.id} folder=${f}
|
|
@@ -236,9 +268,6 @@ export function Sidebar() {
|
|
|
236
268
|
onClick=${() => selectTab('about')}>
|
|
237
269
|
<span class="brand-mark"><${BrandMark} /></span>
|
|
238
270
|
<span class="brand-name">CCSM<span class="brand-dot">.</span></span>
|
|
239
|
-
${serverHealth.value.version ? html`
|
|
240
|
-
<span class="brand-version">v${serverHealth.value.version}</span>
|
|
241
|
-
` : null}
|
|
242
271
|
</button>
|
|
243
272
|
</div>
|
|
244
273
|
|
|
@@ -86,6 +86,22 @@ export function TerminalView({ terminalId }) {
|
|
|
86
86
|
} catch (e) {
|
|
87
87
|
console.warn('[ccsm] WebGL addon failed, using DOM renderer:', e);
|
|
88
88
|
}
|
|
89
|
+
// Ctrl+C with a selection: by default xterm.js sends \x03 AND the
|
|
90
|
+
// browser's own copy event fires — so the user gets "selection
|
|
91
|
+
// copied to clipboard" AND the running CLI gets SIGINT. Mirror
|
|
92
|
+
// VSCode/Windows Terminal behaviour: when there's a selection,
|
|
93
|
+
// suppress \x03 and let the copy event do its thing. With no
|
|
94
|
+
// selection, Ctrl+C still sends \x03 normally.
|
|
95
|
+
term.attachCustomKeyEventHandler((ev) => {
|
|
96
|
+
if (ev.type === 'keydown'
|
|
97
|
+
&& ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey
|
|
98
|
+
&& ev.key.toLowerCase() === 'c'
|
|
99
|
+
&& term.hasSelection()) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
});
|
|
104
|
+
|
|
89
105
|
const host = hostRef.current;
|
|
90
106
|
term.open(host);
|
|
91
107
|
// Defer fit one tick so the container has measured layout
|
package/public/js/main.js
CHANGED
|
@@ -3,32 +3,24 @@
|
|
|
3
3
|
// the mount root.
|
|
4
4
|
|
|
5
5
|
import { render } from 'preact';
|
|
6
|
-
import { effect } from '@preact/signals';
|
|
7
6
|
import { html } from './html.js';
|
|
8
|
-
import { loadPersisted, clockTick, lastRefreshAt, installPrompt, isInstalledPwa, sidebarForcedCollapsed
|
|
7
|
+
import { loadPersisted, clockTick, lastRefreshAt, installPrompt, isInstalledPwa, sidebarForcedCollapsed } from './state.js';
|
|
9
8
|
import { httpBase } from './backend.js';
|
|
10
9
|
import { loadConfig, refreshAll, loadSessions, loadFolders, loadWorkspaces, pollHealth } from './api.js';
|
|
11
10
|
import { setToast } from './toast.js';
|
|
12
11
|
import { App } from './components/App.js';
|
|
13
12
|
|
|
14
13
|
loadPersisted();
|
|
15
|
-
// Window/tab title
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
// resets anything else.
|
|
20
|
-
function desiredTitle() {
|
|
21
|
-
const v = serverHealth.value.version;
|
|
22
|
-
return v ? `CCSM v${v}` : 'CCSM';
|
|
23
|
-
}
|
|
24
|
-
let expected = desiredTitle();
|
|
14
|
+
// Window/tab title pinned to "CCSM". A MutationObserver guards against
|
|
15
|
+
// Chromium standalone builds that occasionally try to inject the URL
|
|
16
|
+
// into the title bar.
|
|
17
|
+
const expected = 'CCSM';
|
|
25
18
|
function lockTitle() { if (document.title !== expected) document.title = expected; }
|
|
26
19
|
lockTitle();
|
|
27
20
|
new MutationObserver(lockTitle).observe(
|
|
28
21
|
document.querySelector('title') || document.head,
|
|
29
22
|
{ childList: true, subtree: true, characterData: true }
|
|
30
23
|
);
|
|
31
|
-
effect(() => { expected = desiredTitle(); lockTitle(); });
|
|
32
24
|
render(html`<${App} />`, document.getElementById('app'));
|
|
33
25
|
|
|
34
26
|
// PWA install affordance — Chromium fires `beforeinstallprompt` when the
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from '../state.js';
|
|
11
11
|
import {
|
|
12
12
|
api, loadConfig, loadWorkspaces, loadFolders,
|
|
13
|
-
createCli, updateCli, deleteCli, setDefaultCli,
|
|
13
|
+
createCli, updateCli, deleteCli, setDefaultCli, testCli,
|
|
14
14
|
createRepo, updateRepo, deleteRepo,
|
|
15
15
|
createFolder, renameFolder, deleteFolder, reorderFolders,
|
|
16
16
|
deleteWorkspace,
|
|
@@ -252,6 +252,7 @@ export function ConfigurePage() {
|
|
|
252
252
|
${edit?.kind === 'cli-new' ? html`
|
|
253
253
|
<${EntityFormModal} title="New CLI" fields=${cliFieldsFor({ creating: true })}
|
|
254
254
|
onClose=${close} submitLabel="Create"
|
|
255
|
+
onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
|
|
255
256
|
onSubmit=${async (v) => {
|
|
256
257
|
try { await createCli(v); setToast(`created CLI · ${v.name}`); }
|
|
257
258
|
catch (e) { setToast(e.message, 'error'); throw e; }
|
|
@@ -259,7 +260,7 @@ export function ConfigurePage() {
|
|
|
259
260
|
|
|
260
261
|
${edit?.kind === 'cli-edit' ? html`
|
|
261
262
|
<${EntityFormModal} title=${`Edit ${edit.payload.name}`} fields=${cliFieldsFor()}
|
|
262
|
-
readOnlyKeys=${edit.payload.builtin ? ['type'
|
|
263
|
+
readOnlyKeys=${edit.payload.builtin ? ['type'] : []}
|
|
263
264
|
initial=${{
|
|
264
265
|
...edit.payload,
|
|
265
266
|
args: (edit.payload.args || []).join(' '),
|
|
@@ -267,6 +268,7 @@ export function ConfigurePage() {
|
|
|
267
268
|
resumeIdArgs: (edit.payload.resumeIdArgs || []).join(' '),
|
|
268
269
|
}}
|
|
269
270
|
onClose=${close}
|
|
271
|
+
onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
|
|
270
272
|
onSubmit=${async (v) => {
|
|
271
273
|
try {
|
|
272
274
|
const patch = {
|
|
@@ -275,8 +277,6 @@ export function ConfigurePage() {
|
|
|
275
277
|
resumeArgs: typeof v.resumeArgs === 'string' ? v.resumeArgs.split(/\s+/).filter(Boolean) : v.resumeArgs,
|
|
276
278
|
resumeIdArgs: typeof v.resumeIdArgs === 'string' ? v.resumeIdArgs.split(/\s+/).filter(Boolean) : v.resumeIdArgs,
|
|
277
279
|
};
|
|
278
|
-
// command is locked on builtins — drop any tampered value.
|
|
279
|
-
if (edit.payload.builtin) delete patch.command;
|
|
280
280
|
await updateCli(edit.payload.id, patch);
|
|
281
281
|
setToast('saved');
|
|
282
282
|
} catch (e) { setToast(e.message, 'error'); throw e; }
|
package/scripts/dev.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Dev launcher · fully isolates from the user's prod ccsm install.
|
|
5
|
+
//
|
|
6
|
+
// Why: many contributors run the published `@bakapiano/ccsm` package
|
|
7
|
+
// for their day-to-day work (port 7777, ~/.ccsm). If `npm run dev`
|
|
8
|
+
// reused the same data dir + port, every hot-reload would clobber the
|
|
9
|
+
// live sessions.json. So dev gets its own:
|
|
10
|
+
//
|
|
11
|
+
// - CCSM_HOME → ~/.ccsm-dev/ (separate config.json, sessions.json, folders.json)
|
|
12
|
+
// - port → 7788 (no contention with prod 7777)
|
|
13
|
+
// - workDir → ~/ccsm-workspaces-dev (separate workspace tree)
|
|
14
|
+
// - no browser auto-open (we're iterating in an already-open tab)
|
|
15
|
+
//
|
|
16
|
+
// Run via `npm run dev`. The first launch seeds a starter config; later
|
|
17
|
+
// launches leave it alone so dev's own customisations stick.
|
|
18
|
+
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const { spawn } = require('node:child_process');
|
|
23
|
+
|
|
24
|
+
const DEV_HOME = path.join(os.homedir(), '.ccsm-dev');
|
|
25
|
+
const DEV_PORT = '7788';
|
|
26
|
+
const DEV_WORKDIR = path.join(os.homedir(), 'ccsm-workspaces-dev');
|
|
27
|
+
|
|
28
|
+
fs.mkdirSync(DEV_HOME, { recursive: true });
|
|
29
|
+
|
|
30
|
+
// Seed a fresh dev config the first time. Subsequent runs leave the
|
|
31
|
+
// existing file alone — the dev's own UI edits persist across restarts.
|
|
32
|
+
const configPath = path.join(DEV_HOME, 'config.json');
|
|
33
|
+
if (!fs.existsSync(configPath)) {
|
|
34
|
+
fs.writeFileSync(configPath, JSON.stringify({
|
|
35
|
+
port: Number(DEV_PORT),
|
|
36
|
+
workDir: DEV_WORKDIR,
|
|
37
|
+
repos: [],
|
|
38
|
+
}, null, 2));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const env = {
|
|
42
|
+
...process.env,
|
|
43
|
+
CCSM_HOME: DEV_HOME,
|
|
44
|
+
CCSM_PORT: DEV_PORT,
|
|
45
|
+
CCSM_NO_BROWSER: '1',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const serverPath = path.join(__dirname, '..', 'server.js');
|
|
49
|
+
const child = spawn(process.execPath, ['--watch', serverPath], {
|
|
50
|
+
env,
|
|
51
|
+
stdio: 'inherit',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const forward = (sig) => () => child.kill(sig);
|
|
55
|
+
process.on('SIGINT', forward('SIGINT'));
|
|
56
|
+
process.on('SIGTERM', forward('SIGTERM'));
|
|
57
|
+
child.on('exit', (code, signal) => {
|
|
58
|
+
process.exit(signal ? 1 : (code ?? 0));
|
|
59
|
+
});
|
package/server.js
CHANGED
|
@@ -203,8 +203,9 @@ function spawnCliSession({ cli, cwd, sessionId, meta, extraArgs = [] }) {
|
|
|
203
203
|
? prefixArgs
|
|
204
204
|
: [...prefixArgs, ...(cli.args || []), ...extraArgs];
|
|
205
205
|
// Merge user-scope PATH from registry into the env we hand the PTY.
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
// spawnEnv() also strips duplicate path-case keys so our override
|
|
207
|
+
// doesn't get shadowed by the inherited `Path` from process.env.
|
|
208
|
+
const env = spawnEnv(cli.env);
|
|
208
209
|
const trySpawn = (executable) => webTerminal.spawn({
|
|
209
210
|
id: sessionId,
|
|
210
211
|
command: executable,
|
|
@@ -328,6 +329,26 @@ function buildMergedUserPath() {
|
|
|
328
329
|
}
|
|
329
330
|
mergedUserPath = buildMergedUserPath();
|
|
330
331
|
|
|
332
|
+
// Hand back a fresh env for spawning a child, with PATH overridden by
|
|
333
|
+
// our merged user PATH and any duplicate case variants of "path"
|
|
334
|
+
// stripped first. Windows env lookup is case-insensitive but the env
|
|
335
|
+
// block we hand CreateProcess is an ordered byte buffer — if both
|
|
336
|
+
// `Path` (inherited from process.env, OS canonical case) and `PATH`
|
|
337
|
+
// (our override) are present, Windows resolves to whichever comes
|
|
338
|
+
// first in the block. Node's Object.keys preserves insertion order,
|
|
339
|
+
// so the inherited `Path` would win and our merged override silently
|
|
340
|
+
// disappear. Strip all path-shaped keys first, then add the merge.
|
|
341
|
+
function spawnEnv(extraEnv = {}) {
|
|
342
|
+
const env = { ...process.env, ...extraEnv };
|
|
343
|
+
if (process.platform === 'win32') {
|
|
344
|
+
for (const k of Object.keys(env)) {
|
|
345
|
+
if (k.toLowerCase() === 'path') delete env[k];
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (mergedUserPath) env.PATH = mergedUserPath;
|
|
349
|
+
return env;
|
|
350
|
+
}
|
|
351
|
+
|
|
331
352
|
// ---- config ----
|
|
332
353
|
|
|
333
354
|
// Per-CLI install probe. Looks up the command on PATH using `where` (win)
|
|
@@ -372,7 +393,92 @@ app.put('/api/config', asyncH(async (req, res) => {
|
|
|
372
393
|
res.json(decorateConfigWithProbes(cfg));
|
|
373
394
|
}));
|
|
374
395
|
|
|
375
|
-
// ----
|
|
396
|
+
// ---- CLI probe / test ----
|
|
397
|
+
//
|
|
398
|
+
// Run the user's configured command with `--version` and report back
|
|
399
|
+
// stdout/stderr + whether the output looks like the claimed CLI type.
|
|
400
|
+
// Used by the Configure page "Test" button so the user can verify the
|
|
401
|
+
// command resolves + actually launches the right tool BEFORE saving.
|
|
402
|
+
// Body: { command, args?, shell?, type? }. args is ignored for the
|
|
403
|
+
// version probe — we always append `--version` directly so the user's
|
|
404
|
+
// runtime args (e.g. --dangerously-skip-permissions) don't perturb the
|
|
405
|
+
// quick probe.
|
|
406
|
+
app.post('/api/clis/test', asyncH(async (req, res) => {
|
|
407
|
+
const { spawn } = require('node:child_process');
|
|
408
|
+
const body = req.body || {};
|
|
409
|
+
const command = String(body.command || '').trim();
|
|
410
|
+
const shell = ['direct', 'pwsh', 'cmd'].includes(body.shell) ? body.shell : 'direct';
|
|
411
|
+
const type = ['claude', 'codex', 'copilot', 'other'].includes(body.type) ? body.type : 'other';
|
|
412
|
+
if (!command) return res.status(400).json({ error: 'command required' });
|
|
413
|
+
|
|
414
|
+
// Build the test exec. Same shell-wrapping rules as resolveCommand,
|
|
415
|
+
// but we force `--version` as the only arg and we DROP `-NoExit`
|
|
416
|
+
// from the pwsh wrapper so pwsh terminates after printing.
|
|
417
|
+
let exe, args;
|
|
418
|
+
const cmd = command.replace(/^\.[\\\/]/, '');
|
|
419
|
+
const versionArg = '--version';
|
|
420
|
+
if (shell === 'pwsh') {
|
|
421
|
+
const joined = `& ${/[\s'"\`$]/.test(cmd) ? `'${cmd.replace(/'/g, "''")}'` : cmd} ${versionArg}`;
|
|
422
|
+
exe = 'pwsh.exe';
|
|
423
|
+
args = ['-NoLogo', '-Command', joined];
|
|
424
|
+
} else if (shell === 'cmd') {
|
|
425
|
+
exe = process.env.ComSpec || 'cmd.exe';
|
|
426
|
+
args = ['/d', '/s', '/c', `${cmd} ${versionArg}`];
|
|
427
|
+
} else if (path.isAbsolute(cmd)) {
|
|
428
|
+
const ext = path.extname(cmd).toLowerCase();
|
|
429
|
+
if (ext === '.cmd' || ext === '.bat') {
|
|
430
|
+
exe = process.env.ComSpec || 'cmd.exe';
|
|
431
|
+
args = ['/d', '/s', '/c', cmd, versionArg];
|
|
432
|
+
} else if (ext === '.ps1') {
|
|
433
|
+
exe = 'powershell.exe';
|
|
434
|
+
args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd, versionArg];
|
|
435
|
+
} else {
|
|
436
|
+
exe = cmd;
|
|
437
|
+
args = [versionArg];
|
|
438
|
+
}
|
|
439
|
+
} else {
|
|
440
|
+
exe = process.env.ComSpec || 'cmd.exe';
|
|
441
|
+
args = ['/d', '/s', '/c', cmd, versionArg];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const t0 = Date.now();
|
|
445
|
+
let stdout = '';
|
|
446
|
+
let stderr = '';
|
|
447
|
+
let exitCode = null;
|
|
448
|
+
let timedOut = false;
|
|
449
|
+
let spawnError = null;
|
|
450
|
+
try {
|
|
451
|
+
const child = spawn(exe, args, { env: spawnEnv(), windowsHide: true });
|
|
452
|
+
const killer = setTimeout(() => { timedOut = true; try { child.kill(); } catch {} }, 5000);
|
|
453
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); if (stdout.length > 8192) stdout = stdout.slice(0, 8192); });
|
|
454
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 8192) stderr = stderr.slice(0, 8192); });
|
|
455
|
+
exitCode = await new Promise((resolve, reject) => {
|
|
456
|
+
child.on('exit', (code) => { clearTimeout(killer); resolve(code); });
|
|
457
|
+
child.on('error', (err) => { clearTimeout(killer); reject(err); });
|
|
458
|
+
});
|
|
459
|
+
} catch (e) {
|
|
460
|
+
spawnError = String(e && e.message || e);
|
|
461
|
+
}
|
|
462
|
+
const durationMs = Date.now() - t0;
|
|
463
|
+
|
|
464
|
+
const out = (stdout + '\n' + stderr).toLowerCase();
|
|
465
|
+
const PATTERNS = {
|
|
466
|
+
claude: /claude/,
|
|
467
|
+
codex: /codex|openai/,
|
|
468
|
+
copilot: /copilot/,
|
|
469
|
+
};
|
|
470
|
+
const matchedType = type === 'other' ? null : (PATTERNS[type] ? PATTERNS[type].test(out) : null);
|
|
471
|
+
const ok = !spawnError && !timedOut && exitCode === 0;
|
|
472
|
+
res.json({
|
|
473
|
+
ok, exitCode, durationMs, timedOut, spawnError,
|
|
474
|
+
stdout: stdout.trim(),
|
|
475
|
+
stderr: stderr.trim(),
|
|
476
|
+
matchedType,
|
|
477
|
+
expectedType: type,
|
|
478
|
+
spawned: { exe, args },
|
|
479
|
+
});
|
|
480
|
+
}));
|
|
481
|
+
|
|
376
482
|
// ---- folders ----
|
|
377
483
|
|
|
378
484
|
app.get('/api/folders', asyncH(async (_req, res) => {
|