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:
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { listSnippets, type SnippetListItem } from "@/api/snippets";
|
||||
import { listSnippets, mergeSnippets, type SnippetListItem } from "@/api/snippets";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const router = useRouter();
|
||||
@@ -13,6 +13,59 @@ const error = ref<string | null>(null);
|
||||
const search = ref("");
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// 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;
|
||||
@@ -50,9 +103,14 @@ function languageOf(tags: string[]): string {
|
||||
<main class="snippets-list">
|
||||
<div class="page-header">
|
||||
<h1>Snippets</h1>
|
||||
<button class="btn-primary" @click="router.push('/snippets/new')">
|
||||
+ New snippet
|
||||
</button>
|
||||
<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
|
||||
@@ -101,12 +159,20 @@ function languageOf(tags: string[]): string {
|
||||
v-for="s in snippets"
|
||||
:key="s.id"
|
||||
class="snippet-card"
|
||||
:class="{ selected: selectedIds.has(s.id) }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="router.push(`/snippets/${s.id}`)"
|
||||
@keydown.enter="router.push(`/snippets/${s.id}`)"
|
||||
: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>
|
||||
@@ -118,6 +184,50 @@ function languageOf(tags: string[]): string {
|
||||
</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>
|
||||
|
||||
@@ -313,9 +423,179 @@ function languageOf(tags: string[]): string {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user