CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 1m56s
Operator, 2026-08-22 (note 2897): ThoughtSync is an intermediary surface. You
write here because it's easy — a notebook in your pocket — and later you recall
the thing and go finish it somewhere else. Recall is the product; organization
is secondary. A linking system is organization, and it isn't what this is for.
So: `[[wiki-links]]`, backlinks, the `[[` autocomplete, the note_links table,
`/api/notes/link-search`, `/api/notes/<id>/backlinks`, the whole graph blueprint
and GraphView. Rust core loses `extract_links`, `backlinks`, `link_search` and
`create_titled`; the desktop loses the three Tauri commands that exposed them.
This subsumes 982d24c rather than reverting it. That commit bound links to a
note id so a rename would stop rewriting other notes' bodies — real infra, but
infra for a feature that is now gone, and nothing it added survives. Alembic
0023 stays in the chain anyway: it shipped in an image and may already be
applied, and deleting an applied revision strands a database's version pointer.
0024 drops the table and takes the column with it. The history stays honest
about the fact that it existed for a day.
Two things deliberately kept, because they were serving recall and only
incidentally serving links:
- `/api/notes/titles` and the titles store. The command palette lists them so
you can jump to a note by name. `resolve()` — the name→note lookup that only
linking needed — is gone.
- `display_title`. Every note still has a name for search results and export
filenames. What that name is FOR changed; that it exists did not.
`notes/links.py` is now `notes/tags.py`, holding the #tag→label reconciliation
it always also owned. A file called links.py with no links in it would have been
exactly the drift this removal is meant to end.
Also swept out on the way: `_escape_like`, whose only caller was link-search,
and the `graph` icon. Nothing lost that a person typed — note_links was always
derived, and the `[[text]]` is still sitting in every body it was written in.
135 lines
4.3 KiB
Vue
135 lines
4.3 KiB
Vue
<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";
|
|
import BaseModal from "./BaseModal.vue";
|
|
|
|
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:reminders", label: "Go to Reminders", hint: "Navigate", run: () => go("/reminders") },
|
|
{ id: "cmd:timeline", label: "Go to Timeline", hint: "Navigate", run: () => go("/timeline") },
|
|
{ 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>
|
|
<BaseModal
|
|
panel-class="w-full max-w-lg overflow-hidden shadow-2xl"
|
|
aria-label="Command palette"
|
|
@close="emit('close')"
|
|
>
|
|
<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>
|
|
</BaseModal>
|
|
</template>
|