@bakapiano/ccsm 0.11.0 → 0.12.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/package.json +2 -2
- package/public/css/sidebar.css +20 -28
- package/public/js/components/Sidebar.js +78 -7
- package/public/js/main.js +5 -13
- package/scripts/dev.js +59 -0
- package/server.js +23 -2
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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;
|
|
@@ -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`
|
|
@@ -108,8 +118,22 @@ function SessionRow({ s }) {
|
|
|
108
118
|
} catch (e) { setToast(e.message, 'error'); }
|
|
109
119
|
};
|
|
110
120
|
|
|
121
|
+
const onDragStart = (ev) => {
|
|
122
|
+
draggingSessionId.value = s.id;
|
|
123
|
+
ev.dataTransfer.effectAllowed = 'move';
|
|
124
|
+
// Firefox refuses to start a drag without some data set.
|
|
125
|
+
try { ev.dataTransfer.setData('text/plain', s.id); } catch {}
|
|
126
|
+
};
|
|
127
|
+
const onDragEnd = () => {
|
|
128
|
+
draggingSessionId.value = null;
|
|
129
|
+
dragOverFolderKey.value = null;
|
|
130
|
+
};
|
|
131
|
+
|
|
111
132
|
return html`
|
|
112
133
|
<div class=${`tree-session${isActive ? ' is-active' : ''}${running ? ' is-running' : ' is-stopped'}`}
|
|
134
|
+
draggable=${true}
|
|
135
|
+
onDragStart=${onDragStart}
|
|
136
|
+
onDragEnd=${onDragEnd}
|
|
113
137
|
onClick=${onClick}
|
|
114
138
|
onContextMenu=${onContext}
|
|
115
139
|
title=${`${title}\n${s.cwd}\n${running ? 'running' : 'stopped'} · ${s.cliId}`}>
|
|
@@ -124,7 +148,7 @@ function SessionRow({ s }) {
|
|
|
124
148
|
}
|
|
125
149
|
|
|
126
150
|
function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
|
|
127
|
-
const key = folder
|
|
151
|
+
const key = folderKey(folder);
|
|
128
152
|
const collapsed = !!foldersCollapsed.value[key];
|
|
129
153
|
const name = folder ? folder.name : 'Unsorted';
|
|
130
154
|
const onToggle = () => toggleFolder(folder ? folder.id : null);
|
|
@@ -148,8 +172,55 @@ function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
|
|
|
148
172
|
catch (e) { setToast(e.message, 'error'); }
|
|
149
173
|
};
|
|
150
174
|
|
|
175
|
+
// Session-into-folder drop target. We don't go through useDragSort
|
|
176
|
+
// because that one is wired for folder-reorder. Folder reorder's
|
|
177
|
+
// handlers (in dndRow) short-circuit when no folder is being dragged,
|
|
178
|
+
// and our handlers below short-circuit when no session is being
|
|
179
|
+
// dragged — so composing both is safe.
|
|
180
|
+
const draggedSession = draggingSessionId.value
|
|
181
|
+
? sessions.value.find((s) => s.id === draggingSessionId.value)
|
|
182
|
+
: null;
|
|
183
|
+
const sameFolder = draggedSession
|
|
184
|
+
&& (draggedSession.folderId || null) === (folder ? folder.id : null);
|
|
185
|
+
const isOver = !sameFolder && dragOverFolderKey.value === key;
|
|
186
|
+
|
|
187
|
+
const onSessionDragOver = (ev) => {
|
|
188
|
+
if (!draggingSessionId.value || sameFolder) return;
|
|
189
|
+
ev.preventDefault();
|
|
190
|
+
ev.dataTransfer.dropEffect = 'move';
|
|
191
|
+
if (dragOverFolderKey.value !== key) dragOverFolderKey.value = key;
|
|
192
|
+
};
|
|
193
|
+
const onSessionDragLeave = (ev) => {
|
|
194
|
+
if (!draggingSessionId.value) return;
|
|
195
|
+
const rt = ev.relatedTarget;
|
|
196
|
+
if (rt && ev.currentTarget.contains(rt)) return;
|
|
197
|
+
if (dragOverFolderKey.value === key) dragOverFolderKey.value = null;
|
|
198
|
+
};
|
|
199
|
+
const onSessionDrop = (ev) => {
|
|
200
|
+
const sid = draggingSessionId.value;
|
|
201
|
+
draggingSessionId.value = null;
|
|
202
|
+
dragOverFolderKey.value = null;
|
|
203
|
+
if (!sid || sameFolder) return;
|
|
204
|
+
ev.preventDefault();
|
|
205
|
+
ev.stopPropagation();
|
|
206
|
+
setSessionFolder(sid, folder ? folder.id : null)
|
|
207
|
+
.then(() => setToast(folder ? `moved to ${folder.name}` : 'moved to Unsorted'))
|
|
208
|
+
.catch((e) => setToast(e.message, 'error'));
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Spread folder-reorder row handlers first, then compose our
|
|
212
|
+
// session-drop handlers on top so both fire.
|
|
213
|
+
const { onDragOver: rowOver, onDragLeave: rowLeave, onDrop: rowDrop, ...rowAttrs } = dndRow || {};
|
|
214
|
+
const composedOver = (ev) => { onSessionDragOver(ev); rowOver?.(ev); };
|
|
215
|
+
const composedLeave = (ev) => { onSessionDragLeave(ev); rowLeave?.(ev); };
|
|
216
|
+
const composedDrop = (ev) => { onSessionDrop(ev); rowDrop?.(ev); };
|
|
217
|
+
|
|
151
218
|
return html`
|
|
152
|
-
<div class
|
|
219
|
+
<div class=${`tree-folder${isOver ? ' is-session-drop-target' : ''}`}
|
|
220
|
+
...${rowAttrs}
|
|
221
|
+
onDragOver=${composedOver}
|
|
222
|
+
onDragLeave=${composedLeave}
|
|
223
|
+
onDrop=${composedDrop}>
|
|
153
224
|
<button class=${`tree-folder-head${collapsed ? '' : ' is-open'}`} onClick=${onToggle}
|
|
154
225
|
...${dndHandle || {}}>
|
|
155
226
|
<span class="tree-folder-icon">
|
|
@@ -194,6 +265,9 @@ function SessionTree() {
|
|
|
194
265
|
<div class="tree">
|
|
195
266
|
<div class="tree-head">
|
|
196
267
|
<span class="tree-head-label">Sessions</span>
|
|
268
|
+
<button class="tree-head-action" title="New folder" onClick=${onNewFolder}>
|
|
269
|
+
<${IconPlus} />
|
|
270
|
+
</button>
|
|
197
271
|
</div>
|
|
198
272
|
${orderedFolders.map((f) => html`
|
|
199
273
|
<${FolderGroup} key=${f.id} folder=${f}
|
|
@@ -236,9 +310,6 @@ export function Sidebar() {
|
|
|
236
310
|
onClick=${() => selectTab('about')}>
|
|
237
311
|
<span class="brand-mark"><${BrandMark} /></span>
|
|
238
312
|
<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
313
|
</button>
|
|
243
314
|
</div>
|
|
244
315
|
|
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
|
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)
|