feat(ui): add command palette with Ctrl+K global shortcut
Fuzzy-search modal searches across connections (name, hostname, tags) and app actions (new SSH/RDP, open vault, settings, import). Supports arrow-key navigation, Enter to execute, Esc to close. Dark themed with quick fade-in animation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
39471244ac
commit
5a86fe6e0c
267
frontend/src/components/common/CommandPalette.vue
Normal file
267
frontend/src/components/common/CommandPalette.vue
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
<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;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
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: () => {
|
||||||
|
// TODO: Open new connection dialog for 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: () => {
|
||||||
|
// TODO: Open new connection dialog for 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: () => {
|
||||||
|
// TODO: Open settings dialog
|
||||||
|
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>
|
||||||
Loading…
Reference in New Issue
Block a user