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,16 @@
|
|||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
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
|
/** 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 {
|
export interface SnippetFields {
|
||||||
name: string;
|
name: string;
|
||||||
when_to_use: string;
|
when_to_use: string;
|
||||||
@@ -10,6 +19,7 @@ export interface SnippetFields {
|
|||||||
repo: string;
|
repo: string;
|
||||||
path: string;
|
path: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
locations: SnippetLocation[];
|
||||||
code: string;
|
code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,9 +60,7 @@ export interface SnippetInput {
|
|||||||
language?: string;
|
language?: string;
|
||||||
signature?: string;
|
signature?: string;
|
||||||
when_to_use?: string;
|
when_to_use?: string;
|
||||||
repo?: string;
|
locations?: SnippetLocation[];
|
||||||
path?: string;
|
|
||||||
symbol?: string;
|
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
project_id?: number | null;
|
project_id?: number | null;
|
||||||
}
|
}
|
||||||
@@ -85,3 +93,12 @@ export async function updateSnippet(
|
|||||||
export async function deleteSnippet(id: number): Promise<void> {
|
export async function deleteSnippet(id: number): Promise<void> {
|
||||||
return apiDelete(`/api/snippets/${id}`);
|
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<Snippet & { merged_ids: number[] }> {
|
||||||
|
return apiPost(`/api/snippets/${targetId}/merge`, { source_ids: sourceIds });
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ const canWrite = computed(() => {
|
|||||||
return p === undefined || p === "owner" || p === "edit" || p === "admin";
|
return p === undefined || p === "owner" || p === "edit" || p === "admin";
|
||||||
});
|
});
|
||||||
|
|
||||||
const locationParts = computed(() => {
|
const locations = computed(() => snippet.value?.snippet.locations ?? []);
|
||||||
const s = snippet.value?.snippet;
|
|
||||||
if (!s) return [];
|
function locParts(loc: { repo: string; path: string; symbol: string }): string[] {
|
||||||
return [s.repo, s.path, s.symbol].filter((p) => p && p.trim());
|
return [loc.repo, loc.path, loc.symbol].filter((p) => p && p.trim());
|
||||||
});
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -92,10 +92,12 @@ async function confirmDelete() {
|
|||||||
<dt>Signature</dt>
|
<dt>Signature</dt>
|
||||||
<dd><code>{{ snippet.snippet.signature }}</code></dd>
|
<dd><code>{{ snippet.snippet.signature }}</code></dd>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="locationParts.length">
|
<template v-if="locations.length">
|
||||||
<dt>Location</dt>
|
<dt>{{ locations.length > 1 ? "Locations" : "Location" }}</dt>
|
||||||
<dd class="location">
|
<dd class="location-list">
|
||||||
<code v-for="(p, i) in locationParts" :key="i">{{ p }}</code>
|
<div v-for="(loc, i) in locations" :key="i" class="location">
|
||||||
|
<code v-for="(p, j) in locParts(loc)" :key="j">{{ p }}</code>
|
||||||
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="snippet.snippet.language">
|
<template v-if="snippet.snippet.language">
|
||||||
@@ -242,6 +244,11 @@ async function confirmDelete() {
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
.location-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
.location {
|
.location {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
createSnippet,
|
createSnippet,
|
||||||
updateSnippet,
|
updateSnippet,
|
||||||
type SnippetInput,
|
type SnippetInput,
|
||||||
|
type SnippetLocation,
|
||||||
} from "@/api/snippets";
|
} from "@/api/snippets";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ const toast = useToastStore();
|
|||||||
const editId = computed(() => (route.params.id ? Number(route.params.id) : null));
|
const editId = computed(() => (route.params.id ? Number(route.params.id) : null));
|
||||||
const isEditing = computed(() => editId.value !== 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.
|
// .trim() never meet an undefined.
|
||||||
interface FormState {
|
interface FormState {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,21 +25,27 @@ interface FormState {
|
|||||||
language: string;
|
language: string;
|
||||||
signature: string;
|
signature: string;
|
||||||
when_to_use: string;
|
when_to_use: string;
|
||||||
repo: string;
|
|
||||||
path: string;
|
|
||||||
symbol: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const blankLocation = (): SnippetLocation => ({ repo: "", path: "", symbol: "" });
|
||||||
|
|
||||||
const form = ref<FormState>({
|
const form = ref<FormState>({
|
||||||
name: "",
|
name: "",
|
||||||
code: "",
|
code: "",
|
||||||
language: "",
|
language: "",
|
||||||
signature: "",
|
signature: "",
|
||||||
when_to_use: "",
|
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 tagsText = ref("");
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
@@ -66,10 +73,10 @@ async function load() {
|
|||||||
language: f.language,
|
language: f.language,
|
||||||
signature: f.signature,
|
signature: f.signature,
|
||||||
when_to_use: f.when_to_use,
|
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
|
// The stored tag list carries language + "snippet" markers, which the
|
||||||
// backend re-derives on save — show only the caller's extra tags here.
|
// backend re-derives on save — show only the caller's extra tags here.
|
||||||
tagsText.value = s.tags
|
tagsText.value = s.tags
|
||||||
@@ -91,6 +98,12 @@ function parseTags(): string[] {
|
|||||||
.filter(Boolean);
|
.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() {
|
async function save() {
|
||||||
if (!canSave.value) return;
|
if (!canSave.value) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
@@ -100,9 +113,7 @@ async function save() {
|
|||||||
language: form.value.language.trim(),
|
language: form.value.language.trim(),
|
||||||
signature: form.value.signature.trim(),
|
signature: form.value.signature.trim(),
|
||||||
when_to_use: form.value.when_to_use.trim(),
|
when_to_use: form.value.when_to_use.trim(),
|
||||||
repo: form.value.repo.trim(),
|
locations: cleanLocations(),
|
||||||
path: form.value.path.trim(),
|
|
||||||
symbol: form.value.symbol.trim(),
|
|
||||||
tags: parseTags(),
|
tags: parseTags(),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
@@ -188,21 +199,23 @@ function cancel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset class="location-set">
|
<fieldset class="location-set">
|
||||||
<legend>Location <span class="hint-inline">— where the reference implementation lives</span></legend>
|
<legend>
|
||||||
<div class="field-row three">
|
Locations
|
||||||
<div class="field">
|
<span class="hint-inline">— where the reference implementation(s) live; a merged snippet keeps every call site</span>
|
||||||
<label for="sn-repo">Repo</label>
|
</legend>
|
||||||
<input id="sn-repo" v-model="form.repo" type="text" class="input mono" placeholder="scribe" @keydown.escape="cancel" />
|
<div v-for="(loc, i) in locations" :key="i" class="loc-row">
|
||||||
</div>
|
<input v-model="loc.repo" type="text" class="input mono" placeholder="repo" aria-label="Repo" @keydown.escape="cancel" />
|
||||||
<div class="field">
|
<input v-model="loc.path" type="text" class="input mono" placeholder="path" aria-label="Path" @keydown.escape="cancel" />
|
||||||
<label for="sn-path">Path</label>
|
<input v-model="loc.symbol" type="text" class="input mono" placeholder="symbol" aria-label="Symbol" @keydown.escape="cancel" />
|
||||||
<input id="sn-path" v-model="form.path" type="text" class="input mono" placeholder="src/composables/x.ts" @keydown.escape="cancel" />
|
<button
|
||||||
</div>
|
type="button"
|
||||||
<div class="field">
|
class="loc-remove"
|
||||||
<label for="sn-symbol">Symbol</label>
|
aria-label="Remove location"
|
||||||
<input id="sn-symbol" v-model="form.symbol" type="text" class="input mono" placeholder="useDebouncedRef" @keydown.escape="cancel" />
|
title="Remove location"
|
||||||
</div>
|
@click="removeLocation(i)"
|
||||||
|
>×</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="loc-add" @click="addLocation">+ Add location</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -346,6 +359,49 @@ function cancel() {
|
|||||||
.location-set legend {
|
.location-set legend {
|
||||||
padding: 0 0.4rem;
|
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 {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
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";
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -13,6 +13,59 @@ const error = ref<string | null>(null);
|
|||||||
const search = ref("");
|
const search = ref("");
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
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() {
|
async function loadSnippets() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
@@ -50,9 +103,14 @@ function languageOf(tags: string[]): string {
|
|||||||
<main class="snippets-list">
|
<main class="snippets-list">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Snippets</h1>
|
<h1>Snippets</h1>
|
||||||
<button class="btn-primary" @click="router.push('/snippets/new')">
|
<div class="header-actions">
|
||||||
+ New snippet
|
<button v-if="snippets.length" class="btn-ghost" @click="toggleSelectMode">
|
||||||
</button>
|
{{ selectMode ? "Cancel" : "Select" }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary" @click="router.push('/snippets/new')">
|
||||||
|
+ New snippet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="page-sub">
|
<p class="page-sub">
|
||||||
Reusable functions and components, recorded once so they surface before
|
Reusable functions and components, recorded once so they surface before
|
||||||
@@ -101,12 +159,20 @@ function languageOf(tags: string[]): string {
|
|||||||
v-for="s in snippets"
|
v-for="s in snippets"
|
||||||
:key="s.id"
|
:key="s.id"
|
||||||
class="snippet-card"
|
class="snippet-card"
|
||||||
|
:class="{ selected: selectedIds.has(s.id) }"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@click="router.push(`/snippets/${s.id}`)"
|
:aria-pressed="selectMode ? selectedIds.has(s.id) : undefined"
|
||||||
@keydown.enter="router.push(`/snippets/${s.id}`)"
|
@click="onCardActivate(s)"
|
||||||
|
@keydown.enter="onCardActivate(s)"
|
||||||
>
|
>
|
||||||
<div class="card-header">
|
<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 class="snippet-name">{{ splitTitle(s.title).name }}</span>
|
||||||
<span v-if="languageOf(s.tags)" class="lang-pill">{{ languageOf(s.tags) }}</span>
|
<span v-if="languageOf(s.tags)" class="lang-pill">{{ languageOf(s.tags) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,6 +184,50 @@ function languageOf(tags: string[]): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -313,9 +423,179 @@ function languageOf(tags: string[]): string {
|
|||||||
color: var(--color-text-muted);
|
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) {
|
@media (max-width: 600px) {
|
||||||
.snippets-grid {
|
.snippets-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.select-hint {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ async def create_snippet_route():
|
|||||||
repo=data.get("repo", ""),
|
repo=data.get("repo", ""),
|
||||||
path=data.get("path", ""),
|
path=data.get("path", ""),
|
||||||
symbol=data.get("symbol", ""),
|
symbol=data.get("symbol", ""),
|
||||||
|
locations=data.get("locations"),
|
||||||
tags=data.get("tags"),
|
tags=data.get("tags"),
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
)
|
)
|
||||||
@@ -109,6 +110,8 @@ async def update_snippet_route(snippet_id: int):
|
|||||||
# treats None as "leave unchanged"). Empty strings ARE applied — the form
|
# treats None as "leave unchanged"). Empty strings ARE applied — the form
|
||||||
# sends the full field set, so a cleared field is an intentional clear.
|
# sends the full field set, so a cleared field is an intentional clear.
|
||||||
kwargs = {k: data[k] for k in _STR_FIELDS if k in data}
|
kwargs = {k: data[k] for k in _STR_FIELDS if k in data}
|
||||||
|
if "locations" in data:
|
||||||
|
kwargs["locations"] = data["locations"]
|
||||||
if "tags" in data:
|
if "tags" in data:
|
||||||
kwargs["tags"] = data["tags"]
|
kwargs["tags"] = data["tags"]
|
||||||
if "project_id" in data:
|
if "project_id" in data:
|
||||||
|
|||||||
@@ -264,17 +264,20 @@ async def create_snippet(
|
|||||||
repo: str = "",
|
repo: str = "",
|
||||||
path: str = "",
|
path: str = "",
|
||||||
symbol: str = "",
|
symbol: str = "",
|
||||||
|
locations: list[dict] | None = None,
|
||||||
tags: list[str] | None = None,
|
tags: list[str] | None = None,
|
||||||
project_id: int | None = None,
|
project_id: int | None = None,
|
||||||
):
|
):
|
||||||
"""Create a snippet note (embedded on create for immediate recall). Returns
|
"""Create a snippet note (embedded on create for immediate recall). Returns
|
||||||
the created Note."""
|
the created Note. Pass ``locations`` for the multi-location case; the single
|
||||||
|
``repo``/``path``/``symbol`` are the one-location shorthand."""
|
||||||
note = await notes_svc.create_note(
|
note = await notes_svc.create_note(
|
||||||
user_id,
|
user_id,
|
||||||
title=compose_title(name, when_to_use),
|
title=compose_title(name, when_to_use),
|
||||||
body=compose_body(
|
body=compose_body(
|
||||||
code=code, language=language, signature=signature,
|
code=code, language=language, signature=signature,
|
||||||
when_to_use=when_to_use, repo=repo, path=path, symbol=symbol,
|
when_to_use=when_to_use, repo=repo, path=path, symbol=symbol,
|
||||||
|
locations=locations,
|
||||||
),
|
),
|
||||||
note_type=SNIPPET_NOTE_TYPE,
|
note_type=SNIPPET_NOTE_TYPE,
|
||||||
tags=compose_tags(language, tags),
|
tags=compose_tags(language, tags),
|
||||||
|
|||||||
Reference in New Issue
Block a user