@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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Tiny popover primitive — positions a floating panel relative to an
|
|
2
|
+
// anchor element, closes on outside click + Escape. Used by the unified
|
|
3
|
+
// pickers (CLI / Folder / Repo) so they all share interaction behavior.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// const [open, setOpen] = useState(false);
|
|
7
|
+
// const anchor = useRef(null);
|
|
8
|
+
// <button ref=${anchor} onClick=${() => setOpen(true)}>Trigger</button>
|
|
9
|
+
// ${open ? html`<${Popover} anchor=${anchor} onClose=${() => setOpen(false)}>
|
|
10
|
+
// ...panel contents...
|
|
11
|
+
// </${Popover}>` : null}
|
|
12
|
+
|
|
13
|
+
import { html } from '../html.js';
|
|
14
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
15
|
+
import { createPortal } from 'preact/compat';
|
|
16
|
+
|
|
17
|
+
export function Popover({ anchor, onClose, align = 'left', width, children }) {
|
|
18
|
+
const panelRef = useRef(null);
|
|
19
|
+
const [pos, setPos] = useState({ top: 0, left: 0, width: width || 320 });
|
|
20
|
+
|
|
21
|
+
useLayoutEffect(() => {
|
|
22
|
+
const a = anchor && anchor.current;
|
|
23
|
+
if (!a) return;
|
|
24
|
+
const rect = a.getBoundingClientRect();
|
|
25
|
+
const w = width || Math.max(rect.width, 320);
|
|
26
|
+
let left = align === 'right' ? rect.right - w : rect.left;
|
|
27
|
+
// Clamp to viewport with 8px margin.
|
|
28
|
+
left = Math.max(8, Math.min(window.innerWidth - w - 8, left));
|
|
29
|
+
const top = rect.bottom + 6;
|
|
30
|
+
setPos({ top, left, width: w });
|
|
31
|
+
}, [anchor, align, width]);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const onDown = (ev) => {
|
|
35
|
+
if (panelRef.current?.contains(ev.target)) return;
|
|
36
|
+
if (anchor.current?.contains(ev.target)) return;
|
|
37
|
+
onClose?.();
|
|
38
|
+
};
|
|
39
|
+
const onKey = (ev) => { if (ev.key === 'Escape') onClose?.(); };
|
|
40
|
+
document.addEventListener('mousedown', onDown, true);
|
|
41
|
+
document.addEventListener('keydown', onKey, true);
|
|
42
|
+
return () => {
|
|
43
|
+
document.removeEventListener('mousedown', onDown, true);
|
|
44
|
+
document.removeEventListener('keydown', onKey, true);
|
|
45
|
+
};
|
|
46
|
+
}, [anchor, onClose]);
|
|
47
|
+
|
|
48
|
+
return createPortal(
|
|
49
|
+
html`<div ref=${panelRef} class="popover-panel"
|
|
50
|
+
style=${`top:${pos.top}px;left:${pos.left}px;width:${pos.width}px;`}>
|
|
51
|
+
${children}
|
|
52
|
+
</div>`,
|
|
53
|
+
document.body
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -1,32 +1,213 @@
|
|
|
1
1
|
import { html } from '../html.js';
|
|
2
2
|
import {
|
|
3
|
-
activeTab, sidebarCollapsed,
|
|
4
|
-
|
|
3
|
+
activeTab, sidebarCollapsed, sidebarForcedCollapsed, configDirty, capabilities, serverHealth,
|
|
4
|
+
sessions, folders, sessionsByFolder, foldersCollapsed, activeSessionId,
|
|
5
|
+
selectTab, selectSession, toggleSidebar, toggleFolder, setSidebarWidth,
|
|
5
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';
|
|
6
13
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
14
|
+
IconLaunch, IconConfigure,
|
|
15
|
+
IconSidebarToggle, IconPencil, IconClose, IconFolder, IconFolderOpen, BrandMark,
|
|
9
16
|
} from '../icons.js';
|
|
10
17
|
|
|
11
|
-
function NavItem({ tab, icon, label,
|
|
18
|
+
function NavItem({ tab, icon, label, dirty }) {
|
|
12
19
|
const selected = activeTab.value === tab;
|
|
13
|
-
const cls = `nav-item${dirty ? ' has-changes' : ''}`;
|
|
14
20
|
return html`
|
|
15
|
-
<button class=${
|
|
21
|
+
<button class=${`nav-item${dirty ? ' has-changes' : ''}${selected ? ' is-active' : ''}`}
|
|
22
|
+
role="tab" aria-selected=${selected ? 'true' : 'false'}
|
|
16
23
|
onClick=${() => selectTab(tab)}>
|
|
17
24
|
<span class="nav-icon">${icon}</span>
|
|
18
25
|
<span class="nav-label">${label}</span>
|
|
19
|
-
${badge != null ? html`<span class="nav-badge">${badge}</span>` : null}
|
|
20
26
|
</button>`;
|
|
21
27
|
}
|
|
22
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
|
+
|
|
23
207
|
export function Sidebar() {
|
|
24
|
-
const collapsed = sidebarCollapsed.value;
|
|
208
|
+
const collapsed = sidebarCollapsed.value || sidebarForcedCollapsed.value;
|
|
209
|
+
const forced = sidebarForcedCollapsed.value;
|
|
25
210
|
|
|
26
|
-
// Drag-to-resize handle. Pointer events let one handler cover mouse,
|
|
27
|
-
// touch, pen uniformly + setPointerCapture means dragging continues
|
|
28
|
-
// even if cursor leaves the 4px-wide handle. Collapsed sidebars don't
|
|
29
|
-
// expose a handle — Collapse-toggle is the only way out/in.
|
|
30
211
|
const onResizeStart = (ev) => {
|
|
31
212
|
if (collapsed) return;
|
|
32
213
|
ev.preventDefault();
|
|
@@ -34,8 +215,8 @@ export function Sidebar() {
|
|
|
34
215
|
el.setPointerCapture(ev.pointerId);
|
|
35
216
|
document.body.classList.add('is-resizing-sidebar');
|
|
36
217
|
const move = (e) => setSidebarWidth(e.clientX);
|
|
37
|
-
const up = (
|
|
38
|
-
el.releasePointerCapture(ev.pointerId);
|
|
218
|
+
const up = () => {
|
|
219
|
+
try { el.releasePointerCapture(ev.pointerId); } catch {}
|
|
39
220
|
document.body.classList.remove('is-resizing-sidebar');
|
|
40
221
|
el.removeEventListener('pointermove', move);
|
|
41
222
|
el.removeEventListener('pointerup', up);
|
|
@@ -48,28 +229,34 @@ export function Sidebar() {
|
|
|
48
229
|
|
|
49
230
|
return html`
|
|
50
231
|
<aside class="sidebar" data-collapsed=${collapsed ? 'true' : 'false'}>
|
|
51
|
-
<div class="sidebar-
|
|
52
|
-
<
|
|
53
|
-
|
|
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>
|
|
54
243
|
</div>
|
|
55
244
|
|
|
56
|
-
<nav class="sidebar-nav" role="tablist" aria-label="Sections">
|
|
57
|
-
<${NavItem} tab="
|
|
58
|
-
<${NavItem} tab="
|
|
59
|
-
${capabilities.value.webTerminal ? html`
|
|
60
|
-
<${NavItem} tab="terminals" icon=${html`<${IconTerminal} />`} label="Terminals" badge=${webTerminals.value.length || null} />
|
|
61
|
-
` : null}
|
|
62
|
-
<${NavItem} tab="configure" icon=${html`<${IconConfigure} />`} label="Configure" dirty=${configDirty.value} />
|
|
63
|
-
<${NavItem} tab="about" icon=${html`<${IconInfo} />`} label="About" />
|
|
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} />
|
|
64
248
|
</nav>
|
|
65
249
|
|
|
250
|
+
${!collapsed ? html`<${SessionTree} />` : null}
|
|
251
|
+
|
|
66
252
|
<div class="sidebar-foot">
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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}
|
|
73
260
|
</div>
|
|
74
261
|
|
|
75
262
|
${!collapsed ? html`
|
|
@@ -41,7 +41,7 @@ export function TerminalView({ terminalId }) {
|
|
|
41
41
|
if (!terminalId || !hostRef.current) return;
|
|
42
42
|
|
|
43
43
|
const term = new Terminal({
|
|
44
|
-
fontFamily: '"
|
|
44
|
+
fontFamily: '"Cascadia Mono", "Geist Mono", "JetBrains Mono", Consolas, monospace',
|
|
45
45
|
fontSize: 13,
|
|
46
46
|
lineHeight: 1.2,
|
|
47
47
|
cursorBlink: true,
|
|
@@ -86,7 +86,8 @@ export function TerminalView({ terminalId }) {
|
|
|
86
86
|
} catch (e) {
|
|
87
87
|
console.warn('[ccsm] WebGL addon failed, using DOM renderer:', e);
|
|
88
88
|
}
|
|
89
|
-
|
|
89
|
+
const host = hostRef.current;
|
|
90
|
+
term.open(host);
|
|
90
91
|
// Defer fit one tick so the container has measured layout
|
|
91
92
|
requestAnimationFrame(() => { try { fit.fit(); } catch {} });
|
|
92
93
|
termRef.current = term;
|
|
@@ -124,6 +125,29 @@ export function TerminalView({ terminalId }) {
|
|
|
124
125
|
const ro = new ResizeObserver(() => { try { fit.fit(); } catch {} });
|
|
125
126
|
ro.observe(hostRef.current);
|
|
126
127
|
|
|
128
|
+
// Tab-switch refresh. The terminal lives inside a .tab-panel which gets
|
|
129
|
+
// display:none when another tab is active. WebGL renderers keep a glyph
|
|
130
|
+
// texture atlas in GPU memory; when the canvas hides + redisplays at a
|
|
131
|
+
// potentially different devicePixelRatio, the atlas isn't invalidated
|
|
132
|
+
// and old glyphs blend with newly-rasterized ones — visible as scrolling
|
|
133
|
+
// text ghosting / double-strikes. Watching the tab-panel's data-active
|
|
134
|
+
// attribute and clearing the atlas + re-fitting + forcing a full
|
|
135
|
+
// refresh wipes the cache cleanly.
|
|
136
|
+
const panel = host.closest('.tab-panel');
|
|
137
|
+
let panelMo = null;
|
|
138
|
+
if (panel) {
|
|
139
|
+
panelMo = new MutationObserver(() => {
|
|
140
|
+
if (panel.hasAttribute('data-active')) {
|
|
141
|
+
requestAnimationFrame(() => {
|
|
142
|
+
try { term.clearTextureAtlas?.(); } catch {}
|
|
143
|
+
try { fit.fit(); } catch {}
|
|
144
|
+
try { term.refresh(0, term.rows - 1); } catch {}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
panelMo.observe(panel, { attributes: true, attributeFilter: ['data-active'] });
|
|
149
|
+
}
|
|
150
|
+
|
|
127
151
|
// give focus to terminal so user can type immediately
|
|
128
152
|
term.focus();
|
|
129
153
|
|
|
@@ -230,7 +254,6 @@ export function TerminalView({ terminalId }) {
|
|
|
230
254
|
// we CAN re-anchor the textarea to the right edge while composing so
|
|
231
255
|
// it grows leftward instead. Toggling a class on the host is enough;
|
|
232
256
|
// the CSS in terminals.css does the rest.
|
|
233
|
-
const host = hostRef.current;
|
|
234
257
|
const onCompStart = () => {
|
|
235
258
|
if (host) host.classList.add('is-composing');
|
|
236
259
|
// The terminal cursor is rendered on canvas (THEME.cursor), so CSS
|
|
@@ -260,6 +283,7 @@ export function TerminalView({ terminalId }) {
|
|
|
260
283
|
helper.removeEventListener('compositionend', onCompEnd);
|
|
261
284
|
}
|
|
262
285
|
ro.disconnect();
|
|
286
|
+
if (panelMo) panelMo.disconnect();
|
|
263
287
|
try { ws.close(); } catch {}
|
|
264
288
|
try { term.dispose(); } catch {}
|
|
265
289
|
termRef.current = null;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Lightweight HTML5 drag-reorder helper.
|
|
2
|
+
//
|
|
3
|
+
// Usage:
|
|
4
|
+
// const dnd = useDragSort(items.map((i) => i.id), async (nextIds) => {
|
|
5
|
+
// await reorderFolders(nextIds);
|
|
6
|
+
// });
|
|
7
|
+
// items.map((it) => html`
|
|
8
|
+
// <div ...${dnd.rowProps(it.id)}>
|
|
9
|
+
// <span ...${dnd.handleProps(it.id)}>⋮⋮</span>
|
|
10
|
+
// ...
|
|
11
|
+
// </div>`);
|
|
12
|
+
//
|
|
13
|
+
// rowProps spreads onDragOver / onDrop / data-* on the row container.
|
|
14
|
+
// handleProps spreads draggable + onDragStart on the drag handle. We
|
|
15
|
+
// gate "draggable" on the handle so clicks inside the row don't start a
|
|
16
|
+
// drag and the user can still click rows normally.
|
|
17
|
+
|
|
18
|
+
import { useRef, useState } from 'preact/hooks';
|
|
19
|
+
|
|
20
|
+
export function useDragSort(ids, onCommit) {
|
|
21
|
+
const dragging = useRef(null);
|
|
22
|
+
const [overId, setOverId] = useState(null);
|
|
23
|
+
|
|
24
|
+
const handleProps = (id) => ({
|
|
25
|
+
draggable: true,
|
|
26
|
+
onDragStart: (ev) => {
|
|
27
|
+
dragging.current = id;
|
|
28
|
+
ev.dataTransfer.effectAllowed = 'move';
|
|
29
|
+
// Setting some data is required for Firefox to actually start a drag.
|
|
30
|
+
try { ev.dataTransfer.setData('text/plain', id); } catch {}
|
|
31
|
+
},
|
|
32
|
+
onDragEnd: () => { dragging.current = null; setOverId(null); },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const rowProps = (id) => ({
|
|
36
|
+
'data-dnd-id': id,
|
|
37
|
+
'data-dnd-over': overId === id ? 'true' : undefined,
|
|
38
|
+
onDragOver: (ev) => {
|
|
39
|
+
if (dragging.current == null || dragging.current === id) return;
|
|
40
|
+
ev.preventDefault();
|
|
41
|
+
ev.dataTransfer.dropEffect = 'move';
|
|
42
|
+
if (overId !== id) setOverId(id);
|
|
43
|
+
},
|
|
44
|
+
onDragLeave: (ev) => {
|
|
45
|
+
// Only clear if the pointer leaves the row entirely (not when entering a child).
|
|
46
|
+
const rt = ev.relatedTarget;
|
|
47
|
+
if (rt && ev.currentTarget.contains(rt)) return;
|
|
48
|
+
if (overId === id) setOverId(null);
|
|
49
|
+
},
|
|
50
|
+
onDrop: (ev) => {
|
|
51
|
+
ev.preventDefault();
|
|
52
|
+
const src = dragging.current;
|
|
53
|
+
dragging.current = null;
|
|
54
|
+
setOverId(null);
|
|
55
|
+
if (src == null || src === id) return;
|
|
56
|
+
const cur = [...ids];
|
|
57
|
+
const from = cur.indexOf(src);
|
|
58
|
+
const to = cur.indexOf(id);
|
|
59
|
+
if (from < 0 || to < 0) return;
|
|
60
|
+
cur.splice(from, 1);
|
|
61
|
+
cur.splice(to, 0, src);
|
|
62
|
+
onCommit?.(cur);
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return { handleProps, rowProps, draggingId: dragging.current, overId };
|
|
67
|
+
}
|
package/public/js/dialog.js
CHANGED
|
@@ -18,11 +18,19 @@ function push(entry) {
|
|
|
18
18
|
});
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
const CLOSE_X = html`
|
|
22
|
+
<button class="modal-close" type="button" aria-label="Close" data-action="cancel">
|
|
23
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
24
|
+
<line x1="3" y1="3" x2="13" y2="13"/>
|
|
25
|
+
<line x1="13" y1="3" x2="3" y2="13"/>
|
|
26
|
+
</svg>
|
|
27
|
+
</button>`;
|
|
28
|
+
|
|
21
29
|
export function ccsmConfirm(message, opts = {}) {
|
|
22
30
|
const { title = 'Confirm', okLabel = 'Confirm', cancelLabel = 'Cancel', danger = false } = opts;
|
|
23
31
|
return push({
|
|
24
32
|
render: () => html`<div class="modal modal-dialog">
|
|
25
|
-
<header class="modal-head"><h2>${title}</h2
|
|
33
|
+
<header class="modal-head"><h2>${title}</h2>${CLOSE_X}</header>
|
|
26
34
|
<div class="modal-body"><p class="dialog-msg">${message}</p></div>
|
|
27
35
|
<footer class="modal-foot">
|
|
28
36
|
<button class="action" data-action="cancel">${cancelLabel}</button>
|
|
@@ -37,7 +45,7 @@ export function ccsmPrompt(message, defaultValue = '', opts = {}) {
|
|
|
37
45
|
const { title, okLabel = 'Save', cancelLabel = 'Cancel', placeholder = '' } = opts;
|
|
38
46
|
return push({
|
|
39
47
|
render: () => html`<div class="modal modal-dialog">
|
|
40
|
-
<header class="modal-head"><h2>${title || message}</h2
|
|
48
|
+
<header class="modal-head"><h2>${title || message}</h2>${CLOSE_X}</header>
|
|
41
49
|
<div class="modal-body">
|
|
42
50
|
${title ? html`<p class="dialog-msg">${message}</p>` : null}
|
|
43
51
|
<input type="text" class="input" placeholder=${placeholder} value=${defaultValue} />
|
package/public/js/icons.js
CHANGED
|
@@ -29,7 +29,33 @@ export const IconRefresh = ic('0 0 24 24', html`
|
|
|
29
29
|
`, 16);
|
|
30
30
|
|
|
31
31
|
export const IconChevronLeft = ic('0 0 24 24', html`<polyline points="15 18 9 12 15 6"/>`, 14);
|
|
32
|
+
export const IconChevronRight = ic('0 0 24 24', html`<polyline points="9 18 15 12 9 6"/>`, 14);
|
|
33
|
+
export const IconChevronUp = ic('0 0 24 24', html`<polyline points="18 15 12 9 6 15"/>`, 14);
|
|
32
34
|
export const IconChevronDown = ic('0 0 24 24', html`<polyline points="6 9 12 15 18 9"/>`, 14);
|
|
35
|
+
export const IconArrowRight = ic('0 0 24 24', html`<line x1="5" y1="12" x2="19" y2="12"/><polyline points="13 6 19 12 13 18"/>`, 14);
|
|
36
|
+
export const IconHome = ic('0 0 24 24', html`
|
|
37
|
+
<path d="M3 11l9-8 9 8"/>
|
|
38
|
+
<path d="M5 10v10a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V10"/>
|
|
39
|
+
`, 14);
|
|
40
|
+
export const IconSparkle = ic('0 0 24 24', html`
|
|
41
|
+
<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/>
|
|
42
|
+
<path d="M19 17l.8 1.6L21 20l-1.2.4L19 22l-.8-1.6L17 20l1.2-.4z"/>
|
|
43
|
+
`, 18);
|
|
44
|
+
// "Workspace" — stacked layers / cube. Used for the launch-page
|
|
45
|
+
// destination pill so it doesn't clash with the folder-tag pill that
|
|
46
|
+
// uses IconFolder.
|
|
47
|
+
export const IconWorkspace = ic('0 0 24 24', html`
|
|
48
|
+
<path d="M12 2l9 5-9 5-9-5z"/>
|
|
49
|
+
<path d="M3 12l9 5 9-5"/>
|
|
50
|
+
<path d="M3 17l9 5 9-5"/>
|
|
51
|
+
`, 16);
|
|
52
|
+
// Sidebar-toggle icon (panel-left). A rectangle with a vertical divider
|
|
53
|
+
// near the left — universally recognised "show/hide sidebar" affordance
|
|
54
|
+
// (Notion, Codex, Linear all use this shape).
|
|
55
|
+
export const IconSidebarToggle = ic('0 0 24 24', html`
|
|
56
|
+
<rect x="3" y="4" width="18" height="16" rx="2"/>
|
|
57
|
+
<line x1="9" y1="4" x2="9" y2="20"/>
|
|
58
|
+
`, 14);
|
|
33
59
|
|
|
34
60
|
export const IconSearch = ic('0 0 24 24', html`
|
|
35
61
|
<circle cx="11" cy="11" r="7"/>
|
|
@@ -46,6 +72,17 @@ export const IconPlus = ic('0 0 24 24', html`
|
|
|
46
72
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
|
47
73
|
`, 22);
|
|
48
74
|
|
|
75
|
+
// Folder + folder-open. Used in the sidebar session tree to mirror the
|
|
76
|
+
// icon-first style of the top nav items. Open variant for expanded
|
|
77
|
+
// folders so the chevron isn't doing double duty.
|
|
78
|
+
export const IconFolder = ic('0 0 24 24', html`
|
|
79
|
+
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
|
|
80
|
+
`, 16);
|
|
81
|
+
export const IconFolderOpen = ic('0 0 24 24', html`
|
|
82
|
+
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v1H3V7z"/>
|
|
83
|
+
<path d="M3 10h18l-2 7a2 2 0 0 1-2 1.5H5A2 2 0 0 1 3 17V10z"/>
|
|
84
|
+
`, 16);
|
|
85
|
+
|
|
49
86
|
export const IconPencil = ic('0 0 24 24', html`
|
|
50
87
|
<path d="M12 20h9"/>
|
|
51
88
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
|
@@ -81,6 +118,31 @@ export const IconTerminal = ic('0 0 24 24', html`
|
|
|
81
118
|
<line x1="12" y1="19" x2="20" y2="19"/>
|
|
82
119
|
`, 18);
|
|
83
120
|
|
|
121
|
+
// Git branch — for repo selection
|
|
122
|
+
export const IconBranch = ic('0 0 24 24', html`
|
|
123
|
+
<line x1="6" y1="3" x2="6" y2="15"/>
|
|
124
|
+
<circle cx="18" cy="6" r="3"/>
|
|
125
|
+
<circle cx="6" cy="18" r="3"/>
|
|
126
|
+
<path d="M18 9a9 9 0 0 1-9 9"/>
|
|
127
|
+
`, 18);
|
|
128
|
+
|
|
129
|
+
// Brand-colored CLI marks. These use external SVG assets (full color),
|
|
130
|
+
// rendered as <img> so the gradients / fills in the file are preserved.
|
|
131
|
+
export const IconClaudeColor = () => html`
|
|
132
|
+
<img src="./assets/claude-color.svg" alt="" width="18" height="18" style="display:block" />`;
|
|
133
|
+
export const IconCodexColor = () => html`
|
|
134
|
+
<img src="./assets/codex-color.svg" alt="" width="18" height="18" style="display:block" />`;
|
|
135
|
+
export const IconCopilotColor = () => html`
|
|
136
|
+
<img src="./assets/copilot-color.svg" alt="" width="18" height="18" style="display:block" />`;
|
|
137
|
+
|
|
138
|
+
// Pick the right icon for a CLI based on its type field.
|
|
139
|
+
export const IconForCliType = (type) => {
|
|
140
|
+
if (type === 'claude') return IconClaudeColor;
|
|
141
|
+
if (type === 'codex') return IconCodexColor;
|
|
142
|
+
if (type === 'copilot') return IconCopilotColor;
|
|
143
|
+
return IconTerminal;
|
|
144
|
+
};
|
|
145
|
+
|
|
84
146
|
// Two variants used in the StarButton.
|
|
85
147
|
export const StarOutline = ({ size = 15 } = {}) => html`
|
|
86
148
|
<svg viewBox="0 0 24 24" width=${size} height=${size} fill="none" stroke="currentColor"
|
|
@@ -105,9 +167,10 @@ export const BrandMark = () => html`
|
|
|
105
167
|
<svg viewBox="0 0 32 32" width="32" height="32">
|
|
106
168
|
<rect x="2" y="4" width="28" height="24" rx="3" fill="#1a1815"/>
|
|
107
169
|
<line x1="2" y1="10" x2="30" y2="10" stroke="#faf9f5" stroke-width="0.6" opacity="0.45"/>
|
|
108
|
-
|
|
109
|
-
<circle cx="
|
|
110
|
-
<circle cx="
|
|
170
|
+
<!-- macOS traffic-light style: red / yellow / green -->
|
|
171
|
+
<circle cx="6" cy="7" r="1" fill="#ed6a5e"/>
|
|
172
|
+
<circle cx="9.5" cy="7" r="1" fill="#f4be4f"/>
|
|
173
|
+
<circle cx="13" cy="7" r="1" fill="#62c554"/>
|
|
111
174
|
<text x="16" y="19.5" text-anchor="middle" dominant-baseline="central"
|
|
112
175
|
font-family="'JetBrains Mono', 'Cascadia Mono', 'Consolas', monospace"
|
|
113
176
|
font-weight="700" font-size="10" fill="#faf9f5">ccsm</text>
|