Files
FabledScribe/frontend/src/views/SnippetListView.vue
T
bvandeusen dd1b5e5ddb
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Failing after 27s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 1m42s
feat(snippets): reverse lookup — find snippets by repo/path/symbol
"What canonical helpers already live in this file?" was unanswerable:
location lived only in the body markdown. It is now a jsonpath containment
query over the `notes.data` mirror added by migration 0070.

- One predicate in two dialects in services/knowledge.py: SQL (`data @?`,
  applied in the browse arm and the keyword arm before count/pagination, so
  totals stay honest) and Python (`location_matches`, for the semantic arm
  which post-filters candidates it already holds). Both must change together.
- Parts are ANDed within a SINGLE locations entry — repo A in one entry and
  path B in another is not "recorded at A/B". `path` also matches as a
  directory prefix, via jsonpath `starts with` rather than `@>`, which the
  same GIN index serves.
- `repo`/`path`/`symbol` reach the service, the REST list and the MCP tool
  under one name with one default (rule #33); the MCP docstring teaches the
  place form, and so does the reusing-code skill (plugin.json bumped).
- UI: a Location disclosure beside the snippet search, with its own empty
  state — "nothing kept there, so what you're about to write is new."

Settles #2083's open question (pre-0070 NULL `data`) by backfilling after
all: `backfill_snippet_data` runs at startup, deriving the mirror from the
body with the same parser the read path trusts. 0070's caution was about
mangling a hand-edited body; this never touches the body. The alternative
was a permanent second body-regex arm, or a query that silently answers
"nothing here" for an old snippet and gets the helper written twice.

Refs #2083, milestone #232.
2026-07-27 23:09:37 -04:00

755 lines
20 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { listSnippets, mergeSnippets, type SnippetListItem } from "@/api/snippets";
import { useToastStore } from "@/stores/toast";
const router = useRouter();
const toast = useToastStore();
const snippets = ref<SnippetListItem[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const search = ref("");
let searchTimer: ReturnType<typeof setTimeout> | undefined;
// Reverse lookup — "what already lives here?" All three must match the same
// recorded location; path also matches anything beneath it.
const showLocationFilter = ref(false);
const locRepo = ref("");
const locPath = ref("");
const locSymbol = ref("");
const locationActive = computed(
() => !!(locRepo.value.trim() || locPath.value.trim() || locSymbol.value.trim()),
);
function clearLocation() {
locRepo.value = "";
locPath.value = "";
locSymbol.value = "";
loadSnippets();
}
function toggleLocationFilter() {
showLocationFilter.value = !showLocationFilter.value;
// Collapsing while filtered would hide why the list is short.
if (!showLocationFilter.value && locationActive.value) clearLocation();
}
// Multi-select → merge
const selectMode = ref(false);
const selectedIds = ref<Set<number>>(new Set());
const showMergeModal = ref(false);
const canonicalId = ref<number | null>(null);
const merging = ref(false);
const selectedList = computed(() =>
snippets.value.filter((s) => selectedIds.value.has(s.id)),
);
function exitSelectMode() {
selectMode.value = false;
selectedIds.value = new Set();
}
function toggleSelectMode() {
if (selectMode.value) exitSelectMode();
else selectMode.value = true;
}
function toggleSelect(id: number) {
const next = new Set(selectedIds.value);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds.value = next;
}
function onCardActivate(s: SnippetListItem) {
if (selectMode.value) toggleSelect(s.id);
else router.push(`/snippets/${s.id}`);
}
function openMerge() {
if (selectedIds.value.size < 2) return;
canonicalId.value = selectedList.value[0]?.id ?? null;
showMergeModal.value = true;
}
async function doMerge() {
const target = canonicalId.value;
if (target == null) return;
const sources = selectedList.value.map((s) => s.id).filter((id) => id !== target);
if (!sources.length) return;
merging.value = true;
try {
await mergeSnippets(target, sources);
toast.show(`Merged ${sources.length} snippet${sources.length > 1 ? "s" : ""} in`);
showMergeModal.value = false;
exitSelectMode();
await loadSnippets();
} catch {
toast.show("Failed to merge snippets", "error");
} finally {
merging.value = false;
}
}
async function loadSnippets() {
loading.value = true;
error.value = null;
try {
const data = await listSnippets({
q: search.value.trim() || undefined,
repo: locRepo.value.trim() || undefined,
path: locPath.value.trim() || undefined,
symbol: locSymbol.value.trim() || undefined,
});
snippets.value = data.snippets;
} catch {
error.value = "Couldn't load your snippets.";
toast.show("Failed to load snippets", "error");
} finally {
loading.value = false;
}
}
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(loadSnippets, 300);
}
// Same debounce for the location fields — typing a path shouldn't fire a query
// per keystroke.
const onLocationInput = onSearchInput;
onMounted(loadSnippets);
/** Titles are stored as "name — when to reach for it"; split for display. */
function splitTitle(title: string): { name: string; when: string } {
const idx = title.indexOf(" — ");
if (idx === -1) return { name: title, when: "" };
return { name: title.slice(0, idx), when: title.slice(idx + 3) };
}
function languageOf(tags: string[]): string {
return tags.find((t) => t && t !== "snippet") ?? "";
}
</script>
<template>
<main class="snippets-list">
<div class="page-header">
<h1>Snippets</h1>
<div class="header-actions">
<button v-if="snippets.length" class="btn-ghost" @click="toggleSelectMode">
{{ selectMode ? "Cancel" : "Select" }}
</button>
<button class="btn-primary" @click="router.push('/snippets/new')">
+ New snippet
</button>
</div>
</div>
<p class="page-sub">
Reusable functions and components, recorded once so they surface before
they're rewritten as a one-off.
</p>
<div class="search-row">
<input
v-model="search"
type="search"
class="search-input"
placeholder="Search snippets…"
aria-label="Search snippets"
@input="onSearchInput"
@keydown.enter="loadSnippets"
/>
<button
class="btn-ghost"
:class="{ 'filter-on': locationActive }"
:aria-expanded="showLocationFilter"
aria-controls="location-filter"
@click="toggleLocationFilter"
>
<!-- Say it in the label, not only in the accent — the state has to reach
a screen reader too. -->
{{ locationActive ? "Location · filtering" : "Location" }}
</button>
</div>
<!-- Reverse lookup: what's already kept in this repo / file / symbol. -->
<div v-if="showLocationFilter" id="location-filter" class="location-row">
<input
v-model="locRepo"
class="loc-input"
placeholder="repo"
aria-label="Filter by repo"
@input="onLocationInput"
@keydown.enter="loadSnippets"
/>
<input
v-model="locPath"
class="loc-input loc-input-wide"
placeholder="path — matches the file or anything under it"
aria-label="Filter by path"
@input="onLocationInput"
@keydown.enter="loadSnippets"
/>
<input
v-model="locSymbol"
class="loc-input"
placeholder="symbol"
aria-label="Filter by symbol"
@input="onLocationInput"
@keydown.enter="loadSnippets"
/>
<button v-if="locationActive" class="loc-clear" @click="clearLocation">
Clear
</button>
</div>
<div v-if="loading" class="skeleton-grid">
<div class="skeleton-card" v-for="i in 6" :key="i"></div>
</div>
<p v-else-if="error" class="error-msg">{{ error }}</p>
<div v-else-if="snippets.length === 0" class="empty-state-rich">
<div class="empty-icon">_</div>
<p class="empty-title">
{{ locationActive
? "Nothing kept at that location yet"
: search.trim()
? "No snippets match your search"
: "No snippets kept yet" }}
</p>
<p class="empty-sub">
{{ locationActive
? "No recorded snippet lives there — so whatever you're about to write is new. Widen the path, or clear the filter."
: search.trim()
? "Try a different term, or clear the search."
: "Record a reusable function or component and it will be offered back to you later." }}
</p>
<button v-if="locationActive" class="empty-action" @click="clearLocation">
Clear location filter
</button>
<button
v-else-if="!search.trim()"
class="empty-action"
@click="router.push('/snippets/new')"
>
New snippet
</button>
</div>
<div v-else class="snippets-grid">
<div
v-for="s in snippets"
:key="s.id"
class="snippet-card"
:class="{ selected: selectedIds.has(s.id) }"
role="button"
tabindex="0"
:aria-pressed="selectMode ? selectedIds.has(s.id) : undefined"
@click="onCardActivate(s)"
@keydown.enter="onCardActivate(s)"
>
<div class="card-header">
<span
v-if="selectMode"
class="select-box"
:class="{ on: selectedIds.has(s.id) }"
aria-hidden="true"
></span>
<span class="snippet-name">{{ splitTitle(s.title).name }}</span>
<span v-if="languageOf(s.tags)" class="lang-pill">{{ languageOf(s.tags) }}</span>
</div>
<p v-if="splitTitle(s.title).when" class="snippet-when">
{{ splitTitle(s.title).when }}
</p>
<div class="card-footer">
<span class="meta-date">Updated {{ new Date(s.updated_at).toLocaleDateString() }}</span>
<span v-if="s.shared" class="shared-tag" :title="`Shared by ${s.owner ?? 'another user'} — a suggestion, not your own record`">
by {{ s.owner ?? "another user" }}
</span>
</div>
</div>
</div>
<!-- Select action bar -->
<div v-if="selectMode" class="select-bar">
<span class="select-count">{{ selectedIds.size }} selected</span>
<span class="select-hint">Pick two or more to unify into one canonical snippet.</span>
<button class="btn-primary" :disabled="selectedIds.size < 2" @click="openMerge">
Merge
</button>
</div>
<!-- Merge modal -->
<teleport to="body">
<div v-if="showMergeModal" class="modal-overlay" @click.self="showMergeModal = false">
<div class="modal-card" role="dialog" aria-modal="true" aria-label="Merge snippets">
<h3 class="modal-title">Merge snippets</h3>
<p class="modal-desc">
Keep one as the canonical record the others are folded into it (their
locations are added) and moved to the trash, where they can be restored.
</p>
<div class="merge-choices">
<label
v-for="s in selectedList"
:key="s.id"
class="merge-choice"
:class="{ chosen: canonicalId === s.id }"
>
<input type="radio" name="canonical" :value="s.id" v-model="canonicalId" />
<span class="merge-choice-name">{{ splitTitle(s.title).name }}</span>
<span class="merge-choice-tag">{{ canonicalId === s.id ? "keep" : "fold in" }}</span>
</label>
</div>
<div class="modal-actions">
<button class="modal-btn" @click="showMergeModal = false">Cancel</button>
<button
class="modal-btn modal-btn-primary"
:disabled="merging || canonicalId == null"
@click="doMerge"
>
{{ merging ? "Merging…" : "Merge" }}
</button>
</div>
</div>
</div>
</teleport>
</main>
</template>
<style scoped>
.snippets-list {
max-width: var(--page-max-width);
margin: 2rem auto;
padding: 0 var(--page-padding-x);
overflow-x: clip;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.35rem;
}
.page-header h1 {
margin: 0;
}
.page-sub {
margin: 0 0 1.25rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.5;
max-width: 60ch;
}
/* Moss action-primary per Hybrid — utility action, not a brand moment. */
.btn-primary {
padding: 0.45rem 1rem;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
transition: background 0.15s;
white-space: nowrap;
}
.btn-primary:hover {
background: var(--color-action-primary-hover);
}
.search-row {
margin-bottom: 1.25rem;
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
/* Filter is engaged — the accent marks "you are here", per the design system. */
.filter-on {
border-color: var(--color-primary);
color: var(--color-primary);
}
.location-row {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin: -0.5rem 0 1.25rem;
}
.loc-input {
flex: 1 1 8rem;
min-width: 0;
max-width: 12rem;
padding: 0.4rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.85rem;
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
box-sizing: border-box;
}
.loc-input-wide {
flex: 2 1 16rem;
max-width: 26rem;
}
.loc-input::placeholder {
font-family: inherit;
color: var(--color-text-muted);
}
.loc-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.loc-clear {
padding: 0.4rem 0.65rem;
border: none;
background: transparent;
color: var(--color-text-secondary);
font-size: 0.85rem;
font-family: inherit;
cursor: pointer;
text-decoration: underline;
}
.loc-clear:hover {
color: var(--color-text);
}
.search-input {
width: 100%;
max-width: 420px;
padding: 0.5rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
box-sizing: border-box;
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.error-msg {
color: var(--color-danger);
font-size: 0.9rem;
margin-top: 1rem;
}
.empty-state-rich {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
}
.empty-icon {
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-size: 2rem;
margin-bottom: 0.75rem;
opacity: 0.35;
}
.empty-title {
font-size: 1rem;
font-weight: 500;
color: var(--color-text-secondary);
margin: 0 0 0.35rem;
}
.empty-sub {
font-size: 0.85rem;
margin: 0 0 1rem;
max-width: 44ch;
margin-inline: auto;
line-height: 1.5;
}
.empty-action {
display: inline-block;
padding: 0.4rem 1rem;
border: 1px solid var(--color-primary);
border-radius: var(--radius-sm);
color: var(--color-primary);
background: none;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.15s, color 0.15s;
}
.empty-action:hover {
background: var(--color-primary);
color: #fff;
}
.skeleton-grid,
.snippets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
.skeleton-card {
height: 96px;
border-radius: var(--radius-md);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.snippet-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.9rem 1rem;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.18s ease;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.snippet-card:hover {
border-color: var(--color-primary);
box-shadow: 0 2px 8px var(--color-shadow);
transform: translateY(-2px);
}
.snippet-card:focus-visible {
outline: none;
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.snippet-name {
font-size: 0.95rem;
font-weight: 500;
color: var(--color-text);
min-width: 0;
flex: 1;
word-break: break-word;
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
}
/* Language tag — accent pill per the design system's tag treatment. */
.lang-pill {
font-size: 0.68rem;
font-weight: 500;
padding: 0.12rem 0.45rem;
border-radius: 999px;
flex-shrink: 0;
white-space: nowrap;
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
}
.snippet-when {
font-size: 0.83rem;
color: var(--color-text-secondary);
margin: 0;
line-height: 1.45;
}
.card-footer {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.meta-date {
font-size: 0.73rem;
color: var(--color-text-muted);
}
/* Marks a record someone else owns. Present only on shared rows, so an
unmarked card is unambiguously the operator's own. */
.shared-tag {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
white-space: nowrap;
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
color: var(--color-text-muted);
}
/* Header + select-mode */
.header-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-ghost {
padding: 0.4rem 0.85rem;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
transition: border-color 0.15s, color 0.15s;
}
.btn-ghost:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Selected card = 2px accent border per the design system (featured/active). */
.snippet-card.selected {
border-color: var(--color-primary);
border-width: 2px;
padding: calc(0.9rem - 1px) calc(1rem - 1px);
}
.select-box {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-top: 0.15rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
transition: background 0.12s, border-color 0.12s;
}
.select-box.on {
background: var(--color-primary);
border-color: var(--color-primary);
}
.select-bar {
position: sticky;
bottom: 1rem;
margin-top: 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.9rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow);
}
.select-count {
font-weight: 500;
font-size: 0.9rem;
color: var(--color-text);
}
.select-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
flex: 1;
min-width: 0;
}
.select-bar .btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
/* Merge modal */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.5rem;
width: 100%;
max-width: 460px;
box-shadow: 0 8px 32px var(--color-shadow);
display: flex;
flex-direction: column;
gap: 1rem;
}
.modal-title {
margin: 0;
font-size: 1.1rem;
}
.modal-desc {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-secondary);
line-height: 1.5;
}
.merge-choices {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.merge-choice {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.merge-choice.chosen {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.merge-choice-name {
flex: 1;
min-width: 0;
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-size: 0.85rem;
word-break: break-word;
}
.merge-choice-tag {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
flex-shrink: 0;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
color: var(--color-text);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--color-bg);
}
.modal-btn-primary {
background: var(--color-action-primary);
border-color: var(--color-action-primary);
color: #fff;
}
.modal-btn-primary:hover:not(:disabled) {
background: var(--color-action-primary-hover);
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
@media (max-width: 600px) {
.snippets-grid {
grid-template-columns: 1fr;
}
.select-hint {
display: none;
}
}
</style>