dsh-remote 0.6.0: other machines' workspaces in the Workspaces list, richer Devices tab
- sidebar: workspaces (+ sessions) of every other device appear inside the stock Workspaces list with a server icon and the device name; click folds sessions, hover + creates a session there, a session opens as the remote conversation view (/r/<device>/#/session/<id>); the viewed device's own rows stay native - Devices tab: every device with its state (online/offline, last seen, platform, workspace/session/running counts), offline and revoked devices included - deploy/e2e-dom.mjs, e2e-eval.mjs: DOM dump / evaluate-in-page helpers
This commit is contained in:
1 parent
bfbc3c66e4
commit
bf99a952fb
5 files changed
+262
-27
No files matched your search
@@ -169,18 +169,55 @@ window.__ModuleLoader__.load({
|
||||
err && h('span', { style: S.err }, err));
|
||||
}
|
||||
|
||||
// ---- icons (the primitives have no server glyph, so draw it)
|
||||
function ServerIcon({ size = 16 }) {
|
||||
return h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, 'data-dshr-icon': 'server' },
|
||||
h('rect', { x: 2, y: 2.5, width: 12, height: 4.2, rx: 1.1 }),
|
||||
h('rect', { x: 2, y: 9.3, width: 12, height: 4.2, rx: 1.1 }),
|
||||
h('path', { d: 'M4.6 4.6h.01M4.6 11.4h.01', strokeWidth: 1.8 }));
|
||||
}
|
||||
function FolderIcon({ size = 16 }) {
|
||||
return h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
|
||||
h('path', { d: 'M2 4.5A1.5 1.5 0 0 1 3.5 3h2.6l1.4 1.5h5A1.5 1.5 0 0 1 14 6v5.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 11.5z' }));
|
||||
}
|
||||
|
||||
/** Workspaces of one device with their sessions (workspace registry + sessions by cwd). */
|
||||
function groupDevice(dev, q = '') {
|
||||
const match = (s) => !q || `${s.title || ''} ${s.cwd || ''}`.toLowerCase().includes(q);
|
||||
const groups = [];
|
||||
const byPath = new Map();
|
||||
for (const w of dev.workspaces || []) {
|
||||
const g = { key: `${dev.device}:${w.id}`, id: w.id, title: w.title || base(w.path) || w.path, path: w.path, sessions: [] };
|
||||
groups.push(g); byPath.set(w.path, g);
|
||||
}
|
||||
for (const s of dev.items || []) {
|
||||
if (!match(s)) continue;
|
||||
const p = s.cwd || '';
|
||||
let g = byPath.get(p);
|
||||
if (!g) {
|
||||
g = { key: `${dev.device}:cwd:${p}`, id: undefined, title: base(p) || 'No workspace', path: p, sessions: [] };
|
||||
groups.push(g); byPath.set(p, g);
|
||||
}
|
||||
g.sessions.push(s);
|
||||
}
|
||||
return q ? groups.filter((g) => g.sessions.length) : groups;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Devices page
|
||||
function DevicesPage() {
|
||||
const st = useStore();
|
||||
const [filter, setFilter] = useState('');
|
||||
const self = st.state?.self;
|
||||
if (!st.state && !st.error) return h('div', { style: S.page }, 'Loading devices…');
|
||||
const devices = st.sessions || [];
|
||||
const q = filter.trim().toLowerCase();
|
||||
const meta = Object.fromEntries((st.state?.devices || []).map((d) => [d.id, d]));
|
||||
const bySessions = Object.fromEntries((st.sessions || []).map((d) => [d.device, d]));
|
||||
const devices = (st.state?.devices || []).filter((d) => !d.revoked || d.self);
|
||||
const online = devices.filter((d) => d.online).length;
|
||||
return h('div', { style: S.page, 'data-dsh-plugin': 'dsh-remote' },
|
||||
h('h1', { style: S.h1 }, 'Devices'),
|
||||
h('p', { style: S.sub },
|
||||
st.state ? `This device: ${self?.name} (${st.state.role})` : '',
|
||||
`${online} of ${devices.length} online`,
|
||||
st.state ? ` · this device: ${self?.name} (${st.state.role})` : '',
|
||||
st.state?.role === 'slave' && st.state.master
|
||||
? (st.state.master.linked ? ` · linked to ${st.state.master.info?.name || st.state.master.url}` : ` · master unreachable${st.state.master.error ? ': ' + st.state.master.error : ''}`)
|
||||
: ''),
|
||||
@@ -188,34 +225,133 @@ window.__ModuleLoader__.load({
|
||||
h('div', { style: { display: 'flex', gap: 8, margin: '0 0 14px' } },
|
||||
h('input', { style: { ...S.input, flex: 1 }, placeholder: 'Filter sessions…', value: filter, onChange: (e) => setFilter(e.target.value) }),
|
||||
h('button', { style: S.btn, onClick: () => store.refresh() }, 'Refresh')),
|
||||
devices.map((dev) => {
|
||||
const m = meta[dev.device] || {};
|
||||
const items = (dev.items || []).filter((s) => !q || `${s.title || ''} ${s.cwd || ''}`.toLowerCase().includes(q));
|
||||
const byWs = new Map();
|
||||
for (const s of items) {
|
||||
const key = s.cwd || '';
|
||||
if (!byWs.has(key)) byWs.set(key, []);
|
||||
byWs.get(key).push(s);
|
||||
}
|
||||
const wsTitle = (cwd) => (dev.workspaces || []).find((w) => w.path === cwd)?.title || base(cwd) || 'No workspace';
|
||||
return h('div', { key: dev.device, style: S.card },
|
||||
devices.map((m) => {
|
||||
const dev = bySessions[m.id] || { device: m.id, name: m.name, items: [], workspaces: [], self: m.self };
|
||||
const groups = groupDevice(dev, q);
|
||||
const running = (dev.items || []).filter((s) => s.running).length;
|
||||
const state = m.online
|
||||
? (dev.error ? 'unreachable' : `online${m.connectedAt ? ' · connected ' + ago(m.connectedAt) : ''}`)
|
||||
: `offline${m.lastSeenAt ? ' · last seen ' + ago(m.lastSeenAt) : ''}`;
|
||||
return h('div', { key: m.id, style: { ...S.card, opacity: m.online ? 1 : .7 } },
|
||||
h('div', { style: S.devHead },
|
||||
h('span', { style: S.dot(!dev.error) }),
|
||||
h('strong', null, dev.name || dev.device),
|
||||
dev.self && h('span', { style: S.badge('accent') }, 'this device'),
|
||||
h('span', { style: S.dot(m.online && !dev.error) }),
|
||||
h('strong', null, m.name || m.id),
|
||||
m.self && h('span', { style: S.badge('accent') }, 'this device'),
|
||||
m.role === 'master' && h('span', { style: S.badge() }, 'master'),
|
||||
m.via && h('span', { style: S.badge() }, 'via master'),
|
||||
m.revoked && h('span', { style: S.badge('warn') }, 'revoked'),
|
||||
h('span', { style: { flex: 1 } }),
|
||||
!dev.self && h('a', { style: { ...S.btn, textDecoration: 'none' }, href: `${mount.root}r/${dev.device}/` }, 'Open DSH ↗'),
|
||||
h(NewSession, { dev, selfId: self?.id })),
|
||||
m.online && !m.self && h('a', { style: { ...S.btn, textDecoration: 'none' }, href: `${mount.root}r/${m.id}/` }, 'Open DSH ↗'),
|
||||
m.online && !dev.error && h(NewSession, { dev, selfId: self?.id })),
|
||||
h('div', { style: { fontSize: 12, opacity: .65, margin: '0 0 6px' } },
|
||||
state,
|
||||
m.platform ? ` · ${m.platform}` : '',
|
||||
m.online && !dev.error ? ` · ${(dev.workspaces || []).length} workspaces · ${(dev.items || []).length} sessions${running ? ` · ${running} running` : ''}` : ''),
|
||||
dev.error && h('div', { style: S.err }, dev.error),
|
||||
!dev.error && !items.length && h('div', { style: { opacity: .6, fontSize: 13 } }, q ? 'No matching sessions.' : 'No sessions yet.'),
|
||||
[...byWs.entries()].map(([cwd, list]) => h('div', { key: cwd },
|
||||
h('div', { style: S.ws, title: cwd }, wsTitle(cwd)),
|
||||
list.map((s) => h(SessionRow, { key: s.sessionId, dev, s, selfId: self?.id })))));
|
||||
!dev.error && m.online && !groups.length && h('div', { style: { opacity: .6, fontSize: 13 } }, q ? 'No matching sessions.' : 'No workspaces yet.'),
|
||||
groups.map((g) => h('div', { key: g.key },
|
||||
h('div', { style: { ...S.ws, display: 'flex', alignItems: 'center', gap: 6 }, title: g.path },
|
||||
h('span', { style: { display: 'inline-flex', opacity: .8 } }, m.self ? h(FolderIcon, { size: 14 }) : h(ServerIcon, { size: 14 })),
|
||||
g.title),
|
||||
g.sessions.length
|
||||
? g.sessions.map((s) => h(SessionRow, { key: s.sessionId, dev, s, selfId: self?.id }))
|
||||
: h('div', { style: { fontSize: 12, opacity: .5, padding: '2px 8px 4px 28px' } }, 'no sessions'))));
|
||||
}));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ remote workspaces in the sidebar
|
||||
// The stock Workspaces list belongs to ui-workspace and has no slot for extra groups, so a
|
||||
// small React root is mounted as the last child of its tree and styled with the same
|
||||
// design tokens. Rows use a server glyph where the stock rows use a folder; sessions open
|
||||
// as the usual conversation view of that device (/r/<device>/#/session/<id>).
|
||||
const SB_CSS = `
|
||||
.dshr-sb{margin-top:2px;display:flex;flex-direction:column;gap:2px}
|
||||
.dshr-sb-row{box-sizing:border-box;display:flex;align-items:center;gap:6px;height:34px;padding:0 8px;border-radius:var(--dsw-radius-md,8px);cursor:pointer;user-select:none;color:var(--dsw-alias-label-primary,inherit);font-size:14px;text-decoration:none}
|
||||
.dshr-sb-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
|
||||
.dshr-sb-slot{width:16px;height:20px;display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;color:var(--dsw-alias-label-tertiary,#888)}
|
||||
.dshr-sb-title{flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:20px}
|
||||
.dshr-sb-dev{flex:0 0 auto;font-size:10px;line-height:16px;color:var(--dsw-alias-label-tertiary,#888);max-width:64px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.dshr-sb-sess{height:32px;padding-inline-start:30px;gap:0}
|
||||
.dshr-sb-sess .dshr-sb-title{margin:0 6px 0 4px}
|
||||
.dshr-sb-time{flex:0 0 auto;font-size:10px;line-height:16px;color:var(--dsw-alias-label-tertiary,#888)}
|
||||
.dshr-sb-plus{display:none;flex:0 0 auto;width:20px;height:20px;border:0;border-radius:6px;background:none;color:inherit;cursor:pointer;line-height:1;font-size:15px}
|
||||
.dshr-sb-plus:hover{background:rgba(127,127,127,.2)}
|
||||
.dshr-sb-row:hover .dshr-sb-plus{display:inline-flex;align-items:center;justify-content:center}
|
||||
.dshr-sb-more{font-size:12px;color:var(--dsw-alias-label-tertiary,#888);padding:2px 8px 2px 30px;cursor:pointer;border:0;background:none;text-align:left}
|
||||
.dshr-sb-run{width:8px;height:8px;border-radius:8px;background:#d29922;margin-right:2px;flex:0 0 auto;animation:dshr-pulse 1s infinite}
|
||||
`;
|
||||
const SB_SESSIONS = 6;
|
||||
|
||||
function SidebarRemote() {
|
||||
const st = useStore();
|
||||
const [open, setOpen] = useState(() => { try { return JSON.parse(localStorage.getItem('dshr-ws-open') || '{}'); } catch { return {}; } });
|
||||
const [more, setMore] = useState({});
|
||||
const selfId = st.state?.self?.id;
|
||||
// The device whose UI this page is showing already has its own native rows.
|
||||
const viewed = mount.device || selfId;
|
||||
const devs = (st.sessions || []).filter((d) => d.device !== viewed && !d.error);
|
||||
if (!st.state || !devs.length) return null;
|
||||
const toggle = (key) => {
|
||||
const next = { ...open, [key]: !open[key] };
|
||||
setOpen(next);
|
||||
try { localStorage.setItem('dshr-ws-open', JSON.stringify(next)); } catch { /* ignore */ }
|
||||
};
|
||||
const create = async (e, dev, g) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const r = await post('new-session', { device: dev.device, workspaceId: g.id, cwd: g.id ? undefined : g.path || undefined });
|
||||
location.href = sessionUrl(dev.device, selfId, r.sessionId);
|
||||
} catch (err) { console.warn('[dsh-remote] new session failed:', err.message); }
|
||||
};
|
||||
return h('div', { className: 'dshr-sb', 'data-dshr-remote-workspaces': '' },
|
||||
devs.flatMap((dev) => groupDevice(dev).map((g) => {
|
||||
const isOpen = !!open[g.key];
|
||||
const shown = more[g.key] ? g.sessions : g.sessions.slice(0, SB_SESSIONS);
|
||||
return h(React.Fragment, { key: g.key },
|
||||
h('div', { className: 'dshr-sb-row', role: 'treeitem', 'aria-expanded': isOpen, title: `${dev.name}: ${g.path}`, onClick: () => toggle(g.key) },
|
||||
h('span', { className: 'dshr-sb-slot' }, h(ServerIcon)),
|
||||
h('span', { className: 'dshr-sb-title' }, g.title),
|
||||
h('button', { className: 'dshr-sb-plus', title: `New session on ${dev.name}`, onClick: (e) => create(e, dev, g) }, '+'),
|
||||
h('span', { className: 'dshr-sb-dev' }, dev.name || dev.device)),
|
||||
isOpen && shown.map((s) => h('a', { key: s.sessionId, className: 'dshr-sb-row dshr-sb-sess', role: 'treeitem', href: sessionUrl(dev.device, selfId, s.sessionId), title: `${s.title || s.sessionId}\n${dev.name}` },
|
||||
s.running ? h('span', { className: 'dshr-sb-run', title: 'running' }) : null,
|
||||
h('span', { className: 'dshr-sb-title' }, s.title || 'untitled'),
|
||||
h('span', { className: 'dshr-sb-time' }, ago(s.updatedAt)))),
|
||||
isOpen && !more[g.key] && g.sessions.length > SB_SESSIONS && h('button', { className: 'dshr-sb-more', onClick: () => setMore({ ...more, [g.key]: true }) }, `Show ${g.sessions.length - SB_SESSIONS} more`),
|
||||
isOpen && !g.sessions.length && h('div', { className: 'dshr-sb-more', style: { cursor: 'default' } }, 'no sessions'));
|
||||
})));
|
||||
}
|
||||
|
||||
function mountSidebarRemote() {
|
||||
if (!document.getElementById('dshr-sb-style')) {
|
||||
const css = document.createElement('style');
|
||||
css.id = 'dshr-sb-style';
|
||||
css.textContent = SB_CSS;
|
||||
document.head.appendChild(css);
|
||||
}
|
||||
const host = document.createElement('div');
|
||||
host.className = 'dshr-sb-host';
|
||||
host.setAttribute('role', 'none');
|
||||
const root = ReactDOM.createRoot ? ReactDOM.createRoot(host) : null;
|
||||
if (root) root.render(h(SidebarRemote)); else ReactDOM.render(h(SidebarRemote), host);
|
||||
let raf = 0;
|
||||
const place = () => {
|
||||
raf = 0;
|
||||
const tree = document.querySelector('[data-slot="sidebar.workspaces"] [role="tree"]');
|
||||
if (!tree) return;
|
||||
if (host.parentElement !== tree || tree.lastElementChild !== host) tree.appendChild(host);
|
||||
};
|
||||
const schedule = () => { if (!raf) raf = requestAnimationFrame(place); };
|
||||
const obs = new MutationObserver(schedule);
|
||||
obs.observe(document.body, { childList: true, subtree: true });
|
||||
schedule();
|
||||
return () => {
|
||||
obs.disconnect();
|
||||
try { root ? root.unmount() : ReactDOM.unmountComponentAtNode(host); } catch { /* ignore */ }
|
||||
host.remove();
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Settings section
|
||||
function Field({ label, children, hint }) {
|
||||
return h('label', { style: { display: 'grid', gap: 4, margin: '0 0 10px', fontSize: 13 } },
|
||||
@@ -565,6 +701,8 @@ window.__ModuleLoader__.load({
|
||||
const disposers = [];
|
||||
if (!mount.device) disposers.push(mountToasts());
|
||||
disposers.push(mountRemoteBanner());
|
||||
// Other machines' workspaces inside the stock Workspaces list (server icon).
|
||||
disposers.push(mountSidebarRemote());
|
||||
|
||||
disposers.push(ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
||||
name: 'sidebar.panellist', id: PANEL_ID, order: 50, label: 'Devices',
|
||||
|
||||
Reference in new issue
Block a user