wraith/src/components/common/CommandPalette.vue
Vantz Stockwell 0cd4cc0f64 feat: Phase 5 complete — themes, editor, shortcuts, workspace, settings
Theme service: 7 built-in themes seeded from Rust, ThemePicker
loads from backend. Workspace service: save/load snapshots, crash
recovery detection. SettingsModal: full port with Tauri invoke.
CommandPalette, HostKeyDialog, ConnectionEditDialog all ported.

CodeMirror editor: inline above terminal, reads/writes via SFTP.
Full keyboard shortcuts: Ctrl+K/W/Tab/Shift+Tab/1-9/B/F.
Terminal search bar via Ctrl+F (SearchAddon).
Tab badges: protocol dots, ROOT warning, environment pills.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:36:06 -04:00

270 lines
11 KiB
Vue

<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]"
@click.self="close"
@keydown.esc="close"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50" @click="close" />
<!-- Palette -->
<div
ref="paletteRef"
class="relative w-full max-w-lg bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden animate-fade-in"
>
<!-- Search input -->
<div class="flex items-center px-4 py-3 border-b border-[#30363d]">
<svg class="w-4 h-4 text-[var(--wraith-text-muted)] mr-3 shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.5 7a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0zm-.82 4.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04z" />
</svg>
<input
ref="inputRef"
v-model="query"
type="text"
placeholder="Search connections, actions..."
class="flex-1 bg-transparent text-sm text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none"
@keydown.down.prevent="moveSelection(1)"
@keydown.up.prevent="moveSelection(-1)"
@keydown.enter.prevent="executeSelected"
/>
<kbd class="ml-2 px-1.5 py-0.5 text-[10px] text-[var(--wraith-text-muted)] bg-[#30363d] rounded border border-[#484f58]">ESC</kbd>
</div>
<!-- Results -->
<div class="max-h-80 overflow-y-auto py-1">
<!-- Connections group -->
<template v-if="filteredConnections.length > 0">
<div class="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--wraith-text-muted)]">
Connections
</div>
<button
v-for="(conn, idx) in filteredConnections"
:key="`conn-${conn.id}`"
class="w-full flex items-center gap-3 px-4 py-2 text-sm text-left transition-colors cursor-pointer"
:class="selectedIndex === idx ? 'bg-[#1f6feb]/20 text-[var(--wraith-text-primary)]' : 'text-[var(--wraith-text-secondary)] hover:bg-[#30363d]'"
@click="connectTo(conn)"
@mouseenter="selectedIndex = idx"
>
<span
class="w-2 h-2 rounded-full shrink-0"
:class="conn.protocol === 'ssh' ? 'bg-[#3fb950]' : 'bg-[#1f6feb]'"
/>
<span class="flex-1 truncate">{{ conn.name }}</span>
<span class="text-xs text-[var(--wraith-text-muted)]">{{ conn.hostname }}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-[#30363d] text-[var(--wraith-text-muted)] uppercase">
{{ conn.protocol }}
</span>
</button>
</template>
<!-- Actions group -->
<template v-if="filteredActions.length > 0">
<div class="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--wraith-text-muted)]">
Actions
</div>
<button
v-for="(action, idx) in filteredActions"
:key="`action-${action.id}`"
class="w-full flex items-center gap-3 px-4 py-2 text-sm text-left transition-colors cursor-pointer"
:class="selectedIndex === filteredConnections.length + idx ? 'bg-[#1f6feb]/20 text-[var(--wraith-text-primary)]' : 'text-[var(--wraith-text-secondary)] hover:bg-[#30363d]'"
@click="executeAction(action)"
@mouseenter="selectedIndex = filteredConnections.length + idx"
>
<span class="w-4 h-4 flex items-center justify-center text-[var(--wraith-text-muted)] shrink-0" v-html="action.icon" />
<span class="flex-1 truncate">{{ action.label }}</span>
<span v-if="action.shortcut" class="text-[10px] px-1.5 py-0.5 rounded bg-[#30363d] text-[var(--wraith-text-muted)]">
{{ action.shortcut }}
</span>
</button>
</template>
<!-- No results -->
<div
v-if="filteredConnections.length === 0 && filteredActions.length === 0"
class="px-4 py-8 text-center text-sm text-[var(--wraith-text-muted)]"
>
No results found
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import { useConnectionStore, type Connection } from "@/stores/connection.store";
import { useSessionStore } from "@/stores/session.store";
interface PaletteAction {
id: string;
label: string;
icon: string;
shortcut?: string;
handler: () => void;
}
const visible = ref(false);
const query = ref("");
const selectedIndex = ref(0);
const inputRef = ref<HTMLInputElement | null>(null);
const paletteRef = ref<HTMLDivElement | null>(null);
const connectionStore = useConnectionStore();
const sessionStore = useSessionStore();
const emit = defineEmits<{
(e: "open-import"): void;
(e: "open-settings"): void;
(e: "open-new-connection", protocol?: "ssh" | "rdp"): void;
}>();
const actions: PaletteAction[] = [
{
id: "new-ssh",
label: "New SSH Connection",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4"><path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM7.25 8a.749.749 0 0 1-.22.53l-2.25 2.25a.749.749 0 1 1-1.06-1.06L5.44 8 3.72 6.28a.749.749 0 1 1 1.06-1.06l2.25 2.25c.141.14.22.331.22.53Zm1.5 1.5h3a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5Z"/></svg>`,
handler: () => {
emit("open-new-connection", "ssh");
close();
},
},
{
id: "new-rdp",
label: "New RDP Connection",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4"><path d="M1.75 2.5h12.5a.25.25 0 0 1 .25.25v7.5a.25.25 0 0 1-.25.25H1.75a.25.25 0 0 1-.25-.25v-7.5a.25.25 0 0 1 .25-.25ZM14.25 1H1.75A1.75 1.75 0 0 0 0 2.75v7.5C0 11.216.784 12 1.75 12h4.388l-.533 1.5H4a.75.75 0 0 0 0 1.5h8a.75.75 0 0 0 0-1.5h-1.605l-.533-1.5h4.388A1.75 1.75 0 0 0 16 10.25v-7.5A1.75 1.75 0 0 0 14.25 1ZM9.112 13.5H6.888l.533-1.5h1.158l.533 1.5Z"/></svg>`,
handler: () => {
emit("open-new-connection", "rdp");
close();
},
},
{
id: "open-vault",
label: "Open Vault",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4"><path d="M4 4v2h-.25A1.75 1.75 0 0 0 2 7.75v5.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0 0 14 13.25v-5.5A1.75 1.75 0 0 0 12.25 6H12V4a4 4 0 1 0-8 0Zm6.5 2V4a2.5 2.5 0 0 0-5 0v2ZM8 9.5a1.5 1.5 0 0 1 .5 2.915V13.5a.5.5 0 0 1-1 0v-1.085A1.5 1.5 0 0 1 8 9.5Z"/></svg>`,
handler: () => {
// TODO: Navigate to vault
close();
},
},
{
id: "settings",
label: "Settings",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4"><path d="M8 0a8.2 8.2 0 0 1 .701.031C8.955.017 9.209 0 9.466 0a1.934 1.934 0 0 1 1.466.665c.33.367.51.831.54 1.316a7.96 7.96 0 0 1 .82.4c.463-.207.97-.29 1.476-.19.504.1.963.37 1.3.77.339.404.516.91.5 1.423a1.94 1.94 0 0 1-.405 1.168 8.02 8.02 0 0 1 .356.9 1.939 1.939 0 0 1 1.48.803 1.941 1.941 0 0 1 0 2.29 1.939 1.939 0 0 1-1.48.803c-.095.316-.215.622-.357.9a1.94 1.94 0 0 1-.094 2.59 1.94 1.94 0 0 1-2.776.22 7.96 7.96 0 0 1-.82.4 1.94 1.94 0 0 1-2.006 1.98A8.2 8.2 0 0 1 8 16a8.2 8.2 0 0 1-.701-.031 1.938 1.938 0 0 1-2.005-1.98 7.96 7.96 0 0 1-.82-.4 1.94 1.94 0 0 1-2.776-.22 1.94 1.94 0 0 1-.094-2.59 8.02 8.02 0 0 1-.357-.9A1.939 1.939 0 0 1 0 8.945a1.941 1.941 0 0 1 0-2.29 1.939 1.939 0 0 1 1.247-.803c.095-.316.215-.622.357-.9a1.94 1.94 0 0 1 .094-2.59 1.94 1.94 0 0 1 2.776-.22c.258-.157.532-.293.82-.4A1.934 1.934 0 0 1 6.834.665 1.934 1.934 0 0 1 8.3.03 8.2 8.2 0 0 1 8 0ZM8 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>`,
handler: () => {
emit("open-settings");
close();
},
},
{
id: "import-moba",
label: "Import MobaXTerm Config",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4"><path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14ZM11.78 4.72a.749.749 0 1 1-1.06 1.06L8.75 3.81V9.5a.75.75 0 0 1-1.5 0V3.81L5.28 5.78a.749.749 0 1 1-1.06-1.06l3.25-3.25a.749.749 0 0 1 1.06 0l3.25 3.25Z"/></svg>`,
handler: () => {
emit("open-import");
close();
},
},
];
/** Fuzzy match: checks if all characters of needle appear in order in haystack. */
function fuzzyMatch(needle: string, haystack: string): boolean {
const n = needle.toLowerCase();
const h = haystack.toLowerCase();
let ni = 0;
for (let hi = 0; hi < h.length && ni < n.length; hi++) {
if (h[hi] === n[ni]) ni++;
}
return ni === n.length;
}
const filteredConnections = computed(() => {
const q = query.value.trim();
if (!q) return connectionStore.connections.slice(0, 10);
return connectionStore.connections.filter(
(c) =>
fuzzyMatch(q, c.name) ||
fuzzyMatch(q, c.hostname) ||
c.tags?.some((t) => fuzzyMatch(q, t)),
);
});
const filteredActions = computed(() => {
const q = query.value.trim();
if (!q) return actions;
return actions.filter((a) => fuzzyMatch(q, a.label));
});
const totalItems = computed(() => filteredConnections.value.length + filteredActions.value.length);
function moveSelection(delta: number): void {
if (totalItems.value === 0) return;
selectedIndex.value = (selectedIndex.value + delta + totalItems.value) % totalItems.value;
}
function executeSelected(): void {
if (totalItems.value === 0) return;
const idx = selectedIndex.value;
if (idx < filteredConnections.value.length) {
connectTo(filteredConnections.value[idx]);
} else {
const actionIdx = idx - filteredConnections.value.length;
executeAction(filteredActions.value[actionIdx]);
}
}
function connectTo(conn: Connection): void {
sessionStore.connect(conn.id);
close();
}
function executeAction(action: PaletteAction): void {
action.handler();
}
function open(): void {
visible.value = true;
query.value = "";
selectedIndex.value = 0;
nextTick(() => inputRef.value?.focus());
}
function close(): void {
visible.value = false;
}
function toggle(): void {
if (visible.value) {
close();
} else {
open();
}
}
// Reset selection when query changes
watch(query, () => {
selectedIndex.value = 0;
});
defineExpose({ open, close, toggle, visible });
</script>
<style scoped>
.animate-fade-in {
animation: palette-fade-in 0.1s ease-out;
}
@keyframes palette-fade-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>