feat(scribe): snippet merge UI — multi-select merge, repeatable locations
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 1m2s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 1m2s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
createSnippet,
|
||||
updateSnippet,
|
||||
type SnippetInput,
|
||||
type SnippetLocation,
|
||||
} from "@/api/snippets";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
@@ -16,7 +17,7 @@ const toast = useToastStore();
|
||||
const editId = computed(() => (route.params.id ? Number(route.params.id) : null));
|
||||
const isEditing = computed(() => editId.value !== null);
|
||||
|
||||
// All-string form state (tags are edited separately as text) so v-model and
|
||||
// All-string scalar state (tags/locations handled separately) so v-model and
|
||||
// .trim() never meet an undefined.
|
||||
interface FormState {
|
||||
name: string;
|
||||
@@ -24,21 +25,27 @@ interface FormState {
|
||||
language: string;
|
||||
signature: string;
|
||||
when_to_use: string;
|
||||
repo: string;
|
||||
path: string;
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
const blankLocation = (): SnippetLocation => ({ repo: "", path: "", symbol: "" });
|
||||
|
||||
const form = ref<FormState>({
|
||||
name: "",
|
||||
code: "",
|
||||
language: "",
|
||||
signature: "",
|
||||
when_to_use: "",
|
||||
repo: "",
|
||||
path: "",
|
||||
symbol: "",
|
||||
});
|
||||
// A snippet that unified several one-offs carries several locations; a fresh one
|
||||
// starts with a single blank row.
|
||||
const locations = ref<SnippetLocation[]>([blankLocation()]);
|
||||
|
||||
function addLocation() {
|
||||
locations.value.push(blankLocation());
|
||||
}
|
||||
function removeLocation(i: number) {
|
||||
locations.value.splice(i, 1);
|
||||
}
|
||||
const tagsText = ref("");
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
@@ -66,10 +73,10 @@ async function load() {
|
||||
language: f.language,
|
||||
signature: f.signature,
|
||||
when_to_use: f.when_to_use,
|
||||
repo: f.repo,
|
||||
path: f.path,
|
||||
symbol: f.symbol,
|
||||
};
|
||||
locations.value = f.locations?.length
|
||||
? f.locations.map((l) => ({ ...l }))
|
||||
: [blankLocation()];
|
||||
// The stored tag list carries language + "snippet" markers, which the
|
||||
// backend re-derives on save — show only the caller's extra tags here.
|
||||
tagsText.value = s.tags
|
||||
@@ -91,6 +98,12 @@ function parseTags(): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function cleanLocations(): SnippetLocation[] {
|
||||
return locations.value
|
||||
.map((l) => ({ repo: l.repo.trim(), path: l.path.trim(), symbol: l.symbol.trim() }))
|
||||
.filter((l) => l.repo || l.path || l.symbol);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value) return;
|
||||
saving.value = true;
|
||||
@@ -100,9 +113,7 @@ async function save() {
|
||||
language: form.value.language.trim(),
|
||||
signature: form.value.signature.trim(),
|
||||
when_to_use: form.value.when_to_use.trim(),
|
||||
repo: form.value.repo.trim(),
|
||||
path: form.value.path.trim(),
|
||||
symbol: form.value.symbol.trim(),
|
||||
locations: cleanLocations(),
|
||||
tags: parseTags(),
|
||||
};
|
||||
try {
|
||||
@@ -188,21 +199,23 @@ function cancel() {
|
||||
</div>
|
||||
|
||||
<fieldset class="location-set">
|
||||
<legend>Location <span class="hint-inline">— where the reference implementation lives</span></legend>
|
||||
<div class="field-row three">
|
||||
<div class="field">
|
||||
<label for="sn-repo">Repo</label>
|
||||
<input id="sn-repo" v-model="form.repo" type="text" class="input mono" placeholder="scribe" @keydown.escape="cancel" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sn-path">Path</label>
|
||||
<input id="sn-path" v-model="form.path" type="text" class="input mono" placeholder="src/composables/x.ts" @keydown.escape="cancel" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sn-symbol">Symbol</label>
|
||||
<input id="sn-symbol" v-model="form.symbol" type="text" class="input mono" placeholder="useDebouncedRef" @keydown.escape="cancel" />
|
||||
</div>
|
||||
<legend>
|
||||
Locations
|
||||
<span class="hint-inline">— where the reference implementation(s) live; a merged snippet keeps every call site</span>
|
||||
</legend>
|
||||
<div v-for="(loc, i) in locations" :key="i" class="loc-row">
|
||||
<input v-model="loc.repo" type="text" class="input mono" placeholder="repo" aria-label="Repo" @keydown.escape="cancel" />
|
||||
<input v-model="loc.path" type="text" class="input mono" placeholder="path" aria-label="Path" @keydown.escape="cancel" />
|
||||
<input v-model="loc.symbol" type="text" class="input mono" placeholder="symbol" aria-label="Symbol" @keydown.escape="cancel" />
|
||||
<button
|
||||
type="button"
|
||||
class="loc-remove"
|
||||
aria-label="Remove location"
|
||||
title="Remove location"
|
||||
@click="removeLocation(i)"
|
||||
>×</button>
|
||||
</div>
|
||||
<button type="button" class="loc-add" @click="addLocation">+ Add location</button>
|
||||
</fieldset>
|
||||
|
||||
<div class="field">
|
||||
@@ -346,6 +359,49 @@ function cancel() {
|
||||
.location-set legend {
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
.loc-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr 1fr auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.loc-remove {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.loc-remove:hover {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.loc-add {
|
||||
margin-top: 0.15rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.loc-add:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.loc-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user