keyboard: command palette (Cmd/Ctrl+K)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 30s

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=<id>; 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-20 11:05:50 -04:00
co-authored by Claude Opus 4.8
parent 4bd4ce5a1f
commit b24316843e
3 changed files with 168 additions and 1 deletions
+11
View File
@@ -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<HTMLInputElement | null>(null);
const searchText = ref(typeof route.query.q === "string" ? route.query.q : "");
let searchTimer: ReturnType<typeof setTimeout> | 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() {
<LabelsModal v-if="managing" @close="managing = false" />
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
<div
v-if="showShortcuts"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
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<HTMLInputElement | null>(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<Row[]>(() => {
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<Row[]>(() => {
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();
});
</script>
<template>
<div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4 pt-[12vh]"
@mousedown.self="emit('close')"
>
<div
class="w-full max-w-lg overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-2xl dark:border-neutral-700 dark:bg-neutral-900"
role="dialog"
aria-modal="true"
aria-label="Command palette"
>
<input
ref="input"
v-model="query"
type="text"
placeholder="Jump to a note or run a command…"
aria-label="Jump to a note or run a command"
class="w-full border-b border-neutral-200 bg-transparent px-4 py-3 text-sm outline-none placeholder:text-neutral-400 dark:border-neutral-700"
@keydown="onKeydown"
/>
<ul v-if="results.length" class="max-h-80 overflow-y-auto py-1">
<li v-for="(row, i) in results" :key="row.id">
<button
type="button"
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-left text-sm"
:class="
i === selected
? 'bg-brand/15 text-brand-700 dark:text-brand'
: 'hover:bg-neutral-100 dark:hover:bg-neutral-800'
"
@mousemove="selected = i"
@click="
selected = i;
activate();
"
>
<span class="truncate">{{ row.label }}</span>
<span class="shrink-0 text-xs text-neutral-400">{{ row.hint }}</span>
</button>
</li>
</ul>
<p v-else class="px-4 py-6 text-center text-sm text-neutral-400">No matches</p>
</div>
</div>
</template>
+18 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
import { useUiStore } from "../stores/ui";
import QuickAdd from "../components/QuickAdd.vue";
@@ -10,6 +10,7 @@ import NoteEditor from "../components/NoteEditor.vue";
const notes = useNotesStore();
const route = useRoute();
const ui = useUiStore();
const router = useRouter();
const editing = ref<Note | null>(null);
const quickAdd = ref<InstanceType<typeof QuickAdd> | null>(null);
@@ -21,6 +22,22 @@ watch(
() => quickAdd.value?.open(),
);
// The command palette opens a note by navigating here with ?open=<id>.
watch(
() => route.query.open,
(v) => {
if (typeof v === "string" && v) void openFromQuery(v);
},
{ immediate: true },
);
async function openFromQuery(id: string) {
const found = notes.items.find((n) => n.id === id) ?? (await notes.fetchOne(id));
if (found) editing.value = found;
const q = { ...route.query };
delete q.open;
void router.replace({ query: q });
}
function viewForRoute(name: unknown): NoteView {
if (name === "archive") return "archived";
if (name === "trash") return "trash";