From dd1b5e5ddb72f16776b7a656dcf36a3f88e849ce Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 27 Jul 2026 23:09:37 -0400 Subject: [PATCH] =?UTF-8?q?feat(snippets):=20reverse=20lookup=20=E2=80=94?= =?UTF-8?q?=20find=20snippets=20by=20repo/path/symbol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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. --- frontend/src/api/snippets.ts | 15 +- frontend/src/views/SnippetListView.vue | 148 +++++++++- plugin/.claude-plugin/plugin.json | 2 +- plugin/skills/reusing-code/SKILL.md | 8 + src/scribe/app.py | 9 + src/scribe/mcp/tools/snippets.py | 18 ++ src/scribe/routes/snippets.py | 6 + src/scribe/services/knowledge.py | 99 ++++++- src/scribe/services/snippets.py | 93 +++++- tests/test_integration_snippet_locations.py | 299 ++++++++++++++++++++ tests/test_routes_snippets.py | 13 + tests/test_snippet_location_filter.py | 205 ++++++++++++++ 12 files changed, 899 insertions(+), 16 deletions(-) create mode 100644 tests/test_integration_snippet_locations.py create mode 100644 tests/test_snippet_location_filter.py diff --git a/frontend/src/api/snippets.ts b/frontend/src/api/snippets.ts index 6b400dd..fa7ef03 100644 --- a/frontend/src/api/snippets.ts +++ b/frontend/src/api/snippets.ts @@ -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}` : ""}`); } diff --git a/frontend/src/views/SnippetListView.vue b/frontend/src/views/SnippetListView.vue index c5849cb..8fbed78 100644 --- a/frontend/src/views/SnippetListView.vue +++ b/frontend/src/views/SnippetListView.vue @@ -13,6 +13,27 @@ const error = ref(null); const search = ref(""); let searchTimer: ReturnType | 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>(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" /> + + + + +
+ + + +
@@ -138,15 +209,24 @@ function languageOf(tags: string[]): string {
❭_

- {{ 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" }}

- {{ 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." }}

+