@bakapiano/ccsm 0.8.4 → 0.10.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 +222 -195
- package/README.md +78 -80
- package/bin/ccsm.js +1 -1
- package/lib/cliSessionWatcher.js +249 -0
- package/lib/config.js +101 -19
- package/lib/folders.js +96 -0
- package/lib/localCliSessions.js +177 -0
- package/lib/persistedSessions.js +134 -0
- package/lib/webTerminal.js +48 -13
- package/lib/workspace.js +26 -4
- package/package.json +4 -4
- 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 +133 -10
- package/public/css/layout.css +79 -26
- package/public/css/modal.css +40 -26
- package/public/css/responsive.css +2 -2
- package/public/css/sidebar.css +456 -20
- package/public/css/terminals.css +182 -0
- package/public/css/tokens.css +28 -12
- package/public/css/wco.css +47 -19
- package/public/css/widgets.css +1177 -6
- package/public/index.html +39 -4
- 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 +244 -26
- package/public/js/components/TerminalView.js +192 -2
- 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 +81 -1
- package/public/js/pages/ConfigurePage.js +452 -159
- package/public/js/pages/LaunchPage.js +328 -76
- package/public/js/pages/SessionsPage.js +91 -41
- package/public/js/state.js +179 -35
- package/public/manifest.webmanifest +2 -2
- package/scripts/install.js +1 -1
- package/server.js +763 -407
- 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 -87
- 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 -142
- 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,52 +1,270 @@
|
|
|
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;
|
|
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
|
+
};
|
|
25
229
|
|
|
26
230
|
return html`
|
|
27
231
|
<aside class="sidebar" data-collapsed=${collapsed ? 'true' : 'false'}>
|
|
28
|
-
<div class="sidebar-
|
|
29
|
-
<
|
|
30
|
-
|
|
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>
|
|
31
243
|
</div>
|
|
32
244
|
|
|
33
|
-
<nav class="sidebar-nav" role="tablist" aria-label="Sections">
|
|
34
|
-
<${NavItem} tab="
|
|
35
|
-
<${NavItem} tab="
|
|
36
|
-
${capabilities.value.webTerminal ? html`
|
|
37
|
-
<${NavItem} tab="terminals" icon=${html`<${IconTerminal} />`} label="Terminals" badge=${webTerminals.value.length || null} />
|
|
38
|
-
` : null}
|
|
39
|
-
<${NavItem} tab="configure" icon=${html`<${IconConfigure} />`} label="Configure" dirty=${configDirty.value} />
|
|
40
|
-
<${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} />
|
|
41
248
|
</nav>
|
|
42
249
|
|
|
250
|
+
${!collapsed ? html`<${SessionTree} />` : null}
|
|
251
|
+
|
|
43
252
|
<div class="sidebar-foot">
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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}
|
|
50
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}
|
|
51
269
|
</aside>`;
|
|
52
270
|
}
|
|
@@ -7,6 +7,8 @@ import { useEffect, useRef } from 'preact/hooks';
|
|
|
7
7
|
import { Terminal } from '@xterm/xterm';
|
|
8
8
|
import { FitAddon } from '@xterm/addon-fit';
|
|
9
9
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
10
|
+
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
|
11
|
+
import { WebglAddon } from '@xterm/addon-webgl';
|
|
10
12
|
import { wsBase } from '../backend.js';
|
|
11
13
|
|
|
12
14
|
// Dark xterm theme. We give the terminal a near-black ink background to
|
|
@@ -39,7 +41,7 @@ export function TerminalView({ terminalId }) {
|
|
|
39
41
|
if (!terminalId || !hostRef.current) return;
|
|
40
42
|
|
|
41
43
|
const term = new Terminal({
|
|
42
|
-
fontFamily: '"
|
|
44
|
+
fontFamily: '"Cascadia Mono", "Geist Mono", "JetBrains Mono", Consolas, monospace',
|
|
43
45
|
fontSize: 13,
|
|
44
46
|
lineHeight: 1.2,
|
|
45
47
|
cursorBlink: true,
|
|
@@ -47,11 +49,45 @@ export function TerminalView({ terminalId }) {
|
|
|
47
49
|
scrollback: 5000,
|
|
48
50
|
allowProposedApi: true,
|
|
49
51
|
theme: THEME,
|
|
52
|
+
// Modern keyboard protocols. Without these, xterm.js encodes
|
|
53
|
+
// Shift+Enter, Ctrl+Enter, Ctrl+Shift+key etc. the same as their
|
|
54
|
+
// unmodified versions (e.g. both Enter and Shift+Enter send \r),
|
|
55
|
+
// so TUIs like claude code can't tell them apart.
|
|
56
|
+
//
|
|
57
|
+
// - kittyKeyboard: opt-in protocol that apps enable per-session;
|
|
58
|
+
// xterm emits CSI u sequences that uniquely encode every modifier
|
|
59
|
+
// combo. Claude / vim / fish recognise it.
|
|
60
|
+
// - win32InputMode: ConPTY-specific protocol that surfaces raw
|
|
61
|
+
// Win32 KEY_EVENT_RECORD to the child process, again preserving
|
|
62
|
+
// modifier info. Required for full key fidelity on Windows.
|
|
63
|
+
// (Same set VSCode enables — see vscode/src/.../xtermTerminal.ts)
|
|
64
|
+
vtExtensions: {
|
|
65
|
+
kittyKeyboard: true,
|
|
66
|
+
win32InputMode: true,
|
|
67
|
+
},
|
|
50
68
|
});
|
|
51
69
|
const fit = new FitAddon();
|
|
52
70
|
term.loadAddon(fit);
|
|
53
71
|
term.loadAddon(new WebLinksAddon());
|
|
54
|
-
|
|
72
|
+
// OSC 52 clipboard integration. Lets TUI apps initiate clipboard reads/
|
|
73
|
+
// writes via escape sequences (e.g. `tmux set-buffer` or claude code
|
|
74
|
+
// saying "copied to clipboard"). Does NOT handle the browser-side
|
|
75
|
+
// Ctrl+V — that's still our document-level paste handler below.
|
|
76
|
+
term.loadAddon(new ClipboardAddon());
|
|
77
|
+
// WebGL renderer for performance. The default DOM renderer struggles
|
|
78
|
+
// when claude code produces dense color output (its diff panels,
|
|
79
|
+
// syntax-highlighted code). WebGL paints onto a canvas, much smoother
|
|
80
|
+
// at thousands-of-cells per frame. Falls back to DOM if WebGL is
|
|
81
|
+
// unavailable (e.g. older GPU, hardware accel disabled).
|
|
82
|
+
try {
|
|
83
|
+
const webgl = new WebglAddon();
|
|
84
|
+
webgl.onContextLoss(() => { try { webgl.dispose(); } catch {} });
|
|
85
|
+
term.loadAddon(webgl);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.warn('[ccsm] WebGL addon failed, using DOM renderer:', e);
|
|
88
|
+
}
|
|
89
|
+
const host = hostRef.current;
|
|
90
|
+
term.open(host);
|
|
55
91
|
// Defer fit one tick so the container has measured layout
|
|
56
92
|
requestAnimationFrame(() => { try { fit.fit(); } catch {} });
|
|
57
93
|
termRef.current = term;
|
|
@@ -89,11 +125,165 @@ export function TerminalView({ terminalId }) {
|
|
|
89
125
|
const ro = new ResizeObserver(() => { try { fit.fit(); } catch {} });
|
|
90
126
|
ro.observe(hostRef.current);
|
|
91
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
|
+
|
|
92
151
|
// give focus to terminal so user can type immediately
|
|
93
152
|
term.focus();
|
|
94
153
|
|
|
154
|
+
// Explicit paste handler. xterm.js relies on the browser routing paste
|
|
155
|
+
// events to its hidden .xterm-helper-textarea, which only works if that
|
|
156
|
+
// textarea has focus at the moment of Ctrl+V. When the user clicks
|
|
157
|
+
// elsewhere then hits Ctrl+V over the terminal, or pastes via the
|
|
158
|
+
// right-click menu on the host div, the event lands on the host and
|
|
159
|
+
// xterm never sees it. Catch it here and route through term.paste()
|
|
160
|
+
// so xterm wraps the text in bracketed-paste markers when the app
|
|
161
|
+
// (claude code) has DECSET 2004 enabled — that's what makes claude
|
|
162
|
+
// show the "[Pasted text]" affordance instead of treating it as
|
|
163
|
+
// typed input.
|
|
164
|
+
const isOurs = () => {
|
|
165
|
+
const ae = document.activeElement;
|
|
166
|
+
return ae && host.contains(ae);
|
|
167
|
+
};
|
|
168
|
+
const doPaste = (text) => {
|
|
169
|
+
if (!text) return;
|
|
170
|
+
if (ws.readyState !== 1) return;
|
|
171
|
+
// Normalize line endings to \r (CR / Enter). This mirrors VSCode's
|
|
172
|
+
// terminal sendText path (terminalInstance.ts ~L1385):
|
|
173
|
+
// text = text.replace(/\r?\n/g, '\r');
|
|
174
|
+
// Bracketed-paste markers protect each \r from being interpreted
|
|
175
|
+
// as a submit by the host app — claude / pwsh / vim all treat
|
|
176
|
+
// bracketed contents as opaque payload regardless of what's inside.
|
|
177
|
+
// Use \n instead and you trip apps that look for "real" line breaks.
|
|
178
|
+
const normalized = text.replace(/\r?\n/g, '\r');
|
|
179
|
+
// Wrap in bracketed-paste markers. Claude Code enables DECSET 2004
|
|
180
|
+
// on startup, so the markers let it detect a paste and render
|
|
181
|
+
// "[Pasted text]". If the host app doesn't have bracketed paste on,
|
|
182
|
+
// it just sees two ignored escape sequences plus the text.
|
|
183
|
+
const wrapped = `\x1b[200~${normalized}\x1b[201~`;
|
|
184
|
+
ws.send(JSON.stringify({ type: 'input', data: wrapped }));
|
|
185
|
+
};
|
|
186
|
+
const onPaste = async (ev) => {
|
|
187
|
+
if (!isOurs()) return;
|
|
188
|
+
let text = '';
|
|
189
|
+
if (ev.clipboardData) text = ev.clipboardData.getData('text');
|
|
190
|
+
if (!text && navigator.clipboard) {
|
|
191
|
+
try { text = await navigator.clipboard.readText(); } catch {}
|
|
192
|
+
}
|
|
193
|
+
if (!text) return;
|
|
194
|
+
ev.preventDefault();
|
|
195
|
+
ev.stopPropagation();
|
|
196
|
+
doPaste(text);
|
|
197
|
+
};
|
|
198
|
+
document.addEventListener('paste', onPaste, true);
|
|
199
|
+
|
|
200
|
+
// Ctrl/Cmd+V fallback for cases the paste event is suppressed (some
|
|
201
|
+
// extensions, or when our IME workaround moved the helper textarea
|
|
202
|
+
// off-screen and the browser refuses to fire paste on it).
|
|
203
|
+
// IMPORTANT: preventDefault must happen synchronously, BEFORE the
|
|
204
|
+
// await on navigator.clipboard.readText(). If we let the event tick
|
|
205
|
+
// run first, xterm's keystroke handler converts Ctrl+V into the raw
|
|
206
|
+
// ^V (0x16) control byte and ships it before our async paste even
|
|
207
|
+
// resolves.
|
|
208
|
+
const onKey = (ev) => {
|
|
209
|
+
const meta = ev.ctrlKey || ev.metaKey;
|
|
210
|
+
if (!meta || ev.key.toLowerCase() !== 'v') return;
|
|
211
|
+
if (ev.shiftKey || ev.altKey) return;
|
|
212
|
+
if (!isOurs()) return;
|
|
213
|
+
if (!navigator.clipboard?.readText) return;
|
|
214
|
+
ev.preventDefault();
|
|
215
|
+
ev.stopPropagation();
|
|
216
|
+
ev.stopImmediatePropagation();
|
|
217
|
+
navigator.clipboard.readText().then((text) => {
|
|
218
|
+
if (text) doPaste(text);
|
|
219
|
+
}).catch(() => {});
|
|
220
|
+
};
|
|
221
|
+
document.addEventListener('keydown', onKey, true);
|
|
222
|
+
|
|
223
|
+
// Shift+Enter / Ctrl+Enter → insert literal newline, don't submit.
|
|
224
|
+
// Background: xterm.js encodes BOTH plain Enter and Shift+Enter and
|
|
225
|
+
// Ctrl+Enter as \r (0x0D / CR). The kitty keyboard / win32 input
|
|
226
|
+
// protocols (enabled in vtExtensions above) WOULD distinguish them,
|
|
227
|
+
// but they're opt-in by the running app — claude code doesn't enable
|
|
228
|
+
// either, so we never get the distinction "for free".
|
|
229
|
+
//
|
|
230
|
+
// Send the LF (0x0A) explicitly. Claude code (and most modern TUIs)
|
|
231
|
+
// treat \n inside a prompt as a literal newline insert, \r as submit.
|
|
232
|
+
// Alt+Enter already works (xterm sends \x1b\r → meta-enter) so we
|
|
233
|
+
// leave that alone.
|
|
234
|
+
const onShiftEnter = (ev) => {
|
|
235
|
+
if (ev.key !== 'Enter') return;
|
|
236
|
+
if (!(ev.shiftKey || ev.ctrlKey)) return;
|
|
237
|
+
if (ev.metaKey || ev.altKey) return;
|
|
238
|
+
if (!isOurs()) return;
|
|
239
|
+
ev.preventDefault();
|
|
240
|
+
ev.stopPropagation();
|
|
241
|
+
ev.stopImmediatePropagation();
|
|
242
|
+
if (ws.readyState === 1) {
|
|
243
|
+
ws.send(JSON.stringify({ type: 'input', data: '\n' }));
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
document.addEventListener('keydown', onShiftEnter, true);
|
|
247
|
+
|
|
248
|
+
// IME fix: xterm positions .xterm-helper-textarea via `left: <col-px>`
|
|
249
|
+
// following the cursor. When the cursor is near the right edge and the
|
|
250
|
+
// user starts composing (e.g. Chinese pinyin), the textarea + native
|
|
251
|
+
// composition popup grow with the composed string and overflow the
|
|
252
|
+
// terminal host — which visually pushes the layout right. We can't cap
|
|
253
|
+
// width / change wrapping (that breaks Chromium's IME event flow), but
|
|
254
|
+
// we CAN re-anchor the textarea to the right edge while composing so
|
|
255
|
+
// it grows leftward instead. Toggling a class on the host is enough;
|
|
256
|
+
// the CSS in terminals.css does the rest.
|
|
257
|
+
const onCompStart = () => {
|
|
258
|
+
if (host) host.classList.add('is-composing');
|
|
259
|
+
// The terminal cursor is rendered on canvas (THEME.cursor), so CSS
|
|
260
|
+
// can't hide it. Theme swap alone doesn't reliably stop the blink
|
|
261
|
+
// frame loop, so also issue the DECTCEM hide sequence which the
|
|
262
|
+
// renderer honours immediately.
|
|
263
|
+
try { term.options.theme = { ...THEME, cursor: 'transparent', cursorAccent: 'transparent' }; } catch {}
|
|
264
|
+
try { term.write('\x1b[?25l'); } catch {}
|
|
265
|
+
};
|
|
266
|
+
const onCompEnd = () => {
|
|
267
|
+
if (host) host.classList.remove('is-composing');
|
|
268
|
+
try { term.options.theme = THEME; } catch {}
|
|
269
|
+
try { term.write('\x1b[?25h'); } catch {}
|
|
270
|
+
};
|
|
271
|
+
const helper = host?.querySelector('.xterm-helper-textarea');
|
|
272
|
+
if (helper) {
|
|
273
|
+
helper.addEventListener('compositionstart', onCompStart);
|
|
274
|
+
helper.addEventListener('compositionend', onCompEnd);
|
|
275
|
+
}
|
|
276
|
+
|
|
95
277
|
return () => {
|
|
278
|
+
document.removeEventListener('paste', onPaste, true);
|
|
279
|
+
document.removeEventListener('keydown', onKey, true);
|
|
280
|
+
document.removeEventListener('keydown', onShiftEnter, true);
|
|
281
|
+
if (helper) {
|
|
282
|
+
helper.removeEventListener('compositionstart', onCompStart);
|
|
283
|
+
helper.removeEventListener('compositionend', onCompEnd);
|
|
284
|
+
}
|
|
96
285
|
ro.disconnect();
|
|
286
|
+
if (panelMo) panelMo.disconnect();
|
|
97
287
|
try { ws.close(); } catch {}
|
|
98
288
|
try { term.dispose(); } catch {}
|
|
99
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
|
+
}
|