diff --git a/src/apps/documentation/public/page.js b/src/apps/documentation/public/page.js index 85050bd..b980afa 100644 --- a/src/apps/documentation/public/page.js +++ b/src/apps/documentation/public/page.js @@ -381,6 +381,7 @@ const MODULES_HTML = `

Default Shortcuts

+ diff --git a/src/packages/shared-assets/keymap-registry.js b/src/packages/shared-assets/keymap-registry.js index 38bfd6f..a648aaa 100644 --- a/src/packages/shared-assets/keymap-registry.js +++ b/src/packages/shared-assets/keymap-registry.js @@ -493,6 +493,11 @@ { ctrlOrMeta: true, altKey: true, code: 'KeyD' }, 'Dashboard (grid view)', 'Shell'); + // Navigation group + KeymapRegistry.register('nav:project-switcher', + { ctrlOrMeta: true, altKey: true, code: 'KeyP' }, + 'Project switcher', 'Navigation'); + // Voice group KeymapRegistry.register('voice:hold-to-speak', { ctrlOrMeta: true, altKey: true, shiftKey: true }, diff --git a/src/public/app.js b/src/public/app.js index a053688..2a676f7 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -695,6 +695,146 @@ if (typeof VoiceWidget !== 'undefined') { }); } +// ── Keyboard project switcher (Ctrl+Alt+P) ────────────────────────────────── + +let _projectSwitcherOpen = false; + +function openProjectSwitcher() { + if (_projectSwitcherOpen) return; + _projectSwitcherOpen = true; + + const projects = getProjectList(); + const active = getActiveProject(); + + const overlay = document.createElement('div'); + overlay.className = 'project-switcher-overlay'; + + const popup = document.createElement('div'); + popup.className = 'project-switcher'; + + const header = document.createElement('div'); + header.className = 'project-switcher-header'; + header.textContent = 'Switch Project'; + popup.appendChild(header); + + const list = document.createElement('div'); + list.className = 'project-switcher-list'; + list.setAttribute('role', 'listbox'); + + if (projects.length === 0) { + const empty = document.createElement('div'); + empty.className = 'project-switcher-empty'; + empty.textContent = 'No projects registered'; + list.appendChild(empty); + } + + let selectedIdx = projects.findIndex(p => p.id === active?.id); + if (selectedIdx < 0) selectedIdx = 0; + + projects.forEach((p, i) => { + const item = document.createElement('button'); + item.className = 'project-switcher-item'; + if (p.id === active?.id) item.classList.add('active'); + if (i === selectedIdx) item.classList.add('selected'); + item.setAttribute('role', 'option'); + item.dataset.idx = String(i); + + const name = document.createElement('span'); + name.className = 'project-switcher-item-name'; + name.textContent = p.name; + + const path = document.createElement('span'); + path.className = 'project-switcher-item-path'; + path.textContent = p.path; + + item.appendChild(name); + item.appendChild(path); + list.appendChild(item); + + item.addEventListener('click', () => { + dashboardSocket.emit('project:activate', { id: p.id }); + closeProjectSwitcher(); + }); + }); + + popup.appendChild(list); + overlay.appendChild(popup); + document.body.appendChild(overlay); + + // Focus management + requestAnimationFrame(() => { + overlay.classList.add('visible'); + scrollSelectedIntoView(list); + }); + + function scrollSelectedIntoView(container) { + const sel = container.querySelector('.selected'); + if (sel) sel.scrollIntoView({ block: 'nearest' }); + } + + function updateSelection(newIdx) { + if (projects.length === 0) return; + const items = list.querySelectorAll('.project-switcher-item'); + items[selectedIdx]?.classList.remove('selected'); + selectedIdx = ((newIdx % projects.length) + projects.length) % projects.length; + items[selectedIdx]?.classList.add('selected'); + scrollSelectedIntoView(list); + } + + function onKeyDown(e) { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + closeProjectSwitcher(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + updateSelection(selectedIdx + 1); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + updateSelection(selectedIdx - 1); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (projects.length > 0) { + dashboardSocket.emit('project:activate', { id: projects[selectedIdx].id }); + } + closeProjectSwitcher(); + } + } + + document.addEventListener('keydown', onKeyDown, true); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) closeProjectSwitcher(); + }); + + overlay._cleanup = () => { + document.removeEventListener('keydown', onKeyDown, true); + }; +} + +function closeProjectSwitcher() { + if (!_projectSwitcherOpen) return; + _projectSwitcherOpen = false; + const overlay = document.querySelector('.project-switcher-overlay'); + if (overlay) { + overlay._cleanup?.(); + overlay.classList.remove('visible'); + setTimeout(() => overlay.remove(), 200); + } +} + +// Global keydown handler for the project switcher hotkey +document.addEventListener('keydown', (e) => { + if (typeof KeymapRegistry === 'undefined') return; + if (KeymapRegistry.matchesAction(e, 'nav:project-switcher')) { + e.preventDefault(); + if (_projectSwitcherOpen) { + closeProjectSwitcher(); + } else { + openProjectSwitcher(); + } + } +}); + // Expose SPA navigate for scenario-runner (spaMode) window.__devglideSpaNavigate = selectApp; diff --git a/src/public/style.css b/src/public/style.css index 69ae966..21a5a84 100644 --- a/src/public/style.css +++ b/src/public/style.css @@ -1243,3 +1243,114 @@ a:focus-visible, border-color: color-mix(in srgb, var(--df-color-state-error) 50%, transparent); color: var(--df-color-state-error); } + +/* ── Project Switcher Popup ───────────────────────────────────────────────── */ + +.project-switcher-overlay { + position: fixed; + inset: 0; + background: color-mix(in srgb, var(--df-color-bg-base) 60%, transparent); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: var(--df-z-index-modal, 1000); + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 18vh; + opacity: 0; + transition: opacity var(--df-duration-fast); +} + +.project-switcher-overlay.visible { + opacity: 1; +} + +.project-switcher { + background: color-mix(in srgb, var(--df-color-bg-surface) 92%, transparent); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--df-color-border-default); + border-radius: var(--df-radius-xl); + width: 420px; + max-width: 90vw; + max-height: 50vh; + display: flex; + flex-direction: column; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5), 0 0 0 1px color-mix(in srgb, var(--df-color-border-strong) 30%, transparent); + transform: translateY(-8px); + transition: transform var(--df-duration-fast) var(--df-easing-spring); +} + +.project-switcher-overlay.visible .project-switcher { + transform: translateY(0); +} + +.project-switcher-header { + font-family: var(--df-font-mono); + font-size: var(--df-font-size-xs); + font-weight: normal; + text-transform: uppercase; + letter-spacing: var(--df-letter-spacing-wider); + color: var(--df-color-text-secondary); + padding: var(--df-space-3) var(--df-space-4); + border-bottom: 1px solid var(--df-color-border-subtle); +} + +.project-switcher-list { + overflow-y: auto; + padding: var(--df-space-2); +} + +.project-switcher-empty { + font-family: var(--df-font-mono); + font-size: var(--df-font-size-sm); + color: var(--df-color-text-muted); + text-align: center; + padding: var(--df-space-6) var(--df-space-4); +} + +.project-switcher-item { + display: flex; + align-items: baseline; + gap: var(--df-space-3); + width: 100%; + padding: var(--df-space-2) var(--df-space-3); + border: 1px solid transparent; + border-radius: var(--df-radius-md); + background: none; + cursor: pointer; + font-family: var(--df-font-mono); + text-align: left; + color: var(--df-color-text-primary); + transition: background var(--df-duration-fast), border-color var(--df-duration-fast); +} + +.project-switcher-item.selected { + background: color-mix(in srgb, var(--df-color-accent-default) 12%, transparent); + border-color: color-mix(in srgb, var(--df-color-accent-default) 30%, transparent); +} + +.project-switcher-item.active .project-switcher-item-name::after { + content: ' \2713'; + color: var(--df-color-accent-default); + font-size: var(--df-font-size-xs); +} + +.project-switcher-item:hover { + background: var(--df-color-bg-raised); +} + +.project-switcher-item-name { + font-size: var(--df-font-size-sm); + font-weight: 500; + white-space: nowrap; +} + +.project-switcher-item-path { + font-size: var(--df-font-size-xs); + color: var(--df-color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +}
Ctrl+Alt+POpen project switcher popup
Ctrl+Alt+DSwitch to Dashboard (grid view) in Shell
Ctrl+Alt+1–9Switch to terminal tab 1–9
Ctrl+Alt+JNew terminal pane