Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/apps/documentation/public/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ const MODULES_HTML = `
<h3>Default Shortcuts</h3>
<table class="dc-api-table">
<tbody>
<tr><td><code>Ctrl+Alt+P</code></td><td>Open project switcher popup</td></tr>
<tr><td><code>Ctrl+Alt+D</code></td><td>Switch to Dashboard (grid view) in Shell</td></tr>
<tr><td><code>Ctrl+Alt+1&ndash;9</code></td><td>Switch to terminal tab 1&ndash;9</td></tr>
<tr><td><code>Ctrl+Alt+J</code></td><td>New terminal pane</td></tr>
Expand Down
5 changes: 5 additions & 0 deletions src/packages/shared-assets/keymap-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
140 changes: 140 additions & 0 deletions src/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
111 changes: 111 additions & 0 deletions src/public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading