From b24316843e7ca3c44a9d7f28a2486435391ce742 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 20 Jul 2026 11:05:50 -0400 Subject: [PATCH] keyboard: command palette (Cmd/Ctrl+K) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fuzzy quick-open overlay: type to jump to any note by title (titles index) or run a command — New note, Go to Board/Graph/Reminders/Archive/Trash, and Open Settings (admins). Arrow keys move the selection, Enter activates, Esc closes; Cmd/Ctrl+K toggles it (works even while typing). Selecting a note navigates to the board with ?open=; BoardView watches that query and opens the editor (fetching the note if it isn't loaded), then clears the param. Added to the ? cheat-sheet. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- frontend/src/components/AppShell.vue | 11 ++ frontend/src/components/CommandPalette.vue | 139 +++++++++++++++++++++ frontend/src/views/BoardView.vue | 19 ++- 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/CommandPalette.vue diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index d5fea3d..21eb92b 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -5,6 +5,7 @@ import { useSessionStore } from "../stores/session"; import { useConfigStore } from "../stores/config"; import { useLabelsStore } from "../stores/labels"; import { useUiStore } from "../stores/ui"; +import CommandPalette from "./CommandPalette.vue"; import Icon from "./Icon.vue"; import LabelsModal from "./LabelsModal.vue"; @@ -17,11 +18,13 @@ const ui = useUiStore(); const managing = ref(false); const showShortcuts = ref(false); +const paletteOpen = ref(false); const searchInput = ref(null); const searchText = ref(typeof route.query.q === "string" ? route.query.q : ""); let searchTimer: ReturnType | undefined; const shortcuts = [ + { label: "Command palette", keys: ["⌘/Ctrl", "K"] }, { label: "New note", keys: ["c"] }, { label: "Search", keys: ["/"] }, { label: "Go to Board", keys: ["g", "b"] }, @@ -57,6 +60,12 @@ function compose() { // typing in a field (except Esc, which blurs it). `g` starts a two-key jump // (g b / g g / g r) with a short timeout. function onKeydown(e: KeyboardEvent) { + // Cmd/Ctrl+K toggles the command palette — works even while typing. + if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) { + e.preventDefault(); + paletteOpen.value = !paletteOpen.value; + return; + } if (isTyping(e)) { if (e.key === "Escape") (e.target as HTMLElement).blur(); return; @@ -238,6 +247,8 @@ async function signOut() { + +
+import { computed, nextTick, onMounted, ref, watch } from "vue"; +import { useRouter } from "vue-router"; +import { useSessionStore } from "../stores/session"; +import { useTitlesStore } from "../stores/titles"; +import { useUiStore } from "../stores/ui"; + +const emit = defineEmits<{ (e: "close"): void }>(); + +const router = useRouter(); +const session = useSessionStore(); +const titles = useTitlesStore(); +const ui = useUiStore(); + +const query = ref(""); +const selected = ref(0); +const input = ref(null); + +interface Row { + id: string; + label: string; + hint: string; + run: () => void; +} + +function go(path: string) { + void router.push(path); + emit("close"); +} +function compose() { + void router.push("/").then(() => ui.requestCompose()); + emit("close"); +} +function openNote(id: string) { + void router.push({ path: "/", query: { open: id } }); + emit("close"); +} + +// Static command actions, filtered by query alongside note titles. +const commands = computed(() => { + const list: Row[] = [ + { id: "cmd:new", label: "New note", hint: "Action", run: compose }, + { id: "cmd:board", label: "Go to Board", hint: "Navigate", run: () => go("/") }, + { id: "cmd:graph", label: "Go to Graph", hint: "Navigate", run: () => go("/graph") }, + { id: "cmd:reminders", label: "Go to Reminders", hint: "Navigate", run: () => go("/reminders") }, + { id: "cmd:archive", label: "Go to Archive", hint: "Navigate", run: () => go("/archive") }, + { id: "cmd:trash", label: "Go to Trash", hint: "Navigate", run: () => go("/trash") }, + ]; + if (session.user?.is_admin) { + list.push({ id: "cmd:settings", label: "Open Settings", hint: "Navigate", run: () => go("/settings") }); + } + return list; +}); + +const results = computed(() => { + const q = query.value.trim().toLowerCase(); + const cmds = commands.value.filter((c) => !q || c.label.toLowerCase().includes(q)); + const notes: Row[] = titles.items + .filter((t) => t.title && (!q || t.title.toLowerCase().includes(q))) + .slice(0, q ? 12 : 6) + .map((t) => ({ id: `note:${t.id}`, label: t.title, hint: "Note", run: () => openNote(t.id) })); + // When searching text, surface matching notes first; when empty, lead with actions. + return q ? [...notes, ...cmds] : [...cmds, ...notes]; +}); + +watch(query, () => (selected.value = 0)); + +function activate() { + results.value[selected.value]?.run(); +} + +function onKeydown(e: KeyboardEvent) { + if (e.key === "ArrowDown") { + e.preventDefault(); + selected.value = Math.min(selected.value + 1, results.value.length - 1); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + selected.value = Math.max(selected.value - 1, 0); + } else if (e.key === "Enter") { + e.preventDefault(); + activate(); + } else if (e.key === "Escape") { + e.preventDefault(); + emit("close"); + } +} + +onMounted(async () => { + await titles.load(); + await nextTick(); + input.value?.focus(); +}); + + + diff --git a/frontend/src/views/BoardView.vue b/frontend/src/views/BoardView.vue index 67bc498..5c4ec51 100644 --- a/frontend/src/views/BoardView.vue +++ b/frontend/src/views/BoardView.vue @@ -1,6 +1,6 @@