Files
FabledScribe/frontend/src/views/SnippetDetailView.vue
T
bvandeusen fe63f3985b
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 40s
feat(snippets): un-merge — reverse one source out of a merged survivor
Closes the last of milestone #232. The task said to settle the design
before coding; here is what was settled and why.

THE HAZARD. Restoring a merged-in source from the trash brought the record
back but never stripped its locations off the survivor, so both claimed
the same call sites and the reverse lookup read the duplicate claims as
real. Subtracting blindly is not a fix: a location can arrive from a
source AND genuinely be the survivor's own, and _normalize_locations dedups
them into one, so blind subtraction would strip a call site the survivor
owns. Same problem defeated partial un-merge — `merged_from` recorded ids,
not which locations came from which source.

THE ANSWER. Record per-source attribution AT MERGE TIME, where it is known
exactly: each entry keeps only what that source ADDED, computed
incrementally as sources fold in. Anything the survivor already had, or an
earlier source already brought, is attributed to nobody. Both open
questions fall out of that one change — partial un-merge is exact, and a
survivor-owned location can never be stripped, because it was never
attributed in the first place.

The shape moved from [id] to [{id, locations, tags}]. Free to do: the
corpus holds one snippet and zero merges, so there is no legacy data (rule
#22). A bare int still normalizes to {"id": n} — not legacy tolerance, but
because snippet_fields falls back to PARSING THE BODY when a row has no
`data`, and the body's provenance line can only carry ids. Such an entry
shows history and refuses un-merge with a reason rather than guessing.

WHICH SURFACE. Neither option in the task, quite. Making trash-restore
notice the merge would teach the generic trash path snippet semantics for
one record type. Instead un-merge OWNS the restore: one operation, one
authorization check, trash stays ignorant. Restoring by hand is still
allowed and still leaves both records claiming the same places — so
un-merge treats an already-alive source as the normal case and goes
straight to the subtraction that repairs it. That is the state that
motivated the feature, not an error.

Adds trash.restore_entity(user_id, type, id) — the missing inverse of
delete(), which returns a batch id callers don't keep. Restores the whole
batch, since the batch is the entity plus its cascade.

Refs #2165

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-28 18:42:29 -04:00

445 lines
12 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
getSnippet,
deleteSnippet,
unmergeSnippet,
type Snippet,
} from "@/api/snippets";
import { useToastStore } from "@/stores/toast";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
const route = useRoute();
const router = useRouter();
const toast = useToastStore();
const snippet = ref<Snippet | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const showDeleteConfirm = ref(false);
const id = computed(() => Number(route.params.id));
const canWrite = computed(() => {
const p = snippet.value?.permission;
return p === undefined || p === "owner" || p === "edit" || p === "admin";
});
const locations = computed(() => snippet.value?.snippet.locations ?? []);
// What this record absorbed. Merge keeps the target's fields and trashes the
// sources, so this line is the only visible trace that the variants existed.
const mergedFrom = computed(() => snippet.value?.snippet.merged_from ?? []);
function locParts(loc: { repo: string; path: string; symbol: string }): string[] {
return [loc.repo, loc.path, loc.symbol].filter((p) => p && p.trim());
}
// Un-merge (#2165)
const unmerging = ref<number | null>(null);
/** Only entries carrying what the source contributed can be reversed exactly.
* Without that, subtraction would be a guess that could strip call sites the
* survivor owns in its own right — so the control isn't offered. */
function canUnmerge(entry: { locations?: unknown[]; tags?: unknown[] }): boolean {
return canWrite.value && (!!entry.locations?.length || !!entry.tags?.length);
}
async function doUnmerge(sourceId: number) {
unmerging.value = sourceId;
try {
await unmergeSnippet(id.value, sourceId);
toast.show(`#${sourceId} pulled back out and restored`);
await load();
} catch (e: unknown) {
// 409 carries the reason the record's state makes it impossible — show it
// rather than a generic failure, since it's the actionable part.
const detail = (e as { body?: { error?: string } }).body?.error;
toast.show(detail || `Couldn't un-merge #${sourceId}`, "error");
} finally {
unmerging.value = null;
}
}
async function load() {
loading.value = true;
error.value = null;
try {
snippet.value = await getSnippet(id.value);
} catch (e: unknown) {
const status = (e as { status?: number }).status;
error.value = status === 404
? "This snippet couldn't be found."
: "Couldn't load this snippet.";
} finally {
loading.value = false;
}
}
onMounted(load);
async function copyCode() {
const code = snippet.value?.snippet.code ?? "";
try {
await navigator.clipboard.writeText(code);
toast.show("Code copied");
} catch {
toast.show("Couldn't copy — select and copy manually", "error");
}
}
async function confirmDelete() {
try {
await deleteSnippet(id.value);
toast.show("Snippet deleted");
router.push("/snippets");
} catch {
toast.show("Failed to delete snippet", "error");
} finally {
showDeleteConfirm.value = false;
}
}
</script>
<template>
<main class="snippet-detail">
<router-link to="/snippets" class="back-link"> Snippets</router-link>
<div v-if="loading" class="state-msg">Fetching the snippet</div>
<p v-else-if="error" class="error-msg">{{ error }}</p>
<template v-else-if="snippet">
<div class="detail-header">
<h1 class="snippet-name">{{ snippet.snippet.name }}</h1>
<div class="header-actions" v-if="canWrite">
<button class="btn-ghost" @click="router.push(`/snippets/${id}/edit`)">Edit</button>
<button class="btn-danger" @click="showDeleteConfirm = true">Delete</button>
</div>
</div>
<p v-if="snippet.shared" class="shared-notice">
Shared by <strong>{{ snippet.owner ?? "another user" }}</strong> their
suggestion, not one of your own records. Worth weighing on its merits
before you build on it.
</p>
<p v-if="snippet.snippet.when_to_use" class="when-to-use">
{{ snippet.snippet.when_to_use }}
</p>
<dl class="meta-grid">
<template v-if="snippet.snippet.signature">
<dt>Signature</dt>
<dd><code>{{ snippet.snippet.signature }}</code></dd>
</template>
<template v-if="locations.length">
<dt>{{ locations.length > 1 ? "Locations" : "Location" }}</dt>
<dd class="location-list">
<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>
</template>
<template v-if="snippet.snippet.language">
<dt>Language</dt>
<dd>{{ snippet.snippet.language }}</dd>
</template>
<template v-if="mergedFrom.length">
<dt>Merged from</dt>
<dd class="merged-from">
<span v-for="m in mergedFrom" :key="m.id" class="merged-entry">
<span>#{{ m.id }}</span>
<button
v-if="canUnmerge(m)"
class="unmerge-btn"
:disabled="unmerging === m.id"
:title="`Pull #${m.id} back out: restore it and remove the ${(m.locations ?? []).length} location(s) it contributed`"
@click="doUnmerge(m.id)"
>
{{ unmerging === m.id ? "…" : "un-merge" }}
</button>
<!-- Says why rather than hiding the control: a disabled thing with
no explanation reads as a bug. -->
<span
v-else-if="canWrite"
class="unmerge-na"
:title="`This merge predates per-source provenance, so what #${m.id} contributed isn't recorded. Restore it from the trash and adjust both records by hand — subtracting a guess could strip call sites this record genuinely owns.`"
>
(not reversible)
</span>
</span>
<span class="merged-hint">
folded in here the originals are in the trash
</span>
</dd>
</template>
</dl>
<div class="code-block">
<div class="code-bar">
<span class="code-lang">{{ snippet.snippet.language || "code" }}</span>
<button class="btn-ghost btn-copy" @click="copyCode">Copy</button>
</div>
<pre><code>{{ snippet.snippet.code }}</code></pre>
</div>
<div v-if="snippet.tags.length" class="tag-row">
<span v-for="t in snippet.tags" :key="t" class="tag-pill">{{ t }}</span>
</div>
</template>
<ConfirmDialog
v-if="showDeleteConfirm"
title="Delete snippet"
:message="`Delete “${snippet?.snippet.name}”? This can be restored from the trash.`"
confirmLabel="Delete"
danger
@confirm="confirmDelete"
@cancel="showDeleteConfirm = false"
/>
</main>
</template>
<style scoped>
.snippet-detail {
max-width: 820px;
margin: 2rem auto;
padding: 0 var(--page-padding-x);
overflow-x: clip;
}
.back-link {
display: inline-block;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--color-text-secondary);
text-decoration: none;
}
.back-link:hover {
color: var(--color-primary);
}
.state-msg {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-top: 1rem;
}
.error-msg {
color: var(--color-danger);
font-size: 0.9rem;
margin-top: 1rem;
}
.detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.snippet-name {
margin: 0;
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-size: 1.4rem;
word-break: break-word;
}
.header-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.btn-ghost {
padding: 0.35rem 0.8rem;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
transition: border-color 0.15s, color 0.15s;
}
.btn-ghost:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-danger {
padding: 0.35rem 0.8rem;
border: none;
background: var(--color-action-destructive, #6B2118);
color: #fff;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
}
.btn-danger:hover {
opacity: 0.9;
}
.when-to-use {
margin: 0.75rem 0 1.25rem;
font-size: 1rem;
color: var(--color-text-secondary);
line-height: 1.55;
}
/* Shown only for a record another user owns. Deliberately above the code: the
provenance has to be read before the implementation is trusted. */
.shared-notice {
margin: 0.75rem 0 0;
padding: 0.6rem 0.85rem;
border-left: 3px solid var(--color-text-muted);
border-radius: 6px;
background: var(--color-bg-secondary);
font-size: 0.85rem;
line-height: 1.5;
color: var(--color-text-secondary);
}
.meta-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.4rem 1rem;
margin: 0 0 1.5rem;
}
.meta-grid dt {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
padding-top: 0.15rem;
}
.meta-grid dd {
margin: 0;
font-size: 0.9rem;
color: var(--color-text);
min-width: 0;
}
.meta-grid code,
.tag-row + * code {
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-size: 0.82rem;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
padding: 0.08rem 0.35rem;
border-radius: var(--radius-sm);
word-break: break-all;
}
.location-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.location {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.merged-from {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: baseline;
}
.merged-hint {
color: var(--color-text-muted);
font-size: 0.8rem;
}
.merged-entry {
display: inline-flex;
align-items: baseline;
gap: 0.3rem;
}
.unmerge-btn {
font-size: 0.72rem;
padding: 0.05rem 0.35rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
}
.unmerge-btn:hover:not(:disabled) {
color: var(--color-text);
border-color: var(--color-text-muted);
}
.unmerge-btn:disabled {
opacity: 0.6;
cursor: default;
}
.unmerge-na {
font-size: 0.72rem;
color: var(--color-text-muted);
/* Cursor cues that the explanation is in the tooltip. */
cursor: help;
}
.code-block {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-bg);
}
.code-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.5rem 0.4rem 0.85rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.code-lang {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
}
.btn-copy {
padding: 0.2rem 0.65rem;
font-size: 0.78rem;
}
.code-block pre {
margin: 0;
padding: 1rem;
overflow-x: auto;
}
.code-block code {
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-size: 0.85rem;
line-height: 1.6;
color: var(--color-text);
white-space: pre;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 1.25rem;
}
.tag-pill {
font-size: 0.72rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
@media (max-width: 600px) {
.detail-header {
flex-direction: column;
}
.meta-grid {
grid-template-columns: 1fr;
gap: 0.15rem 0;
}
.meta-grid dd {
margin-bottom: 0.5rem;
}
}
</style>