@bakapiano/ccsm 0.9.0 → 0.10.1
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/CLAUDE.md +222 -195
- package/README.md +77 -79
- package/lib/cliSessionWatcher.js +249 -0
- package/lib/config.js +101 -24
- package/lib/folders.js +96 -0
- package/lib/localCliSessions.js +177 -0
- package/lib/persistedSessions.js +134 -0
- package/lib/webTerminal.js +31 -18
- package/lib/workspace.js +26 -4
- package/package.json +1 -1
- package/public/assets/claude-color.svg +1 -0
- package/public/assets/codex-color.svg +1 -0
- package/public/assets/copilot-color.svg +1 -0
- package/public/css/base.css +22 -5
- package/public/css/cards.css +37 -3
- package/public/css/feedback.css +127 -43
- package/public/css/forms.css +97 -25
- package/public/css/layout.css +74 -26
- package/public/css/modal.css +40 -26
- package/public/css/responsive.css +2 -2
- package/public/css/sidebar.css +424 -25
- package/public/css/terminals.css +138 -0
- package/public/css/tokens.css +28 -12
- package/public/css/wco.css +38 -39
- package/public/css/widgets.css +1177 -6
- package/public/index.html +35 -2
- package/public/js/api.js +194 -37
- package/public/js/components/AdoptModal.js +171 -0
- package/public/js/components/App.js +1 -11
- package/public/js/components/DirectoryPicker.js +203 -0
- package/public/js/components/EntityFormModal.js +105 -0
- package/public/js/components/Modal.js +51 -0
- package/public/js/components/OfflineBanner.js +29 -23
- package/public/js/components/PageTitleBar.js +13 -0
- package/public/js/components/Picker.js +179 -0
- package/public/js/components/Popover.js +55 -0
- package/public/js/components/Sidebar.js +219 -32
- package/public/js/components/TerminalView.js +27 -3
- package/public/js/components/useDragSort.js +67 -0
- package/public/js/dialog.js +10 -2
- package/public/js/icons.js +66 -3
- package/public/js/main.js +54 -3
- package/public/js/pages/AboutPage.js +80 -0
- package/public/js/pages/ConfigurePage.js +429 -207
- package/public/js/pages/LaunchPage.js +326 -86
- package/public/js/pages/SessionsPage.js +91 -41
- package/public/js/state.js +102 -73
- package/public/manifest.webmanifest +2 -2
- package/scripts/install.js +7 -2
- package/server.js +755 -441
- package/lib/favorites.js +0 -51
- package/lib/focus.js +0 -369
- package/lib/labels.js +0 -29
- package/lib/launcher.js +0 -219
- package/lib/sessions.js +0 -272
- package/lib/snapshot.js +0 -141
- package/public/js/actions.js +0 -107
- package/public/js/components/Fab.js +0 -11
- package/public/js/components/FavoritesTable.js +0 -81
- package/public/js/components/Footer.js +0 -12
- package/public/js/components/NewSessionModal.js +0 -153
- package/public/js/components/PageHead.js +0 -33
- package/public/js/components/Pagination.js +0 -27
- package/public/js/components/RecentTable.js +0 -68
- package/public/js/components/SessionsTable.js +0 -71
- package/public/js/components/SnapshotPanel.js +0 -77
- package/public/js/components/TitleCell.js +0 -40
- package/public/js/components/WorkspacesGrid.js +0 -41
- package/public/js/pages/TerminalsPage.js +0 -74
package/lib/sessions.js
DELETED
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('node:fs/promises');
|
|
4
|
-
const path = require('node:path');
|
|
5
|
-
const os = require('node:os');
|
|
6
|
-
const { exec } = require('node:child_process');
|
|
7
|
-
|
|
8
|
-
const SESSIONS_DIR = path.join(os.homedir(), '.claude', 'sessions');
|
|
9
|
-
const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
10
|
-
|
|
11
|
-
function projectSlugForCwd(cwd) {
|
|
12
|
-
return String(cwd).replace(/[:\\]/g, '-');
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function getLiveClaudePids() {
|
|
16
|
-
return new Promise((resolve) => {
|
|
17
|
-
exec(
|
|
18
|
-
'tasklist /FI "IMAGENAME eq claude.exe" /FO CSV /NH',
|
|
19
|
-
{ windowsHide: true },
|
|
20
|
-
(err, stdout) => {
|
|
21
|
-
if (err) return resolve(new Set());
|
|
22
|
-
const pids = new Set();
|
|
23
|
-
for (const line of stdout.split(/\r?\n/)) {
|
|
24
|
-
const m = line.match(/"claude\.exe","(\d+)"/);
|
|
25
|
-
if (m) pids.add(Number(m[1]));
|
|
26
|
-
}
|
|
27
|
-
resolve(pids);
|
|
28
|
-
}
|
|
29
|
-
);
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async function getAiTitleFromJsonl(jsonlPath) {
|
|
34
|
-
try {
|
|
35
|
-
const stat = await fs.stat(jsonlPath);
|
|
36
|
-
if (stat.size === 0) return null;
|
|
37
|
-
const TAIL = 1024 * 1024;
|
|
38
|
-
const offset = Math.max(0, stat.size - TAIL);
|
|
39
|
-
const readSize = stat.size - offset;
|
|
40
|
-
const fd = await fs.open(jsonlPath, 'r');
|
|
41
|
-
try {
|
|
42
|
-
const buf = Buffer.alloc(readSize);
|
|
43
|
-
await fd.read(buf, 0, readSize, offset);
|
|
44
|
-
const text = buf.toString('utf8');
|
|
45
|
-
const lines = text.split('\n');
|
|
46
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
47
|
-
const line = lines[i];
|
|
48
|
-
if (!line.includes('"type":"ai-title"')) continue;
|
|
49
|
-
try {
|
|
50
|
-
const obj = JSON.parse(line);
|
|
51
|
-
if (obj.aiTitle) return obj.aiTitle;
|
|
52
|
-
} catch {}
|
|
53
|
-
}
|
|
54
|
-
return null;
|
|
55
|
-
} finally {
|
|
56
|
-
await fd.close();
|
|
57
|
-
}
|
|
58
|
-
} catch {
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function listSessions() {
|
|
64
|
-
let files;
|
|
65
|
-
try {
|
|
66
|
-
files = await fs.readdir(SESSIONS_DIR);
|
|
67
|
-
} catch {
|
|
68
|
-
return [];
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const livePids = await getLiveClaudePids();
|
|
72
|
-
|
|
73
|
-
const entries = await Promise.all(
|
|
74
|
-
files
|
|
75
|
-
.filter((f) => f.endsWith('.json'))
|
|
76
|
-
.map(async (file) => {
|
|
77
|
-
const fullPath = path.join(SESSIONS_DIR, file);
|
|
78
|
-
try {
|
|
79
|
-
const raw = await fs.readFile(fullPath, 'utf8');
|
|
80
|
-
const s = JSON.parse(raw);
|
|
81
|
-
if (!livePids.has(Number(s.pid))) return null;
|
|
82
|
-
|
|
83
|
-
const slug = projectSlugForCwd(s.cwd);
|
|
84
|
-
const jsonl = path.join(PROJECTS_DIR, slug, `${s.sessionId}.jsonl`);
|
|
85
|
-
const aiTitle = await getAiTitleFromJsonl(jsonl);
|
|
86
|
-
|
|
87
|
-
return {
|
|
88
|
-
pid: s.pid,
|
|
89
|
-
sessionId: s.sessionId,
|
|
90
|
-
cwd: s.cwd,
|
|
91
|
-
status: s.status || 'unknown',
|
|
92
|
-
startedAt: s.startedAt || null,
|
|
93
|
-
updatedAt: s.updatedAt || null,
|
|
94
|
-
version: s.version || null,
|
|
95
|
-
kind: s.kind || null,
|
|
96
|
-
name: s.name || null,
|
|
97
|
-
aiTitle: aiTitle || null,
|
|
98
|
-
title: aiTitle || s.name || null,
|
|
99
|
-
jsonlPath: jsonl,
|
|
100
|
-
sessionFile: fullPath,
|
|
101
|
-
};
|
|
102
|
-
} catch {
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
105
|
-
})
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
return entries
|
|
109
|
-
.filter(Boolean)
|
|
110
|
-
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Pull cwd/title/firstTimestamp from a jsonl by reading the head (cwd lives in
|
|
114
|
-
// any user/assistant/attachment line) and tailing for the last ai-title.
|
|
115
|
-
async function readJsonlMetadata(jsonlPath) {
|
|
116
|
-
let fd;
|
|
117
|
-
try {
|
|
118
|
-
fd = await fs.open(jsonlPath, 'r');
|
|
119
|
-
const stat = await fd.stat();
|
|
120
|
-
if (stat.size === 0) return { size: 0 };
|
|
121
|
-
|
|
122
|
-
const HEAD = Math.min(stat.size, 128 * 1024);
|
|
123
|
-
const headBuf = Buffer.alloc(HEAD);
|
|
124
|
-
await fd.read(headBuf, 0, HEAD, 0);
|
|
125
|
-
const headText = headBuf.toString('utf8');
|
|
126
|
-
|
|
127
|
-
let cwd = null;
|
|
128
|
-
let gitBranch = null;
|
|
129
|
-
let firstTimestamp = null;
|
|
130
|
-
for (const line of headText.split('\n')) {
|
|
131
|
-
if (!line) continue;
|
|
132
|
-
if (cwd && firstTimestamp) break;
|
|
133
|
-
if (!line.includes('"cwd"') && !line.includes('"timestamp"')) continue;
|
|
134
|
-
try {
|
|
135
|
-
const obj = JSON.parse(line);
|
|
136
|
-
if (obj.cwd && !cwd) cwd = obj.cwd;
|
|
137
|
-
if (obj.gitBranch && !gitBranch) gitBranch = obj.gitBranch;
|
|
138
|
-
if (obj.timestamp && !firstTimestamp) firstTimestamp = obj.timestamp;
|
|
139
|
-
} catch {}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const TAIL = Math.min(stat.size, 512 * 1024);
|
|
143
|
-
const tailBuf = Buffer.alloc(TAIL);
|
|
144
|
-
await fd.read(tailBuf, 0, TAIL, Math.max(0, stat.size - TAIL));
|
|
145
|
-
const tailText = tailBuf.toString('utf8');
|
|
146
|
-
let title = null;
|
|
147
|
-
const tailLines = tailText.split('\n');
|
|
148
|
-
for (let i = tailLines.length - 1; i >= 0; i--) {
|
|
149
|
-
if (!tailLines[i].includes('"type":"ai-title"')) continue;
|
|
150
|
-
try {
|
|
151
|
-
const obj = JSON.parse(tailLines[i]);
|
|
152
|
-
if (obj.aiTitle) { title = obj.aiTitle; break; }
|
|
153
|
-
} catch {}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return { cwd, gitBranch, firstTimestamp, title, size: stat.size };
|
|
157
|
-
} catch {
|
|
158
|
-
return {};
|
|
159
|
-
} finally {
|
|
160
|
-
if (fd) await fd.close().catch(() => {});
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Cheap enumeration of jsonl session files — file stats only, no content read.
|
|
165
|
-
async function enumerateRecentCandidates({ excludeIds = null } = {}) {
|
|
166
|
-
let projectDirs;
|
|
167
|
-
try {
|
|
168
|
-
projectDirs = await fs.readdir(PROJECTS_DIR);
|
|
169
|
-
} catch {
|
|
170
|
-
return [];
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const candidates = [];
|
|
174
|
-
for (const slugDir of projectDirs) {
|
|
175
|
-
const dirPath = path.join(PROJECTS_DIR, slugDir);
|
|
176
|
-
let entries;
|
|
177
|
-
try {
|
|
178
|
-
entries = await fs.readdir(dirPath);
|
|
179
|
-
} catch {
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
for (const file of entries) {
|
|
183
|
-
if (!file.endsWith('.jsonl')) continue;
|
|
184
|
-
const sessionId = file.slice(0, -'.jsonl'.length);
|
|
185
|
-
if (excludeIds && excludeIds.has(sessionId)) continue;
|
|
186
|
-
const fullPath = path.join(dirPath, file);
|
|
187
|
-
try {
|
|
188
|
-
const stat = await fs.stat(fullPath);
|
|
189
|
-
if (stat.size === 0) continue;
|
|
190
|
-
candidates.push({
|
|
191
|
-
sessionId,
|
|
192
|
-
slug: slugDir,
|
|
193
|
-
jsonlPath: fullPath,
|
|
194
|
-
mtime: stat.mtimeMs,
|
|
195
|
-
});
|
|
196
|
-
} catch {}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
201
|
-
return candidates;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Paginated, sorted by mtime desc. Returns { recent, total } so callers can
|
|
205
|
-
// render pagination controls. Cheap stat-only enumeration happens for ALL
|
|
206
|
-
// candidates; expensive jsonl-content reads only on the page slice.
|
|
207
|
-
async function listRecentSessions({ limit = 50, offset = 0, excludeIds = null } = {}) {
|
|
208
|
-
const candidates = await enumerateRecentCandidates({ excludeIds });
|
|
209
|
-
const total = candidates.length;
|
|
210
|
-
const page = candidates.slice(offset, offset + limit);
|
|
211
|
-
|
|
212
|
-
const results = await Promise.all(
|
|
213
|
-
page.map(async (c) => {
|
|
214
|
-
const meta = await readJsonlMetadata(c.jsonlPath);
|
|
215
|
-
const firstTs = meta.firstTimestamp ? Date.parse(meta.firstTimestamp) : null;
|
|
216
|
-
return {
|
|
217
|
-
sessionId: c.sessionId,
|
|
218
|
-
cwd: meta.cwd || null,
|
|
219
|
-
title: meta.title || null,
|
|
220
|
-
gitBranch: meta.gitBranch || null,
|
|
221
|
-
updatedAt: c.mtime,
|
|
222
|
-
startedAt: Number.isFinite(firstTs) ? firstTs : null,
|
|
223
|
-
jsonlPath: c.jsonlPath,
|
|
224
|
-
};
|
|
225
|
-
})
|
|
226
|
-
);
|
|
227
|
-
|
|
228
|
-
// Drop entries with no cwd — can't resume without one. Adjust total
|
|
229
|
-
// accordingly is tricky (would need to enrich all), so we don't — the
|
|
230
|
-
// total is the upper bound of resumable + non-resumable.
|
|
231
|
-
return { recent: results.filter((r) => r.cwd), total };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Look up metadata for a specific sessionId by scanning the projects dir.
|
|
235
|
-
// Used by /api/favorites to display rich metadata for archived sessions.
|
|
236
|
-
async function findSessionMetadata(sessionId) {
|
|
237
|
-
let projectDirs;
|
|
238
|
-
try {
|
|
239
|
-
projectDirs = await fs.readdir(PROJECTS_DIR);
|
|
240
|
-
} catch {
|
|
241
|
-
return null;
|
|
242
|
-
}
|
|
243
|
-
for (const slugDir of projectDirs) {
|
|
244
|
-
const fullPath = path.join(PROJECTS_DIR, slugDir, `${sessionId}.jsonl`);
|
|
245
|
-
try {
|
|
246
|
-
const stat = await fs.stat(fullPath);
|
|
247
|
-
if (stat.size === 0) continue;
|
|
248
|
-
const meta = await readJsonlMetadata(fullPath);
|
|
249
|
-
const firstTs = meta.firstTimestamp ? Date.parse(meta.firstTimestamp) : null;
|
|
250
|
-
return {
|
|
251
|
-
sessionId,
|
|
252
|
-
cwd: meta.cwd || null,
|
|
253
|
-
title: meta.title || null,
|
|
254
|
-
gitBranch: meta.gitBranch || null,
|
|
255
|
-
updatedAt: stat.mtimeMs,
|
|
256
|
-
startedAt: Number.isFinite(firstTs) ? firstTs : null,
|
|
257
|
-
jsonlPath: fullPath,
|
|
258
|
-
};
|
|
259
|
-
} catch {}
|
|
260
|
-
}
|
|
261
|
-
return null;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
module.exports = {
|
|
265
|
-
listSessions,
|
|
266
|
-
listRecentSessions,
|
|
267
|
-
findSessionMetadata,
|
|
268
|
-
projectSlugForCwd,
|
|
269
|
-
getLiveClaudePids,
|
|
270
|
-
SESSIONS_DIR,
|
|
271
|
-
PROJECTS_DIR,
|
|
272
|
-
};
|
package/lib/snapshot.js
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('node:fs/promises');
|
|
4
|
-
const fsSync = require('node:fs');
|
|
5
|
-
const path = require('node:path');
|
|
6
|
-
const { listSessions } = require('./sessions');
|
|
7
|
-
const { launchResume } = require('./launcher');
|
|
8
|
-
const { DATA_DIR } = require('./config');
|
|
9
|
-
|
|
10
|
-
const SNAPSHOT_PATH = path.join(DATA_DIR, 'snapshot.json');
|
|
11
|
-
const SNAPSHOT_HISTORY_DIR = path.join(DATA_DIR, 'snapshots');
|
|
12
|
-
|
|
13
|
-
function ensureDirs() {
|
|
14
|
-
for (const d of [DATA_DIR, SNAPSHOT_HISTORY_DIR]) {
|
|
15
|
-
if (!fsSync.existsSync(d)) fsSync.mkdirSync(d, { recursive: true });
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function snapshotFromSessions(sessions) {
|
|
20
|
-
return {
|
|
21
|
-
takenAt: Date.now(),
|
|
22
|
-
sessions: sessions.map((s) => ({
|
|
23
|
-
pid: s.pid,
|
|
24
|
-
sessionId: s.sessionId,
|
|
25
|
-
cwd: s.cwd,
|
|
26
|
-
title: s.title || null,
|
|
27
|
-
status: s.status,
|
|
28
|
-
updatedAt: s.updatedAt,
|
|
29
|
-
})),
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function tsLabel(ms) {
|
|
34
|
-
const d = new Date(ms);
|
|
35
|
-
const p = (n) => String(n).padStart(2, '0');
|
|
36
|
-
return (
|
|
37
|
-
d.getFullYear() +
|
|
38
|
-
p(d.getMonth() + 1) +
|
|
39
|
-
p(d.getDate()) +
|
|
40
|
-
'-' +
|
|
41
|
-
p(d.getHours()) +
|
|
42
|
-
p(d.getMinutes()) +
|
|
43
|
-
p(d.getSeconds())
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function rotateHistory(keep) {
|
|
48
|
-
if (!keep || keep < 1) return;
|
|
49
|
-
try {
|
|
50
|
-
const files = (await fs.readdir(SNAPSHOT_HISTORY_DIR))
|
|
51
|
-
.filter((f) => f.endsWith('.json'))
|
|
52
|
-
.sort();
|
|
53
|
-
const excess = files.length - keep;
|
|
54
|
-
for (let i = 0; i < excess; i++) {
|
|
55
|
-
await fs.unlink(path.join(SNAPSHOT_HISTORY_DIR, files[i])).catch(() => {});
|
|
56
|
-
}
|
|
57
|
-
} catch {}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function saveSnapshot({ keep = 30 } = {}) {
|
|
61
|
-
ensureDirs();
|
|
62
|
-
const sessions = await listSessions();
|
|
63
|
-
const snap = snapshotFromSessions(sessions);
|
|
64
|
-
const payload = JSON.stringify(snap, null, 2);
|
|
65
|
-
await fs.writeFile(SNAPSHOT_PATH, payload);
|
|
66
|
-
const histPath = path.join(SNAPSHOT_HISTORY_DIR, `${tsLabel(snap.takenAt)}.json`);
|
|
67
|
-
await fs.writeFile(histPath, payload);
|
|
68
|
-
await rotateHistory(keep);
|
|
69
|
-
return snap;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function loadLatestSnapshot() {
|
|
73
|
-
try {
|
|
74
|
-
const raw = await fs.readFile(SNAPSHOT_PATH, 'utf8');
|
|
75
|
-
return JSON.parse(raw);
|
|
76
|
-
} catch (e) {
|
|
77
|
-
if (e.code === 'ENOENT') return null;
|
|
78
|
-
throw e;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function listSnapshotHistory() {
|
|
83
|
-
ensureDirs();
|
|
84
|
-
try {
|
|
85
|
-
const files = (await fs.readdir(SNAPSHOT_HISTORY_DIR))
|
|
86
|
-
.filter((f) => f.endsWith('.json'))
|
|
87
|
-
.sort()
|
|
88
|
-
.reverse();
|
|
89
|
-
return files.map((f) => ({
|
|
90
|
-
file: f,
|
|
91
|
-
path: path.join(SNAPSHOT_HISTORY_DIR, f),
|
|
92
|
-
}));
|
|
93
|
-
} catch {
|
|
94
|
-
return [];
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
async function loadSnapshotByFile(file) {
|
|
99
|
-
const safe = path.basename(file);
|
|
100
|
-
const p = path.join(SNAPSHOT_HISTORY_DIR, safe);
|
|
101
|
-
const raw = await fs.readFile(p, 'utf8');
|
|
102
|
-
return JSON.parse(raw);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function restoreSnapshot(snap, { terminal = 'wt', claudeCommand = 'claude', commandShell = 'pwsh' } = {}) {
|
|
106
|
-
if (!snap || !Array.isArray(snap.sessions)) {
|
|
107
|
-
return { launched: [], skipped: [] };
|
|
108
|
-
}
|
|
109
|
-
const launched = [];
|
|
110
|
-
const skipped = [];
|
|
111
|
-
for (const s of snap.sessions) {
|
|
112
|
-
if (!s.sessionId || !s.cwd) {
|
|
113
|
-
skipped.push({ ...s, reason: 'missing sessionId or cwd' });
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
try {
|
|
117
|
-
const { pid, args } = launchResume({
|
|
118
|
-
cwd: s.cwd,
|
|
119
|
-
sessionId: s.sessionId,
|
|
120
|
-
title: (s.title || s.sessionId.slice(0, 8)),
|
|
121
|
-
terminal,
|
|
122
|
-
claudeCommand,
|
|
123
|
-
commandShell,
|
|
124
|
-
});
|
|
125
|
-
launched.push({ sessionId: s.sessionId, cwd: s.cwd, wtPid: pid, args });
|
|
126
|
-
} catch (e) {
|
|
127
|
-
skipped.push({ ...s, reason: String(e && e.message || e) });
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return { launched, skipped };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
module.exports = {
|
|
134
|
-
saveSnapshot,
|
|
135
|
-
loadLatestSnapshot,
|
|
136
|
-
listSnapshotHistory,
|
|
137
|
-
loadSnapshotByFile,
|
|
138
|
-
restoreSnapshot,
|
|
139
|
-
SNAPSHOT_PATH,
|
|
140
|
-
SNAPSHOT_HISTORY_DIR,
|
|
141
|
-
};
|
package/public/js/actions.js
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
// Mutation actions shared by SessionsPage, FavoritesTable etc. — each
|
|
2
|
-
// optimistically updates the relevant signal and rolls back on error.
|
|
3
|
-
|
|
4
|
-
import { favorites, labels, sessions, recent, config, capabilities, activeTerminalId, selectTab } from './state.js';
|
|
5
|
-
import { api, loadSessions, loadRecent, loadWebTerminals } from './api.js';
|
|
6
|
-
import { setToast } from './toast.js';
|
|
7
|
-
import { ccsmPrompt } from './dialog.js';
|
|
8
|
-
|
|
9
|
-
export async function renameSession(sessionId, currentLabel) {
|
|
10
|
-
const next = await ccsmPrompt('Rename session', currentLabel || '', {
|
|
11
|
-
title: 'Rename session',
|
|
12
|
-
placeholder: 'leave empty to clear the label',
|
|
13
|
-
okLabel: 'Save',
|
|
14
|
-
});
|
|
15
|
-
if (next === null) return;
|
|
16
|
-
const trimmed = next.trim();
|
|
17
|
-
const prev = labels.value[sessionId];
|
|
18
|
-
const nextLabels = { ...labels.value };
|
|
19
|
-
if (trimmed) nextLabels[sessionId] = trimmed;
|
|
20
|
-
else delete nextLabels[sessionId];
|
|
21
|
-
labels.value = nextLabels;
|
|
22
|
-
try {
|
|
23
|
-
if (trimmed) {
|
|
24
|
-
await api('PUT', `/api/labels/${sessionId}`, { label: trimmed });
|
|
25
|
-
setToast(`renamed · ${sessionId.slice(0, 8)}`);
|
|
26
|
-
} else {
|
|
27
|
-
await api('DELETE', `/api/labels/${sessionId}`);
|
|
28
|
-
setToast(`cleared label · ${sessionId.slice(0, 8)}`);
|
|
29
|
-
}
|
|
30
|
-
} catch (e) {
|
|
31
|
-
const rollback = { ...labels.value };
|
|
32
|
-
if (prev !== undefined) rollback[sessionId] = prev;
|
|
33
|
-
else delete rollback[sessionId];
|
|
34
|
-
labels.value = rollback;
|
|
35
|
-
setToast('rename failed: ' + e.message, 'error');
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// snapshotData: { cwd, title, gitBranch } — captured from the source row so
|
|
40
|
-
// the favorite stays renderable after the session leaves live/recent.
|
|
41
|
-
export async function toggleFavorite(sessionId, snapshotData = {}) {
|
|
42
|
-
const wasFav = !!favorites.value[sessionId];
|
|
43
|
-
if (wasFav) {
|
|
44
|
-
const next = { ...favorites.value };
|
|
45
|
-
delete next[sessionId];
|
|
46
|
-
favorites.value = next;
|
|
47
|
-
try { await api('DELETE', `/api/favorites/${sessionId}`); }
|
|
48
|
-
catch (e) { setToast('unfavorite failed: ' + e.message, 'error'); }
|
|
49
|
-
} else {
|
|
50
|
-
const { cwd = '', title = '', gitBranch = '' } = snapshotData;
|
|
51
|
-
favorites.value = {
|
|
52
|
-
...favorites.value,
|
|
53
|
-
[sessionId]: { sessionId, cwd, title, gitBranch, addedAt: Date.now() },
|
|
54
|
-
};
|
|
55
|
-
try { await api('POST', `/api/favorites/${sessionId}`, { cwd, title, gitBranch }); }
|
|
56
|
-
catch (e) { setToast('favorite failed: ' + e.message, 'error'); }
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function focusSession(sessionId) {
|
|
61
|
-
try {
|
|
62
|
-
const r = await api('POST', `/api/sessions/${sessionId}/focus`);
|
|
63
|
-
if (r.ok && r.activated) setToast(`focused · ${r.windowTitle || sessionId.slice(0, 8)}`);
|
|
64
|
-
else if (r.ok) setToast(`window found, focus blocked (${r.windowProcess})`, 'error');
|
|
65
|
-
else setToast(`no window for pid · ${(r.chain || []).map((c) => c.name).join('→')}`, 'error');
|
|
66
|
-
} catch (e) { setToast(e.message, 'error'); }
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export async function resumeSession(sessionId, cwd, { kind = 'resume' } = {}) {
|
|
70
|
-
if (!cwd) return setToast('no cwd for this session', 'error');
|
|
71
|
-
const wantWeb = capabilities.value?.webTerminal
|
|
72
|
-
&& (config.value?.defaultTerminalMode || 'wt') === 'web';
|
|
73
|
-
const terminal = wantWeb ? 'web' : 'wt';
|
|
74
|
-
try {
|
|
75
|
-
const r = await api('POST', `/api/sessions/${sessionId}/resume`, { cwd, terminal });
|
|
76
|
-
if (r.launched?.mode === 'web') {
|
|
77
|
-
setToast(`${kind === 'continue' ? 'continuing' : 'resuming'} in web · ${sessionId.slice(0, 8)}…`);
|
|
78
|
-
await loadWebTerminals();
|
|
79
|
-
if (r.launched.id) activeTerminalId.value = r.launched.id;
|
|
80
|
-
selectTab('terminals');
|
|
81
|
-
} else {
|
|
82
|
-
const verb = kind === 'continue' ? 'continuing' : 'opening wt';
|
|
83
|
-
setToast(`${verb} · ${sessionId.slice(0, 8)}…`);
|
|
84
|
-
}
|
|
85
|
-
if (kind === 'continue') {
|
|
86
|
-
setTimeout(() => loadSessions().catch(() => {}), 3000);
|
|
87
|
-
setTimeout(() => loadRecent().catch(() => {}), 4000);
|
|
88
|
-
}
|
|
89
|
-
} catch (e) { setToast(e.message, 'error'); }
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export async function runFinder() {
|
|
93
|
-
const wantWeb = capabilities.value?.webTerminal
|
|
94
|
-
&& (config.value?.defaultTerminalMode || 'wt') === 'web';
|
|
95
|
-
const terminal = wantWeb ? 'web' : 'wt';
|
|
96
|
-
try {
|
|
97
|
-
const r = await api('POST', '/api/sessions/finder', { terminal });
|
|
98
|
-
if (r.launched?.mode === 'web') {
|
|
99
|
-
await loadWebTerminals();
|
|
100
|
-
if (r.launched.id) activeTerminalId.value = r.launched.id;
|
|
101
|
-
selectTab('terminals');
|
|
102
|
-
setToast('finder launching in web terminal');
|
|
103
|
-
} else {
|
|
104
|
-
setToast('finder session launching in a new wt window');
|
|
105
|
-
}
|
|
106
|
-
} catch (e) { setToast(e.message, 'error'); }
|
|
107
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { html } from '../html.js';
|
|
2
|
-
import { modalOpen } from '../state.js';
|
|
3
|
-
import { IconPlus } from '../icons.js';
|
|
4
|
-
|
|
5
|
-
export function Fab() {
|
|
6
|
-
return html`
|
|
7
|
-
<button class="fab" title="Launch new session" aria-label="Launch new session"
|
|
8
|
-
onClick=${() => (modalOpen.value = true)}>
|
|
9
|
-
<${IconPlus} />
|
|
10
|
-
</button>`;
|
|
11
|
-
}
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { html } from '../html.js';
|
|
2
|
-
import {
|
|
3
|
-
favoritesList, favoritesOffset, favoritesLimit,
|
|
4
|
-
sessions, clockTick,
|
|
5
|
-
} from '../state.js';
|
|
6
|
-
import { fmtAgo, fmtTime } from '../util.js';
|
|
7
|
-
import { focusSession, resumeSession } from '../actions.js';
|
|
8
|
-
import { TitleCell } from './TitleCell.js';
|
|
9
|
-
import { Pagination } from './Pagination.js';
|
|
10
|
-
import { IconMonitor, IconExternal } from '../icons.js';
|
|
11
|
-
|
|
12
|
-
export function FavoritesTable() {
|
|
13
|
-
void clockTick.value;
|
|
14
|
-
const full = favoritesList.value;
|
|
15
|
-
if (favoritesOffset.value >= full.length) {
|
|
16
|
-
favoritesOffset.value = Math.max(0, Math.floor((full.length - 1) / favoritesLimit.value) * favoritesLimit.value);
|
|
17
|
-
}
|
|
18
|
-
const slice = full.slice(favoritesOffset.value, favoritesOffset.value + favoritesLimit.value);
|
|
19
|
-
|
|
20
|
-
if (full.length === 0) {
|
|
21
|
-
return html`<div class="empty" id="favoritesEmpty">No favorites yet. Star a session row to pin it here.</div>`;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
return html`
|
|
25
|
-
<div class="table-scroll">
|
|
26
|
-
<table class="data">
|
|
27
|
-
<thead>
|
|
28
|
-
<tr>
|
|
29
|
-
<th>Title</th>
|
|
30
|
-
<th>Working directory</th>
|
|
31
|
-
<th>Branch</th>
|
|
32
|
-
<th class="num">Pinned</th>
|
|
33
|
-
<th class="col-actions"></th>
|
|
34
|
-
</tr>
|
|
35
|
-
</thead>
|
|
36
|
-
<tbody>${slice.map((f) => html`<${Row} key=${f.sessionId} fav=${f} />`)}</tbody>
|
|
37
|
-
</table>
|
|
38
|
-
</div>
|
|
39
|
-
<${Pagination}
|
|
40
|
-
total=${full.length}
|
|
41
|
-
offset=${favoritesOffset.value}
|
|
42
|
-
limit=${favoritesLimit.value}
|
|
43
|
-
onChange=${(off, lim) => { favoritesOffset.value = off; favoritesLimit.value = lim; }} />`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function Row({ fav: f }) {
|
|
47
|
-
const live = sessions.value.find((s) => s.sessionId === f.sessionId);
|
|
48
|
-
const title = live?.title || f.title;
|
|
49
|
-
const cwd = live?.cwd || f.cwd;
|
|
50
|
-
const branch = f.gitBranch;
|
|
51
|
-
const liveExtra = live ? html` · <span style="color:var(--green);">live</span>` : null;
|
|
52
|
-
const actions = live
|
|
53
|
-
? html`
|
|
54
|
-
<button class="action small" title="raise the wt window"
|
|
55
|
-
onClick=${() => focusSession(f.sessionId)}>
|
|
56
|
-
<${IconMonitor} /> Focus
|
|
57
|
-
</button>`
|
|
58
|
-
: html`
|
|
59
|
-
<button class="action small" title="claude --resume in a fresh wt window"
|
|
60
|
-
disabled=${!cwd}
|
|
61
|
-
onClick=${() => resumeSession(f.sessionId, cwd, { kind: 'continue' })}>
|
|
62
|
-
<${IconExternal} /> Continue
|
|
63
|
-
</button>`;
|
|
64
|
-
|
|
65
|
-
return html`
|
|
66
|
-
<tr>
|
|
67
|
-
<td>
|
|
68
|
-
<${TitleCell}
|
|
69
|
-
sessionId=${f.sessionId}
|
|
70
|
-
title=${title}
|
|
71
|
-
secondaryExtra=${liveExtra}
|
|
72
|
-
snapshotData=${{ cwd: cwd || '', title, gitBranch: branch || '' }} />
|
|
73
|
-
</td>
|
|
74
|
-
<td><div class="path-cell" title=${cwd || ''}>${cwd || ''}</div></td>
|
|
75
|
-
<td>
|
|
76
|
-
${branch ? html`<span class="branch-tag">${branch}</span>` : html`<span class="muted-text">—</span>`}
|
|
77
|
-
</td>
|
|
78
|
-
<td class="num" title=${fmtTime(f.addedAt)}>${fmtAgo(f.addedAt)}</td>
|
|
79
|
-
<td><div class="row-actions">${actions}</div></td>
|
|
80
|
-
</tr>`;
|
|
81
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { html } from '../html.js';
|
|
2
|
-
import { config } from '../state.js';
|
|
3
|
-
|
|
4
|
-
export function Footer() {
|
|
5
|
-
const cfg = config.value;
|
|
6
|
-
return html`
|
|
7
|
-
<footer class="footer-status">
|
|
8
|
-
<span class="fs-key">Data</span> <span class="fs-val">~/.ccsm</span>
|
|
9
|
-
<span class="fs-divider">·</span>
|
|
10
|
-
<span class="fs-key">Workspaces</span> <span class="fs-val">${cfg?.workDir ?? '—'}</span>
|
|
11
|
-
</footer>`;
|
|
12
|
-
}
|