feat(snippets): reverse lookup — find snippets by repo/path/symbol
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
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
"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.
This commit is contained in:
@@ -80,13 +80,26 @@ export interface SnippetInput {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/** `repo`/`path`/`symbol` are the reverse lookup — "what already lives here?"
|
||||
* They must all match the same recorded location; `path` also matches anything
|
||||
* beneath it. Combinable with `q`. */
|
||||
export async function listSnippets(
|
||||
params: { q?: string; tag?: string; projectId?: number | null } = {},
|
||||
params: {
|
||||
q?: string;
|
||||
tag?: string;
|
||||
projectId?: number | null;
|
||||
repo?: string;
|
||||
path?: string;
|
||||
symbol?: string;
|
||||
} = {},
|
||||
): Promise<{ snippets: SnippetListItem[]; total: number }> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.q) qs.set("q", params.q);
|
||||
if (params.tag) qs.set("tag", params.tag);
|
||||
if (params.projectId) qs.set("project_id", String(params.projectId));
|
||||
if (params.repo) qs.set("repo", params.repo);
|
||||
if (params.path) qs.set("path", params.path);
|
||||
if (params.symbol) qs.set("symbol", params.symbol);
|
||||
const query = qs.toString();
|
||||
return apiGet(`/api/snippets${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,27 @@ 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());
|
||||
@@ -70,7 +91,12 @@ async function loadSnippets() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const data = await listSnippets({ q: search.value.trim() || undefined });
|
||||
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.";
|
||||
@@ -84,6 +110,9 @@ 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);
|
||||
|
||||
@@ -127,6 +156,48 @@ function languageOf(tags: string[]): string {
|
||||
@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">
|
||||
@@ -138,15 +209,24 @@ function languageOf(tags: string[]): string {
|
||||
<div v-else-if="snippets.length === 0" class="empty-state-rich">
|
||||
<div class="empty-icon">❭_</div>
|
||||
<p class="empty-title">
|
||||
{{ search.trim() ? "No snippets match your search" : "No snippets kept yet" }}
|
||||
{{ locationActive
|
||||
? "Nothing kept at that location yet"
|
||||
: search.trim()
|
||||
? "No snippets match your search"
|
||||
: "No snippets kept yet" }}
|
||||
</p>
|
||||
<p class="empty-sub">
|
||||
{{ 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." }}
|
||||
{{ 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-if="!search.trim()"
|
||||
v-else-if="!search.trim()"
|
||||
class="empty-action"
|
||||
@click="router.push('/snippets/new')"
|
||||
>
|
||||
@@ -278,6 +358,62 @@ function languageOf(tags: string[]): string {
|
||||
|
||||
.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%;
|
||||
|
||||
Reference in New Issue
Block a user