<script lang="ts">
import { tick } from "svelte";
import { ui } from "../../stores/ui.svelte.js";
import { sync } from "../../stores/sync.svelte.js";
import { workbench } from "../../stores/workbench.svelte.js";
import { starred } from "../../stores/starred.svelte.js";
import { explorer } from "../../stores/explorer.svelte.js";
import { commands } from "../../stores/commands.svelte.js";
import { notifications } from "../../stores/notifications.svelte.js";
import AppIcon from "../layout/AppIcon.svelte";
import type { AppIconName } from "../layout/AppIcon.svelte";
import { onMount } from "svelte";
interface Command {
id: string;
label: string;
description?: string;
icon: AppIconName;
action: () => void;
}
let inputRef: HTMLInputElement | undefined = $state(undefined);
let query = $state("");
let selectedIndex = $state(0);
onMount(() => {
void explorer.ensureWorkbenchModel();
});
const isMac = navigator.platform.toUpperCase().includes("MAC");
const mod = isMac ? "\u2318" : "Ctrl+";
const baseCommands: Command[] = [
{
id: "workbench.action.quickOpen",
label: "Search Sessions & Files",
description: `${mod}P`,
icon: "search",
action: () => { void commands.execute("workbench.action.quickOpen"); },
},
{
id: "workbench.action.openWorktree",
label: "Go to Worktree…",
description: `${mod}K`,
icon: "folder",
action: () => { void commands.execute("workbench.action.openWorktree"); },
},
{
id: "nav.sessions",
label: "Go to Sessions",
icon: "messages-square",
action: () => { workbench.showView("worktree", "sessions"); close(); },
},
{
id: "workbench.view.explorer",
label: "Go to Explorer",
description: `${mod}${isMac ? "\u21E7" : "Shift+"}E`,
icon: "folder",
action: () => { void commands.execute("workbench.view.explorer"); close(); },
},
{
id: "workbench.view.extensions",
label: "Go to Extensions",
description: `${mod}${isMac ? "\u21E7" : "Shift+"}X`,
icon: "code",
action: () => { void commands.execute("workbench.view.extensions"); close(); },
},
{
id: "nav.catalogue",
label: "Go to Test Catalogue",
icon: "code",
action: () => { workbench.openCatalogueDiagnostics(); close(); },
},
{
id: "nav.settings",
label: "Go to Settings",
icon: "settings",
action: () => { workbench.openSettings(); close(); },
},
{
id: "workbench.action.selectTheme",
label: "Preferences: Color Theme",
icon: "palette",
action: () => { ui.activeModal = "themePicker"; },
},
{
id: "workbench.action.toggleSidebarVisibility",
label: "Toggle Sidebar",
description: `${mod}B`,
icon: "panel-left",
action: () => { void commands.execute("workbench.action.toggleSidebarVisibility"); close(); },
},
{
id: "workbench.action.togglePanel",
label: "Toggle Panel",
description: `${mod}J`,
icon: "panel-bottom",
action: () => { void commands.execute("workbench.action.togglePanel"); close(); },
},
{
id: "workbench.action.closeActiveEditor",
label: "Close Editor",
description: `${mod}W`,
icon: "x",
action: () => { void commands.execute("workbench.action.closeActiveEditor"); close(); },
},
{
id: "workbench.action.closeAllEditors",
label: "Close All Editors",
icon: "x",
action: () => { void commands.execute("workbench.action.closeAllEditors"); close(); },
},
{
id: "workbench.action.splitEditor",
label: "Split Editor Right",
description: `${mod}\\`,
icon: "panel-left",
action: () => { void commands.execute("workbench.action.splitEditor"); close(); },
},
{
id: "workbench.action.output.focus",
label: "Output: Show Output",
icon: "panel-bottom",
action: () => { void commands.execute("workbench.action.output.focus"); close(); },
},
{
id: "workbench.action.problems.focus",
label: "Problems: Show Problems",
description: `${mod}${isMac ? "\u21E7" : "Shift+"}M`,
icon: "triangle-alert",
action: () => { void commands.execute("workbench.action.problems.focus"); close(); },
},
{
id: "sync",
label: "Sync Sessions",
icon: "refresh",
action: () => { sync.triggerSync(); close(); },
},
{
id: "starred.toggle",
label: starred.filterOnly ? "Show All Sessions" : "Show Starred Only",
icon: "star",
action: () => { starred.filterOnly = !starred.filterOnly; close(); },
},
{
id: "shortcuts",
label: "Keyboard Shortcuts",
description: "?",
icon: "keyboard",
action: () => { ui.activeModal = "shortcuts"; },
},
{
id: "about",
label: "About",
icon: "info",
action: () => { ui.activeModal = "about"; },
},
];
let allCommands = $derived.by(() => {
const activePath =
workbench.activeTab?.input.kind === "file" ? workbench.activeTab.input.path : null;
const context = activePath ? explorer.editorContextForPath(activePath) : null;
const model = explorer.workbenchModel.model;
const extensionCommands: Command[] = [];
if (model) {
for (const entry of explorer.menuCommands("commandPalette", context)) {
const extension = model.extensions.find(
(item) => item.id === entry.extension_id,
);
if (!extension) continue;
extensionCommands.push({
id: `extension:${extension.id}:${entry.command}`,
label: entry.title,
description: extension.display_name,
icon: "code" as const,
action: async () => {
try {
await commands.execute(entry.command);
} catch (error) {
notifications.upsert(
`extension-command:${entry.command}`,
"error",
"Extension command failed",
error instanceof Error ? error.message : String(error),
"extensions",
);
} finally {
close();
}
},
});
}
}
return [...baseCommands, ...extensionCommands];
});
let filtered = $derived.by(() => {
if (!query) return allCommands;
const q = query.toLowerCase().replace(/^>\s*/, "");
return allCommands.filter(
(c) =>
c.label.toLowerCase().includes(q) ||
c.description?.toLowerCase().includes(q),
);
});
$effect(() => {
// Reset selection when filtered list changes.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
filtered;
selectedIndex = 0;
});
$effect(() => {
if (inputRef) inputRef.focus();
});
$effect(() => {
const _idx = selectedIndex;
tick().then(() => {
document
.querySelector(".cmd-results .cmd-item.selected")
?.scrollIntoView({ block: "nearest" });
});
});
function close() {
ui.activeModal = null;
}
function run(cmd: Command) {
cmd.action();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, filtered.length - 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
} else if (e.key === "Enter") {
e.preventDefault();
const cmd = filtered[selectedIndex];
if (cmd) run(cmd);
} else if (e.key === "Escape") {
e.preventDefault();
close();
}
}
function handleOverlayClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains("cmd-overlay")) close();
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="cmd-overlay" onclick={handleOverlayClick} onkeydown={handleKeydown}>
<div class="cmd-palette">
<div class="cmd-input-wrap">
<span class="cmd-prefix">></span>
<input
bind:this={inputRef}
class="cmd-input"
type="text"
placeholder="Type a command..."
bind:value={query}
/>
<kbd class="esc-hint">Esc</kbd>
</div>
<div class="cmd-results">
{#if filtered.length === 0}
<div class="cmd-empty">No commands found</div>
{:else}
{#each filtered as cmd, i (cmd.id)}
<button
class="cmd-item"
class:selected={i === selectedIndex}
onclick={() => run(cmd)}
onmouseenter={() => (selectedIndex = i)}
>
<span class="cmd-icon">
<AppIcon name={cmd.icon} size={14} />
</span>
<span class="cmd-label">{cmd.label}</span>
{#if cmd.description}
<span class="cmd-desc">{cmd.description}</span>
{/if}
</button>
{/each}
{/if}
</div>
</div>
</div>
<style>
.cmd-overlay {
position: fixed;
inset: 0;
background: var(--vscode-widget-shadow);
display: flex;
justify-content: center;
padding-top: 20vh;
z-index: 100;
}
.cmd-palette {
width: 480px;
max-height: 360px;
background: var(--vscode-sideBar-background);
border: 1px solid var(--vscode-panel-border);
border-radius: var(--wsb-radius-lg);
box-shadow: var(--wb-shadow-md);
display: flex;
flex-direction: column;
overflow: hidden;
}
.cmd-input-wrap {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
padding: var(--wb-spacing-10) var(--wb-spacing-14);
border-bottom: 1px solid var(--vscode-panel-border);
}
.cmd-prefix {
font-size: var(--wb-font-size-16);
font-weight: 600;
color: var(--vscode-focusBorder);
font-family: var(--wsb-font-mono);
flex-shrink: 0;
line-height: 1;
}
.cmd-input {
flex: 1;
background: none;
border: none;
font-size: var(--wb-font-size-14);
color: var(--vscode-editor-foreground);
outline: none;
}
.cmd-input::placeholder {
color: var(--vscode-disabledForeground);
}
.esc-hint {
font-size: var(--wb-font-size-10);
padding: var(--wb-spacing-1) var(--wb-spacing-5);
border: 1px solid var(--vscode-panel-border);
border-radius: var(--wsb-radius-sm);
color: var(--vscode-disabledForeground);
background: var(--vscode-input-background);
font-family: var(--wsb-font-sans);
flex-shrink: 0;
}
.cmd-results {
overflow-y: auto;
flex: 1;
padding: var(--wb-spacing-4) 0;
}
.cmd-item {
display: flex;
align-items: center;
gap: var(--wb-spacing-10);
width: 100%;
padding: var(--wb-spacing-7) var(--wb-spacing-14);
text-align: left;
font-size: var(--wb-font-size-13);
color: var(--vscode-editor-foreground);
}
.cmd-item:hover,
.cmd-item.selected {
background: var(--vscode-list-hoverBackground);
}
.cmd-icon {
color: var(--vscode-disabledForeground);
flex-shrink: 0;
display: flex;
}
.cmd-item.selected .cmd-icon {
color: var(--vscode-focusBorder);
}
.cmd-label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cmd-desc {
font-size: var(--wb-font-size-11);
color: var(--vscode-disabledForeground);
white-space: nowrap;
flex-shrink: 0;
}
.cmd-empty {
padding: var(--wb-spacing-16);
text-align: center;
color: var(--vscode-disabledForeground);
font-size: var(--wb-font-size-13);
}
</style>