@bakapiano/ccsm 0.10.3 → 0.11.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/CLAUDE.md +475 -475
- package/README.md +190 -190
- package/bin/ccsm.js +194 -194
- package/lib/cliSessionWatcher.js +249 -249
- package/lib/config.js +185 -185
- package/lib/folders.js +96 -96
- package/lib/localCliSessions.js +489 -177
- package/lib/persistedSessions.js +134 -134
- package/lib/webTerminal.js +208 -208
- package/lib/workspace.js +230 -255
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +303 -303
- package/public/css/forms.css +405 -405
- package/public/css/layout.css +160 -160
- package/public/css/modal.css +190 -183
- package/public/css/responsive.css +10 -10
- package/public/css/sidebar.css +616 -601
- package/public/css/terminals.css +294 -294
- package/public/css/tokens.css +81 -79
- package/public/css/wco.css +98 -98
- package/public/css/widgets.css +1596 -1375
- package/public/index.html +105 -103
- package/public/js/api.js +272 -260
- package/public/js/components/AdoptModal.js +343 -171
- package/public/js/components/App.js +35 -35
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +105 -105
- package/public/js/components/Modal.js +51 -51
- package/public/js/components/OfflineBanner.js +93 -93
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/Sidebar.js +270 -270
- package/public/js/components/TerminalView.js +298 -298
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +177 -177
- package/public/js/main.js +140 -140
- package/public/js/pages/AboutPage.js +165 -165
- package/public/js/pages/ConfigurePage.js +475 -487
- package/public/js/pages/LaunchPage.js +369 -369
- package/public/js/pages/SessionsPage.js +97 -97
- package/public/js/state.js +231 -231
- package/public/manifest.webmanifest +15 -15
- package/scripts/install.js +137 -137
- package/server.js +1126 -1117
|
@@ -1,270 +1,270 @@
|
|
|
1
|
-
import { html } from '../html.js';
|
|
2
|
-
import {
|
|
3
|
-
activeTab, sidebarCollapsed, sidebarForcedCollapsed, configDirty, capabilities, serverHealth,
|
|
4
|
-
sessions, folders, sessionsByFolder, foldersCollapsed, activeSessionId,
|
|
5
|
-
selectTab, selectSession, toggleSidebar, toggleFolder, setSidebarWidth,
|
|
6
|
-
} from '../state.js';
|
|
7
|
-
import { createFolder, renameFolder, deleteFolder, reorderFolders, setSessionFolder, deleteSession, resumeSession, setSessionTitle } from '../api.js';
|
|
8
|
-
import { ccsmPrompt, ccsmConfirm } from '../dialog.js';
|
|
9
|
-
import { setToast } from '../toast.js';
|
|
10
|
-
import { fmtAgo } from '../util.js';
|
|
11
|
-
import { clockTick } from '../state.js';
|
|
12
|
-
import { useDragSort } from './useDragSort.js';
|
|
13
|
-
import {
|
|
14
|
-
IconLaunch, IconConfigure,
|
|
15
|
-
IconSidebarToggle, IconPencil, IconClose, IconFolder, IconFolderOpen, BrandMark,
|
|
16
|
-
} from '../icons.js';
|
|
17
|
-
|
|
18
|
-
function NavItem({ tab, icon, label, dirty }) {
|
|
19
|
-
const selected = activeTab.value === tab;
|
|
20
|
-
return html`
|
|
21
|
-
<button class=${`nav-item${dirty ? ' has-changes' : ''}${selected ? ' is-active' : ''}`}
|
|
22
|
-
role="tab" aria-selected=${selected ? 'true' : 'false'}
|
|
23
|
-
onClick=${() => selectTab(tab)}>
|
|
24
|
-
<span class="nav-icon">${icon}</span>
|
|
25
|
-
<span class="nav-label">${label}</span>
|
|
26
|
-
</button>`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// One row in the session tree. Click → open in main pane. Right-click /
|
|
30
|
-
// long-press not implemented; "..." menu via the inline kebab.
|
|
31
|
-
function SessionRow({ s }) {
|
|
32
|
-
clockTick.value; // subscribe for fmtAgo refresh
|
|
33
|
-
const isActive = activeSessionId.value === s.id;
|
|
34
|
-
const running = s.status === 'running';
|
|
35
|
-
const title = s.title || s.workspace || s.id.slice(0, 12);
|
|
36
|
-
|
|
37
|
-
const onClick = async (ev) => {
|
|
38
|
-
ev.preventDefault();
|
|
39
|
-
selectSession(s.id);
|
|
40
|
-
// Auto-resume on click if the session is stopped — saves the user
|
|
41
|
-
// from a second click on the "Resume" button in the right pane.
|
|
42
|
-
// No-op if already running.
|
|
43
|
-
if (s.status !== 'running') {
|
|
44
|
-
try { await resumeSession(s.id); }
|
|
45
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
|
|
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
|
-
const onRenameClick = async (ev) => {
|
|
91
|
-
ev.preventDefault();
|
|
92
|
-
ev.stopPropagation();
|
|
93
|
-
const next = await ccsmPrompt('New title', title, { title: 'Rename session', okLabel: 'Save' });
|
|
94
|
-
if (next === null) return;
|
|
95
|
-
try { await setSessionTitle(s.id, next.trim()); }
|
|
96
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
const onDeleteClick = async (ev) => {
|
|
100
|
-
ev.preventDefault();
|
|
101
|
-
ev.stopPropagation();
|
|
102
|
-
const ok = await ccsmConfirm(`Delete session ${title}? PTY will be killed if alive.`, {
|
|
103
|
-
title: 'Delete session', okLabel: 'Delete', danger: true });
|
|
104
|
-
if (!ok) return;
|
|
105
|
-
try {
|
|
106
|
-
await deleteSession(s.id);
|
|
107
|
-
if (activeSessionId.value === s.id) activeSessionId.value = null;
|
|
108
|
-
} catch (e) { setToast(e.message, 'error'); }
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
return html`
|
|
112
|
-
<div class=${`tree-session${isActive ? ' is-active' : ''}${running ? ' is-running' : ' is-stopped'}`}
|
|
113
|
-
onClick=${onClick}
|
|
114
|
-
onContextMenu=${onContext}
|
|
115
|
-
title=${`${title}\n${s.cwd}\n${running ? 'running' : 'stopped'} · ${s.cliId}`}>
|
|
116
|
-
<span class=${`tree-dot ${running ? 'is-running' : 'is-stopped'}`}></span>
|
|
117
|
-
<span class="tree-label">${title}</span>
|
|
118
|
-
<span class="tree-session-actions">
|
|
119
|
-
<button class="tree-session-action" title="rename" onClick=${onRenameClick}><${IconPencil} /></button>
|
|
120
|
-
<button class="tree-session-action" title="delete" onClick=${onDeleteClick}><${IconClose} /></button>
|
|
121
|
-
</span>
|
|
122
|
-
<span class="tree-meta">${fmtAgo(s.lastActiveAt)}</span>
|
|
123
|
-
</div>`;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
|
|
127
|
-
const key = folder ? folder.id : 'unsorted';
|
|
128
|
-
const collapsed = !!foldersCollapsed.value[key];
|
|
129
|
-
const name = folder ? folder.name : 'Unsorted';
|
|
130
|
-
const onToggle = () => toggleFolder(folder ? folder.id : null);
|
|
131
|
-
|
|
132
|
-
const onRename = async (ev) => {
|
|
133
|
-
ev.stopPropagation();
|
|
134
|
-
if (!folder) return;
|
|
135
|
-
const next = await ccsmPrompt('Rename folder', folder.name, { title: folder.name, okLabel: 'Save' });
|
|
136
|
-
if (next === null || !next.trim()) return;
|
|
137
|
-
try { await renameFolder(folder.id, next.trim()); }
|
|
138
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
const onDelete = async (ev) => {
|
|
142
|
-
ev.stopPropagation();
|
|
143
|
-
if (!folder) return;
|
|
144
|
-
const ok = await ccsmConfirm(`Delete folder "${folder.name}"? Sessions inside move to Unsorted.`, {
|
|
145
|
-
title: 'Delete folder', okLabel: 'Delete', danger: true });
|
|
146
|
-
if (!ok) return;
|
|
147
|
-
try { await deleteFolder(folder.id); }
|
|
148
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
return html`
|
|
152
|
-
<div class="tree-folder" ...${dndRow || {}}>
|
|
153
|
-
<button class=${`tree-folder-head${collapsed ? '' : ' is-open'}`} onClick=${onToggle}
|
|
154
|
-
...${dndHandle || {}}>
|
|
155
|
-
<span class="tree-folder-icon">
|
|
156
|
-
${collapsed ? html`<${IconFolder} />` : html`<${IconFolderOpen} />`}
|
|
157
|
-
</span>
|
|
158
|
-
<span class="tree-folder-name">${name}</span>
|
|
159
|
-
${folder ? html`
|
|
160
|
-
<span class="tree-folder-actions">
|
|
161
|
-
<button class="tree-folder-action" title="rename" onClick=${onRename}><${IconPencil} /></button>
|
|
162
|
-
<button class="tree-folder-action" title="delete" onClick=${onDelete}><${IconClose} /></button>
|
|
163
|
-
</span>` : null}
|
|
164
|
-
</button>
|
|
165
|
-
${!collapsed ? html`
|
|
166
|
-
<div class="tree-folder-body">
|
|
167
|
-
${sessionList.length === 0
|
|
168
|
-
? html`<div class="tree-empty">no sessions</div>`
|
|
169
|
-
: sessionList.map((s) => html`<${SessionRow} key=${s.id} s=${s} />`)}
|
|
170
|
-
</div>
|
|
171
|
-
` : null}
|
|
172
|
-
</div>`;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function SessionTree() {
|
|
176
|
-
const grouped = sessionsByFolder.value;
|
|
177
|
-
const orderedFolders = folders.value;
|
|
178
|
-
const dnd = useDragSort(
|
|
179
|
-
orderedFolders.map((f) => f.id),
|
|
180
|
-
async (nextIds) => {
|
|
181
|
-
try { await reorderFolders(nextIds); }
|
|
182
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
183
|
-
},
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
const onNewFolder = async () => {
|
|
187
|
-
const name = await ccsmPrompt('Folder name', '', { title: 'New folder', okLabel: 'Create' });
|
|
188
|
-
if (!name || !name.trim()) return;
|
|
189
|
-
try { await createFolder(name.trim()); }
|
|
190
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
return html`
|
|
194
|
-
<div class="tree">
|
|
195
|
-
<div class="tree-head">
|
|
196
|
-
<span class="tree-head-label">Sessions</span>
|
|
197
|
-
</div>
|
|
198
|
-
${orderedFolders.map((f) => html`
|
|
199
|
-
<${FolderGroup} key=${f.id} folder=${f}
|
|
200
|
-
sessionList=${grouped.get(f.id) || []}
|
|
201
|
-
dndHandle=${dnd.handleProps(f.id)}
|
|
202
|
-
dndRow=${dnd.rowProps(f.id)} />`)}
|
|
203
|
-
<${FolderGroup} folder=${null} sessionList=${grouped.get(null) || []} />
|
|
204
|
-
</div>`;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export function Sidebar() {
|
|
208
|
-
const collapsed = sidebarCollapsed.value || sidebarForcedCollapsed.value;
|
|
209
|
-
const forced = sidebarForcedCollapsed.value;
|
|
210
|
-
|
|
211
|
-
const onResizeStart = (ev) => {
|
|
212
|
-
if (collapsed) return;
|
|
213
|
-
ev.preventDefault();
|
|
214
|
-
const el = ev.currentTarget;
|
|
215
|
-
el.setPointerCapture(ev.pointerId);
|
|
216
|
-
document.body.classList.add('is-resizing-sidebar');
|
|
217
|
-
const move = (e) => setSidebarWidth(e.clientX);
|
|
218
|
-
const up = () => {
|
|
219
|
-
try { el.releasePointerCapture(ev.pointerId); } catch {}
|
|
220
|
-
document.body.classList.remove('is-resizing-sidebar');
|
|
221
|
-
el.removeEventListener('pointermove', move);
|
|
222
|
-
el.removeEventListener('pointerup', up);
|
|
223
|
-
el.removeEventListener('pointercancel', up);
|
|
224
|
-
};
|
|
225
|
-
el.addEventListener('pointermove', move);
|
|
226
|
-
el.addEventListener('pointerup', up);
|
|
227
|
-
el.addEventListener('pointercancel', up);
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
return html`
|
|
231
|
-
<aside class="sidebar" data-collapsed=${collapsed ? 'true' : 'false'}>
|
|
232
|
-
<div class="sidebar-top">
|
|
233
|
-
<button class="sidebar-brand sidebar-brand-button"
|
|
234
|
-
role="tab" aria-selected=${activeTab.value === 'about' ? 'true' : 'false'}
|
|
235
|
-
title="About"
|
|
236
|
-
onClick=${() => selectTab('about')}>
|
|
237
|
-
<span class="brand-mark"><${BrandMark} /></span>
|
|
238
|
-
<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
|
-
</button>
|
|
243
|
-
</div>
|
|
244
|
-
|
|
245
|
-
<nav class="sidebar-nav compact" role="tablist" aria-label="Sections">
|
|
246
|
-
<${NavItem} tab="launch" icon=${html`<${IconLaunch} />`} label="New Session" />
|
|
247
|
-
<${NavItem} tab="configure" icon=${html`<${IconConfigure} />`} label="Settings" dirty=${configDirty.value} />
|
|
248
|
-
</nav>
|
|
249
|
-
|
|
250
|
-
${!collapsed ? html`<${SessionTree} />` : null}
|
|
251
|
-
|
|
252
|
-
<div class="sidebar-foot">
|
|
253
|
-
${!forced ? html`
|
|
254
|
-
<button class="util-item collapse-toggle" aria-label=${collapsed ? 'expand sidebar' : 'collapse sidebar'}
|
|
255
|
-
title=${collapsed ? 'expand sidebar' : 'collapse sidebar'}
|
|
256
|
-
onClick=${toggleSidebar}>
|
|
257
|
-
<span class="nav-icon"><${IconSidebarToggle} /></span>
|
|
258
|
-
</button>
|
|
259
|
-
` : null}
|
|
260
|
-
</div>
|
|
261
|
-
|
|
262
|
-
${!collapsed ? html`
|
|
263
|
-
<div class="sidebar-resize-handle" role="separator" aria-orientation="vertical"
|
|
264
|
-
aria-label="resize sidebar"
|
|
265
|
-
title="drag to resize · double-click to reset"
|
|
266
|
-
onPointerDown=${onResizeStart}
|
|
267
|
-
onDblClick=${() => setSidebarWidth(232)}></div>
|
|
268
|
-
` : null}
|
|
269
|
-
</aside>`;
|
|
270
|
-
}
|
|
1
|
+
import { html } from '../html.js';
|
|
2
|
+
import {
|
|
3
|
+
activeTab, sidebarCollapsed, sidebarForcedCollapsed, configDirty, capabilities, serverHealth,
|
|
4
|
+
sessions, folders, sessionsByFolder, foldersCollapsed, activeSessionId,
|
|
5
|
+
selectTab, selectSession, toggleSidebar, toggleFolder, setSidebarWidth,
|
|
6
|
+
} from '../state.js';
|
|
7
|
+
import { createFolder, renameFolder, deleteFolder, reorderFolders, setSessionFolder, deleteSession, resumeSession, setSessionTitle } from '../api.js';
|
|
8
|
+
import { ccsmPrompt, ccsmConfirm } from '../dialog.js';
|
|
9
|
+
import { setToast } from '../toast.js';
|
|
10
|
+
import { fmtAgo } from '../util.js';
|
|
11
|
+
import { clockTick } from '../state.js';
|
|
12
|
+
import { useDragSort } from './useDragSort.js';
|
|
13
|
+
import {
|
|
14
|
+
IconLaunch, IconConfigure,
|
|
15
|
+
IconSidebarToggle, IconPencil, IconClose, IconFolder, IconFolderOpen, BrandMark,
|
|
16
|
+
} from '../icons.js';
|
|
17
|
+
|
|
18
|
+
function NavItem({ tab, icon, label, dirty }) {
|
|
19
|
+
const selected = activeTab.value === tab;
|
|
20
|
+
return html`
|
|
21
|
+
<button class=${`nav-item${dirty ? ' has-changes' : ''}${selected ? ' is-active' : ''}`}
|
|
22
|
+
role="tab" aria-selected=${selected ? 'true' : 'false'}
|
|
23
|
+
onClick=${() => selectTab(tab)}>
|
|
24
|
+
<span class="nav-icon">${icon}</span>
|
|
25
|
+
<span class="nav-label">${label}</span>
|
|
26
|
+
</button>`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// One row in the session tree. Click → open in main pane. Right-click /
|
|
30
|
+
// long-press not implemented; "..." menu via the inline kebab.
|
|
31
|
+
function SessionRow({ s }) {
|
|
32
|
+
clockTick.value; // subscribe for fmtAgo refresh
|
|
33
|
+
const isActive = activeSessionId.value === s.id;
|
|
34
|
+
const running = s.status === 'running';
|
|
35
|
+
const title = s.title || s.workspace || s.id.slice(0, 12);
|
|
36
|
+
|
|
37
|
+
const onClick = async (ev) => {
|
|
38
|
+
ev.preventDefault();
|
|
39
|
+
selectSession(s.id);
|
|
40
|
+
// Auto-resume on click if the session is stopped — saves the user
|
|
41
|
+
// from a second click on the "Resume" button in the right pane.
|
|
42
|
+
// No-op if already running.
|
|
43
|
+
if (s.status !== 'running') {
|
|
44
|
+
try { await resumeSession(s.id); }
|
|
45
|
+
catch (e) { setToast(e.message, 'error'); }
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
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
|
+
const onRenameClick = async (ev) => {
|
|
91
|
+
ev.preventDefault();
|
|
92
|
+
ev.stopPropagation();
|
|
93
|
+
const next = await ccsmPrompt('New title', title, { title: 'Rename session', okLabel: 'Save' });
|
|
94
|
+
if (next === null) return;
|
|
95
|
+
try { await setSessionTitle(s.id, next.trim()); }
|
|
96
|
+
catch (e) { setToast(e.message, 'error'); }
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const onDeleteClick = async (ev) => {
|
|
100
|
+
ev.preventDefault();
|
|
101
|
+
ev.stopPropagation();
|
|
102
|
+
const ok = await ccsmConfirm(`Delete session ${title}? PTY will be killed if alive.`, {
|
|
103
|
+
title: 'Delete session', okLabel: 'Delete', danger: true });
|
|
104
|
+
if (!ok) return;
|
|
105
|
+
try {
|
|
106
|
+
await deleteSession(s.id);
|
|
107
|
+
if (activeSessionId.value === s.id) activeSessionId.value = null;
|
|
108
|
+
} catch (e) { setToast(e.message, 'error'); }
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return html`
|
|
112
|
+
<div class=${`tree-session${isActive ? ' is-active' : ''}${running ? ' is-running' : ' is-stopped'}`}
|
|
113
|
+
onClick=${onClick}
|
|
114
|
+
onContextMenu=${onContext}
|
|
115
|
+
title=${`${title}\n${s.cwd}\n${running ? 'running' : 'stopped'} · ${s.cliId}`}>
|
|
116
|
+
<span class=${`tree-dot ${running ? 'is-running' : 'is-stopped'}`}></span>
|
|
117
|
+
<span class="tree-label">${title}</span>
|
|
118
|
+
<span class="tree-session-actions">
|
|
119
|
+
<button class="tree-session-action" title="rename" onClick=${onRenameClick}><${IconPencil} /></button>
|
|
120
|
+
<button class="tree-session-action" title="delete" onClick=${onDeleteClick}><${IconClose} /></button>
|
|
121
|
+
</span>
|
|
122
|
+
<span class="tree-meta">${fmtAgo(s.lastActiveAt)}</span>
|
|
123
|
+
</div>`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
|
|
127
|
+
const key = folder ? folder.id : 'unsorted';
|
|
128
|
+
const collapsed = !!foldersCollapsed.value[key];
|
|
129
|
+
const name = folder ? folder.name : 'Unsorted';
|
|
130
|
+
const onToggle = () => toggleFolder(folder ? folder.id : null);
|
|
131
|
+
|
|
132
|
+
const onRename = async (ev) => {
|
|
133
|
+
ev.stopPropagation();
|
|
134
|
+
if (!folder) return;
|
|
135
|
+
const next = await ccsmPrompt('Rename folder', folder.name, { title: folder.name, okLabel: 'Save' });
|
|
136
|
+
if (next === null || !next.trim()) return;
|
|
137
|
+
try { await renameFolder(folder.id, next.trim()); }
|
|
138
|
+
catch (e) { setToast(e.message, 'error'); }
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const onDelete = async (ev) => {
|
|
142
|
+
ev.stopPropagation();
|
|
143
|
+
if (!folder) return;
|
|
144
|
+
const ok = await ccsmConfirm(`Delete folder "${folder.name}"? Sessions inside move to Unsorted.`, {
|
|
145
|
+
title: 'Delete folder', okLabel: 'Delete', danger: true });
|
|
146
|
+
if (!ok) return;
|
|
147
|
+
try { await deleteFolder(folder.id); }
|
|
148
|
+
catch (e) { setToast(e.message, 'error'); }
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return html`
|
|
152
|
+
<div class="tree-folder" ...${dndRow || {}}>
|
|
153
|
+
<button class=${`tree-folder-head${collapsed ? '' : ' is-open'}`} onClick=${onToggle}
|
|
154
|
+
...${dndHandle || {}}>
|
|
155
|
+
<span class="tree-folder-icon">
|
|
156
|
+
${collapsed ? html`<${IconFolder} />` : html`<${IconFolderOpen} />`}
|
|
157
|
+
</span>
|
|
158
|
+
<span class="tree-folder-name">${name}</span>
|
|
159
|
+
${folder ? html`
|
|
160
|
+
<span class="tree-folder-actions">
|
|
161
|
+
<button class="tree-folder-action" title="rename" onClick=${onRename}><${IconPencil} /></button>
|
|
162
|
+
<button class="tree-folder-action" title="delete" onClick=${onDelete}><${IconClose} /></button>
|
|
163
|
+
</span>` : null}
|
|
164
|
+
</button>
|
|
165
|
+
${!collapsed ? html`
|
|
166
|
+
<div class="tree-folder-body">
|
|
167
|
+
${sessionList.length === 0
|
|
168
|
+
? html`<div class="tree-empty">no sessions</div>`
|
|
169
|
+
: sessionList.map((s) => html`<${SessionRow} key=${s.id} s=${s} />`)}
|
|
170
|
+
</div>
|
|
171
|
+
` : null}
|
|
172
|
+
</div>`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function SessionTree() {
|
|
176
|
+
const grouped = sessionsByFolder.value;
|
|
177
|
+
const orderedFolders = folders.value;
|
|
178
|
+
const dnd = useDragSort(
|
|
179
|
+
orderedFolders.map((f) => f.id),
|
|
180
|
+
async (nextIds) => {
|
|
181
|
+
try { await reorderFolders(nextIds); }
|
|
182
|
+
catch (e) { setToast(e.message, 'error'); }
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const onNewFolder = async () => {
|
|
187
|
+
const name = await ccsmPrompt('Folder name', '', { title: 'New folder', okLabel: 'Create' });
|
|
188
|
+
if (!name || !name.trim()) return;
|
|
189
|
+
try { await createFolder(name.trim()); }
|
|
190
|
+
catch (e) { setToast(e.message, 'error'); }
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return html`
|
|
194
|
+
<div class="tree">
|
|
195
|
+
<div class="tree-head">
|
|
196
|
+
<span class="tree-head-label">Sessions</span>
|
|
197
|
+
</div>
|
|
198
|
+
${orderedFolders.map((f) => html`
|
|
199
|
+
<${FolderGroup} key=${f.id} folder=${f}
|
|
200
|
+
sessionList=${grouped.get(f.id) || []}
|
|
201
|
+
dndHandle=${dnd.handleProps(f.id)}
|
|
202
|
+
dndRow=${dnd.rowProps(f.id)} />`)}
|
|
203
|
+
<${FolderGroup} folder=${null} sessionList=${grouped.get(null) || []} />
|
|
204
|
+
</div>`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function Sidebar() {
|
|
208
|
+
const collapsed = sidebarCollapsed.value || sidebarForcedCollapsed.value;
|
|
209
|
+
const forced = sidebarForcedCollapsed.value;
|
|
210
|
+
|
|
211
|
+
const onResizeStart = (ev) => {
|
|
212
|
+
if (collapsed) return;
|
|
213
|
+
ev.preventDefault();
|
|
214
|
+
const el = ev.currentTarget;
|
|
215
|
+
el.setPointerCapture(ev.pointerId);
|
|
216
|
+
document.body.classList.add('is-resizing-sidebar');
|
|
217
|
+
const move = (e) => setSidebarWidth(e.clientX);
|
|
218
|
+
const up = () => {
|
|
219
|
+
try { el.releasePointerCapture(ev.pointerId); } catch {}
|
|
220
|
+
document.body.classList.remove('is-resizing-sidebar');
|
|
221
|
+
el.removeEventListener('pointermove', move);
|
|
222
|
+
el.removeEventListener('pointerup', up);
|
|
223
|
+
el.removeEventListener('pointercancel', up);
|
|
224
|
+
};
|
|
225
|
+
el.addEventListener('pointermove', move);
|
|
226
|
+
el.addEventListener('pointerup', up);
|
|
227
|
+
el.addEventListener('pointercancel', up);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
return html`
|
|
231
|
+
<aside class="sidebar" data-collapsed=${collapsed ? 'true' : 'false'}>
|
|
232
|
+
<div class="sidebar-top">
|
|
233
|
+
<button class="sidebar-brand sidebar-brand-button"
|
|
234
|
+
role="tab" aria-selected=${activeTab.value === 'about' ? 'true' : 'false'}
|
|
235
|
+
title="About"
|
|
236
|
+
onClick=${() => selectTab('about')}>
|
|
237
|
+
<span class="brand-mark"><${BrandMark} /></span>
|
|
238
|
+
<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
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<nav class="sidebar-nav compact" role="tablist" aria-label="Sections">
|
|
246
|
+
<${NavItem} tab="launch" icon=${html`<${IconLaunch} />`} label="New Session" />
|
|
247
|
+
<${NavItem} tab="configure" icon=${html`<${IconConfigure} />`} label="Settings" dirty=${configDirty.value} />
|
|
248
|
+
</nav>
|
|
249
|
+
|
|
250
|
+
${!collapsed ? html`<${SessionTree} />` : null}
|
|
251
|
+
|
|
252
|
+
<div class="sidebar-foot">
|
|
253
|
+
${!forced ? html`
|
|
254
|
+
<button class="util-item collapse-toggle" aria-label=${collapsed ? 'expand sidebar' : 'collapse sidebar'}
|
|
255
|
+
title=${collapsed ? 'expand sidebar' : 'collapse sidebar'}
|
|
256
|
+
onClick=${toggleSidebar}>
|
|
257
|
+
<span class="nav-icon"><${IconSidebarToggle} /></span>
|
|
258
|
+
</button>
|
|
259
|
+
` : null}
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
${!collapsed ? html`
|
|
263
|
+
<div class="sidebar-resize-handle" role="separator" aria-orientation="vertical"
|
|
264
|
+
aria-label="resize sidebar"
|
|
265
|
+
title="drag to resize · double-click to reset"
|
|
266
|
+
onPointerDown=${onResizeStart}
|
|
267
|
+
onDblClick=${() => setSidebarWidth(232)}></div>
|
|
268
|
+
` : null}
|
|
269
|
+
</aside>`;
|
|
270
|
+
}
|