From 7a81b7333e08334fd8c12c308c13f3bbbd273a44 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Jul 2026 17:22:01 -0400 Subject: [PATCH] =?UTF-8?q?feat(scribe):=20snippet=20merge=20UI=20?= =?UTF-8?q?=E2=80=94=20multi-select=20merge,=20repeatable=20locations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of the snippet-merge milestone (#231): the human surfaces for merge + multi-location, at v1 quality. Frontend: - SnippetListView: a Select mode (checkbox on each card) → a sticky action bar → a merge modal that lets you pick which selected snippet is the canonical (the others fold in and go to trash). Accent border on selected cards, Moss action buttons (Hybrid rule). - SnippetEditorView: the single Location fieldset becomes a repeatable locations list (add/remove rows), so editing a merged snippet no longer collapses its call sites — no data loss. Sends `locations`. - SnippetDetailView: renders every location (Location vs Locations label). - api/snippets.ts: SnippetLocation type, `locations` on fields/input, mergeSnippets(). Backend (editor enablement): - create_snippet service + POST route accept an optional `locations` list; PATCH route forwards `locations` — so the editor's location list works uniformly on create and edit. Single repo/path/symbol remain the one-location shorthand (MCP create contract unchanged). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t --- frontend/src/api/snippets.ts | 25 +- frontend/src/views/SnippetDetailView.vue | 25 +- frontend/src/views/SnippetEditorView.vue | 110 ++++++--- frontend/src/views/SnippetListView.vue | 294 ++++++++++++++++++++++- src/scribe/routes/snippets.py | 3 + src/scribe/services/snippets.py | 5 +- 6 files changed, 414 insertions(+), 48 deletions(-) diff --git a/frontend/src/api/snippets.ts b/frontend/src/api/snippets.ts index ea5b5a0..40796f3 100644 --- a/frontend/src/api/snippets.ts +++ b/frontend/src/api/snippets.ts @@ -1,7 +1,16 @@ import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; +/** One canonical location of a reusable thing. A snippet that unifies several + * one-offs carries several — one per call site. */ +export interface SnippetLocation { + repo: string; + path: string; + symbol: string; +} + /** Structured fields parsed out of a snippet note (mirrors the backend - * `parse_snippet_fields` — see services/snippets.py). */ + * `parse_snippet_fields` — see services/snippets.py). `repo`/`path`/`symbol` + * mirror the first location for back-compat; `locations` is the full list. */ export interface SnippetFields { name: string; when_to_use: string; @@ -10,6 +19,7 @@ export interface SnippetFields { repo: string; path: string; symbol: string; + locations: SnippetLocation[]; code: string; } @@ -50,9 +60,7 @@ export interface SnippetInput { language?: string; signature?: string; when_to_use?: string; - repo?: string; - path?: string; - symbol?: string; + locations?: SnippetLocation[]; tags?: string[]; project_id?: number | null; } @@ -85,3 +93,12 @@ export async function updateSnippet( export async function deleteSnippet(id: number): Promise { return apiDelete(`/api/snippets/${id}`); } + +/** Unify `sourceIds` into the canonical snippet `targetId`. Returns the merged + * survivor plus `merged_ids` — the sources actually folded in and trashed. */ +export async function mergeSnippets( + targetId: number, + sourceIds: number[], +): Promise { + return apiPost(`/api/snippets/${targetId}/merge`, { source_ids: sourceIds }); +} diff --git a/frontend/src/views/SnippetDetailView.vue b/frontend/src/views/SnippetDetailView.vue index 9dca0dd..63ca1f2 100644 --- a/frontend/src/views/SnippetDetailView.vue +++ b/frontend/src/views/SnippetDetailView.vue @@ -21,11 +21,11 @@ const canWrite = computed(() => { return p === undefined || p === "owner" || p === "edit" || p === "admin"; }); -const locationParts = computed(() => { - const s = snippet.value?.snippet; - if (!s) return []; - return [s.repo, s.path, s.symbol].filter((p) => p && p.trim()); -}); +const locations = computed(() => snippet.value?.snippet.locations ?? []); + +function locParts(loc: { repo: string; path: string; symbol: string }): string[] { + return [loc.repo, loc.path, loc.symbol].filter((p) => p && p.trim()); +} async function load() { loading.value = true; @@ -92,10 +92,12 @@ async function confirmDelete() {
Signature
{{ snippet.snippet.signature }}
-