feat: v0.6.0 light mode, command palette, dialogs, toasts
- Light mode with the house ☀/◐ toggle and lt_theme preference; light terminal palette; Claude runs with dark-ansi so its colours follow the palette; tmux and the statusLine use ANSI colours. - Command palette (⌕, Ctrl+Shift+P) with session, terminal, view, link and help commands. - .lt-modal dialogs for new/rename/end and a keyboard shortcuts sheet. - .lt-toast results for actions, uploads, copy and CI. - ttyd dropped the reply of fast one-shot commands; replies now wait for the browser to hang up, and the client hangs up as soon as it has one. - Sessions panel re-fetches after an action instead of reusing a stale poll. Closes #31 Closes #32 Closes #33 Closes #34 Closes #35 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+194
-21
@@ -1,14 +1,17 @@
|
||||
/* global __DEVDESK_VERSION__ */
|
||||
import { createTerminal, loadFonts } from './term.js';
|
||||
import { THEMES, createTerminal, loadFonts } from './term.js';
|
||||
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
|
||||
import {
|
||||
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
|
||||
} from './connection.js';
|
||||
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
|
||||
import { SessionsPanel, el, recentNames, rememberSession, sessionStatus } from './sessions.js';
|
||||
import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js';
|
||||
import { renderAlerts } from './alerts.js';
|
||||
import { renderRepo } from './repo.js';
|
||||
import { bindAskClaude } from './widgets.js';
|
||||
import { heardWithin, onShared, share } from './shared.js';
|
||||
import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js';
|
||||
import { el } from './dom.js';
|
||||
import * as notify from './notify.js';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
@@ -70,6 +73,8 @@ const isNarrow = () => !window.matchMedia('(min-width: 1100px)').matches;
|
||||
let term;
|
||||
let fit;
|
||||
let sessionsPanel;
|
||||
let palette;
|
||||
let lastRepo = null;
|
||||
let lastAlerts = null;
|
||||
let waitingOthers = [];
|
||||
let paneTitleRaw = '';
|
||||
@@ -193,7 +198,7 @@ function uploadMessage(text, isError = false, sticky = false) {
|
||||
async function attachFiles(files) {
|
||||
if (!files.length) return;
|
||||
if (!state.session || !state.conn?.isOpen) {
|
||||
uploadMessage('Open a session first: files are attached to the current session.', true);
|
||||
toast.warning('Open a session first: files are attached to the current session.');
|
||||
return;
|
||||
}
|
||||
const session = state.session;
|
||||
@@ -204,15 +209,17 @@ async function attachFiles(files) {
|
||||
uploadMessage(`Uploading ${file.name || 'image'}… ${Math.round(p * 100)}%`, false, true));
|
||||
paths.push(path);
|
||||
} catch (error) {
|
||||
uploadMessage(`${file.name || 'file'}: ${error.message}`, true);
|
||||
uploadUi.hidden = true;
|
||||
toast.error(`${file.name || 'file'}: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
uploadUi.hidden = true;
|
||||
if (session !== state.session) return;
|
||||
// A pasted image path makes Claude attach the image itself.
|
||||
term.paste(`${paths.join(' ')} `);
|
||||
term.focus();
|
||||
uploadMessage(paths.length === 1 ? `Attached ${paths[0]}` : `Attached ${paths.length} files`);
|
||||
toast.success(paths.length === 1 ? `Attached ${paths[0].split('/').pop()}` : `Attached ${paths.length} files`);
|
||||
}
|
||||
|
||||
function wireUploads() {
|
||||
@@ -257,6 +264,7 @@ let repoBusy = false;
|
||||
async function refreshRepo() {
|
||||
const session = state.session;
|
||||
if (!session) {
|
||||
lastRepo = null;
|
||||
renderRepo($('widget-repo'), null);
|
||||
return;
|
||||
}
|
||||
@@ -266,10 +274,13 @@ async function refreshRepo() {
|
||||
const repo = await fetchRepo(session);
|
||||
if (session !== state.session) return;
|
||||
renderRepo($('widget-repo'), repo);
|
||||
lastRepo = repo;
|
||||
const key = `${session}@${repo.sha}`;
|
||||
const ci = repo.ci?.total ? repo.ci.state : '';
|
||||
if (key === lastCi.key && lastCi.state === 'pending' && ['success', 'failure', 'error'].includes(ci)) {
|
||||
notify.ring(session, `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`);
|
||||
const text = `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`;
|
||||
notify.ring(session, text);
|
||||
toast[ci === 'success' ? 'success' : 'error'](text);
|
||||
}
|
||||
lastCi = { key, state: ci };
|
||||
} catch {
|
||||
@@ -497,10 +508,7 @@ function renderSwitcher() {
|
||||
el('div', { class: 'dd-menu-sep' }),
|
||||
el('button', {
|
||||
type: 'button', role: 'menuitem', class: 'dd-menu-item',
|
||||
onclick: choose(() => {
|
||||
setSidebar(true);
|
||||
sessionsPanel.toggleForm(true);
|
||||
}),
|
||||
onclick: choose(() => sessionsPanel.newDialog()),
|
||||
}, '+ New session…'),
|
||||
el('button', { type: 'button', role: 'menuitem', class: 'dd-menu-item', onclick: choose(() => connect(null)) }, 'Open picker'),
|
||||
);
|
||||
@@ -509,6 +517,7 @@ function renderSwitcher() {
|
||||
// ---------------------------------------------------------------- chrome controls
|
||||
|
||||
function setFontSize(size) {
|
||||
if (!Number.isFinite(size)) return;
|
||||
const clamped = Math.min(24, Math.max(10, size));
|
||||
term.options.fontSize = clamped;
|
||||
store.set('fontSize', clamped);
|
||||
@@ -516,6 +525,157 @@ function setFontSize(size) {
|
||||
fit.fit();
|
||||
}
|
||||
|
||||
async function toggleNotifications() {
|
||||
const current = notify.notificationsState();
|
||||
if (current === 'default') {
|
||||
const result = await notify.requestNotifications();
|
||||
if (result === 'granted') toast.success('Desktop notifications on');
|
||||
else if (result === 'denied') toast.warning('The browser blocked notifications for this site');
|
||||
} else if (current === 'granted') {
|
||||
notify.setMuted(!notify.isMuted());
|
||||
toast.info(notify.isMuted() ? 'Desktop notifications muted in this browser' : 'Desktop notifications unmuted');
|
||||
} else {
|
||||
toast.warning(current === 'denied' ? 'Notifications are blocked in the browser\'s site settings' : 'This browser has no notifications');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- palette & help
|
||||
|
||||
const sendKeys = (keys) => state.conn?.sendInput(keys);
|
||||
|
||||
async function copyLink() {
|
||||
const url = new URL(window.location.href);
|
||||
url.search = state.session ? `?arg=${encodeURIComponent(state.session)}` : '';
|
||||
try {
|
||||
await navigator.clipboard.writeText(url.href);
|
||||
toast.success('Link copied');
|
||||
} catch {
|
||||
toast.error('Could not copy: the browser refused clipboard access');
|
||||
}
|
||||
}
|
||||
|
||||
const openUrl = (url) => window.open(url, '_blank', 'noopener');
|
||||
|
||||
// Commands for the palette (devdesk#32), rebuilt on every open.
|
||||
function buildCommands() {
|
||||
const sessions = [...(sessionsPanel?.data?.sessions ?? [])].sort((a, b) =>
|
||||
(b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name));
|
||||
const current = sessions.find((s) => s.name === state.session);
|
||||
const panelsOpen = ui.body.classList.contains('dd-sidebar-open');
|
||||
const notifyState = notify.notificationsState();
|
||||
const commands = [];
|
||||
const add = (command) => commands.push(command);
|
||||
|
||||
for (const s of sessions) {
|
||||
if (s.name === state.session) continue;
|
||||
const status = sessionStatus(s);
|
||||
add({
|
||||
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: status.label, keywords: s.dir,
|
||||
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name),
|
||||
});
|
||||
}
|
||||
add({ id: 'new', group: 'Sessions', icon: '+', label: 'New session…', keywords: 'create open project', run: () => sessionsPanel.newDialog() });
|
||||
if (state.session) {
|
||||
add({ id: 'rename', group: 'Sessions', icon: '✎', label: `Rename ${state.session}…`, run: () => sessionsPanel.rename(state.session) });
|
||||
add({
|
||||
id: 'end', group: 'Sessions', icon: '×', label: `End ${state.session}…`, keywords: 'close kill stop',
|
||||
run: () => sessionsPanel.end(current ?? { name: state.session, running: true }),
|
||||
});
|
||||
add({ id: 'copy-link', group: 'Sessions', icon: '⧉', label: 'Copy link to this session', keywords: 'url share', run: copyLink });
|
||||
}
|
||||
add({ id: 'picker', group: 'Sessions', icon: '≡', label: 'Open the session picker', keywords: 'fzf list', run: () => connect(null) });
|
||||
|
||||
if (state.session && state.conn?.isOpen) {
|
||||
add({ id: 'win-claude', group: 'Terminal', icon: '0', label: 'Claude window', kbd: '^b 0', run: () => sendKeys('\x020') });
|
||||
add({ id: 'win-shell', group: 'Terminal', icon: '1', label: 'Shell window', kbd: '^b 1', run: () => sendKeys('\x021') });
|
||||
add({ id: 'scroll', group: 'Terminal', icon: '⇞', label: 'Scroll back (tmux copy mode, q to leave)', kbd: '^b [', keywords: 'history scrollback', run: () => sendKeys('\x02[') });
|
||||
add({ id: 'detach', group: 'Terminal', icon: '⏏', label: 'Detach (the session keeps running)', kbd: '^b d', run: () => sendKeys('\x02d') });
|
||||
}
|
||||
|
||||
add({
|
||||
id: 'theme', group: 'View', icon: getTheme() === 'light' ? '◐' : '☀', keywords: 'theme light dark mode colour color',
|
||||
label: getTheme() === 'light' ? 'Switch to dark mode' : 'Switch to light mode', run: toggleTheme,
|
||||
});
|
||||
add({ id: 'panels', group: 'View', icon: '▤', label: panelsOpen ? 'Hide panels' : 'Show panels', keywords: 'sidebar', run: () => setSidebar(!panelsOpen) });
|
||||
add({ id: 'font-up', group: 'View', icon: 'A+', label: 'Larger text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize + 1) });
|
||||
add({ id: 'font-down', group: 'View', icon: 'A−', label: 'Smaller text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize - 1) });
|
||||
add({ id: 'font-reset', group: 'View', icon: 'A', label: 'Reset text size', keywords: 'font zoom', run: () => setFontSize(14) });
|
||||
add({ id: 'fullscreen', group: 'View', icon: '⛶', label: document.fullscreenElement ? 'Exit fullscreen' : 'Fullscreen', run: toggleFullscreen });
|
||||
if (notifyState !== 'unsupported') {
|
||||
add({
|
||||
id: 'notify', group: 'View', icon: '🔔', keywords: 'notifications alerts bell',
|
||||
label: { default: 'Enable desktop notifications', denied: 'Desktop notifications (blocked)', granted: notify.isMuted() ? 'Unmute desktop notifications' : 'Mute desktop notifications' }[notifyState],
|
||||
run: () => toggleNotifications().then(renderNotifyButton),
|
||||
});
|
||||
}
|
||||
|
||||
if (lastRepo?.url) {
|
||||
add({ id: 'open-repo', group: 'Links', icon: '↗', label: `Open ${lastRepo.repo} in Gitea`, run: () => openUrl(lastRepo.url) });
|
||||
add({ id: 'open-prs', group: 'Links', icon: '↗', label: 'Open pull requests', run: () => openUrl(`${lastRepo.url}/pulls`) });
|
||||
if (lastRepo.ci?.url) add({ id: 'open-ci', group: 'Links', icon: '↗', label: `Open CI run (${lastRepo.ci.state})`, run: () => openUrl(lastRepo.ci.url) });
|
||||
}
|
||||
if (lastAlerts?.alerts?.length) {
|
||||
add({
|
||||
id: 'alerts', group: 'Links', icon: '!', label: `Show ${lastAlerts.alerts.length} ops alert(s)`, keywords: 'grafana',
|
||||
run: () => {
|
||||
setSidebar(true);
|
||||
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
},
|
||||
});
|
||||
}
|
||||
add({ id: 'help', group: 'Help', icon: '?', label: 'Keyboard shortcuts', keywords: 'keys help', run: showHelp });
|
||||
|
||||
// After a command, typing goes back to the terminal unless it opened a dialog.
|
||||
return commands.map((c) => ({
|
||||
...c,
|
||||
run: () => {
|
||||
c.run();
|
||||
if (!dialogOpen()) term.focus();
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const KEY_HELP = [
|
||||
['Claude', [
|
||||
['⇧ Enter', 'New line in the prompt'],
|
||||
['Esc', 'Interrupt Claude'],
|
||||
['⇧ Tab', 'Cycle mode (auto-accept, plan…)'],
|
||||
['↑ / ↓', 'Previous / next prompt'],
|
||||
['Ctrl+C', 'Cancel; twice to quit (the menu offers resume)'],
|
||||
]],
|
||||
['Windows & sessions (tmux)', [
|
||||
['Ctrl-b 0 / 1', 'Claude window / shell window'],
|
||||
['Ctrl-b s', 'Switch session (the URL follows)'],
|
||||
['Ctrl-b [', 'Scroll back; q to leave'],
|
||||
['Ctrl-b d', 'Detach (the session keeps running)'],
|
||||
]],
|
||||
['DevDesk', [
|
||||
['Ctrl+⇧ P', 'Command palette'],
|
||||
['Ctrl+⇧ C / V', 'Copy selection / paste'],
|
||||
['⇧ drag', 'Select text in the browser'],
|
||||
['Drop or paste a file', 'Upload it and attach it for Claude'],
|
||||
['Ctrl+W', 'Asks before closing; Chromium fullscreen keeps it for the terminal'],
|
||||
]],
|
||||
];
|
||||
|
||||
function showHelp() {
|
||||
const body = el('div', { class: 'dd-keys' }, ...KEY_HELP.map(([title, rows]) => el('section', {},
|
||||
el('h3', { class: 'dd-keys-title' }, title),
|
||||
el('dl', { class: 'dd-keys-list' }, ...rows.flatMap(([keys, what]) => [el('dt', {}, el('kbd', {}, keys)), el('dd', {}, what)])))));
|
||||
if (touch) body.append(el('p', { class: 'dd-dialog-hint' }, 'On a phone, the key bar under the terminal sends Esc, Tab, ⇧Tab, Ctrl+C, arrows, Enter and the window switches.'));
|
||||
openDialog({ title: 'Keyboard shortcuts', body, actions: [{ label: 'Close', primary: true }] }).then(() => term.focus());
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
return;
|
||||
}
|
||||
await document.documentElement.requestFullscreen();
|
||||
// Chromium: capture Ctrl+W/Ctrl+T/Esc etc. for the terminal while fullscreen.
|
||||
navigator.keyboard?.lock?.().catch(() => {});
|
||||
}
|
||||
|
||||
function renderNotifyButton() {
|
||||
const current = notify.notificationsState();
|
||||
const labels = {
|
||||
@@ -578,20 +738,29 @@ function wireChrome() {
|
||||
$('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1));
|
||||
$('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1));
|
||||
ui.notifyButton.addEventListener('click', async () => {
|
||||
if (notify.notificationsState() === 'default') await notify.requestNotifications();
|
||||
else if (notify.notificationsState() === 'granted') notify.setMuted(!notify.isMuted());
|
||||
await toggleNotifications();
|
||||
renderNotifyButton();
|
||||
});
|
||||
$('fullscreen-toggle').addEventListener('click', async () => {
|
||||
$('fullscreen-toggle').addEventListener('click', () => {
|
||||
closeMenus();
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
return;
|
||||
}
|
||||
await document.documentElement.requestFullscreen();
|
||||
// Chromium: capture Ctrl+W/Ctrl+T/Esc etc. for the terminal while fullscreen.
|
||||
navigator.keyboard?.lock?.().catch(() => {});
|
||||
toggleFullscreen();
|
||||
});
|
||||
$('help-open').addEventListener('click', () => {
|
||||
closeMenus();
|
||||
showHelp();
|
||||
});
|
||||
$('footer-help').addEventListener('click', showHelp);
|
||||
palette = new CommandPalette(buildCommands);
|
||||
$('cmd-toggle').addEventListener('click', () => palette.open());
|
||||
// Ctrl+Shift+P (⌘⇧P): Ctrl+K belongs to the terminal (kill-line in Claude and bash).
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyP') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (palette.isOpen) palette.close();
|
||||
else if (!dialogOpen()) palette.open();
|
||||
}
|
||||
}, true);
|
||||
wireMenu(ui.moreToggle, ui.moreMenu, renderNotifyButton);
|
||||
wireMenu(ui.sessionChip, ui.switcher, () => {
|
||||
renderSwitcher();
|
||||
@@ -608,7 +777,7 @@ function wireChrome() {
|
||||
|
||||
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (event.key !== 'Escape' || dialogOpen()) return;
|
||||
const menuOpen = menus.some(({ menu }) => !menu.hidden);
|
||||
const drawerOpen = isNarrow() && ui.body.classList.contains('dd-sidebar-open');
|
||||
if (!menuOpen && !drawerOpen) return;
|
||||
@@ -680,6 +849,7 @@ function defaultSession(data) {
|
||||
// ---------------------------------------------------------------- boot
|
||||
|
||||
async function boot() {
|
||||
initTheme();
|
||||
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
|
||||
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
|
||||
$('font-size').textContent = String(fontSize);
|
||||
@@ -687,8 +857,11 @@ async function boot() {
|
||||
|
||||
({ term, fit } = createTerminal($('terminal'), {
|
||||
fontSize,
|
||||
theme: getTheme(),
|
||||
onShiftEnter: () => state.conn?.sendInput('\n'),
|
||||
}));
|
||||
onThemeChange((theme) => { term.options.theme = THEMES[theme]; });
|
||||
$('theme-toggle').addEventListener('click', toggleTheme);
|
||||
term.onData((data) => state.conn?.sendInput(data));
|
||||
term.onBinary((data) => state.conn?.sendBinary(data));
|
||||
term.onResize(({ cols, rows }) => state.conn?.resize(cols, rows));
|
||||
|
||||
Reference in New Issue
Block a user