Files
FabledScribe/frontend/src/views/SnippetEditorView.vue
T
bvandeusenandClaude Fable 5 4179f3e560
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 27s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 34s
refactor(frontend): the second sweep — scoped rules that no longer match their own template (#2962)
The first sweep asked "does this class token appear anywhere outside a
<style> block?". That is too generous for a `<style scoped>` rule, which can
only ever match its own template, the component's root element, or whatever
`:deep()` reaches — so a scoped rule whose name lives only in some OTHER file
is dead regardless. The server's map had this right and my local pass did
not; this closes the gap.

79 scoped classes are absent from their own file. 48 are names their own file
BUILDS — `status-${task.status}`, `pri-${p}`, `toast--${type}`,
`perm-${permission}`, `diff-${op}`, `is-${kind}` — and 12 more are Vue
transition classes. Those are the map's documented blind spot and they stay.
The remaining 19 match nothing:

- AppHeader (9, -39 lines): the whole connection-status indicator —
  .status-indicator/.status-dot/.status-text and the five colour states,
  plus @keyframes pulse-dot and status-pulse, which had no other user. Same
  Phase 7 residue as the last commit. `.btn-icon.active` goes too: `active`
  is never applied in this component (its nav uses router-link-active).
- SettingsView (6): .status-badge/.status-on/.status-off,
  .perm-granted/.perm-denied, .location-row. This view renders no child
  components at all, so nothing can inherit its scope.
- WorkspaceNoteEditor: .note-row:hover .btn-delete and .btn-suggest-tags —
  those buttons are .btn-danger-outline/.btn-ghost now.
- ProjectListView .loading-msg; SnippetEditorView .field-row.three, whose
  media-query companion keeps its live .field-row half.

Checked against child-component roots before deleting, since a child's root
element does inherit the parent's scope id — none of the 19 is one. Template
and script regions byte-identical; both style checks still pass with only
the six pre-existing reports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:21:43 -04:00

556 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, nextTick, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
getSnippet,
createSnippet,
updateSnippet,
type SnippetInput,
type SnippetLocation,
} from "@/api/snippets";
import { ApiError } from "@/api/client";
import ProjectSelector from "@/components/ProjectSelector.vue";
import { useSystemsStore } from "@/stores/systems";
import { useToastStore } from "@/stores/toast";
const route = useRoute();
const router = useRouter();
const toast = useToastStore();
const systemsStore = useSystemsStore();
const editId = computed(() => (route.params.id ? Number(route.params.id) : null));
const isEditing = computed(() => editId.value !== null);
// All-string scalar state (tags/locations handled separately) so v-model and
// .trim() never meet an undefined.
interface FormState {
name: string;
code: string;
language: string;
signature: string;
when_to_use: string;
}
const blankLocation = (): SnippetLocation => ({ repo: "", path: "", symbol: "" });
const form = ref<FormState>({
name: "",
code: "",
language: "",
signature: "",
when_to_use: "",
});
// 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 projectId = ref<number | null>(null);
const systemIds = ref<number[]>([]);
const loading = ref(false);
const saving = ref(false);
const loadError = ref<string | null>(null);
const nameRef = ref<HTMLInputElement | null>(null);
// Systems belong to a project, so the picker only has anything to offer once
// one is chosen — and it repopulates when the project changes.
const projectSystems = computed(() =>
projectId.value ? (systemsStore.systemsByProject[projectId.value] ?? []) : [],
);
async function loadSystems() {
if (!projectId.value) return;
try {
await systemsStore.fetchSystems(projectId.value);
} catch {
/* non-fatal — the systems picker just won't populate */
}
}
watch(projectId, (next, prev) => {
// Moving to another project invalidates system ids from the old one.
if (prev !== null && next !== prev) systemIds.value = [];
loadSystems();
});
const canSave = computed(
() => !!form.value.name.trim() && !!form.value.code.trim() && !saving.value,
);
// A near-duplicate blocked on create: the same reusable thing is already
// recorded. Recording it twice is what merge then has to undo, so the editor
// offers the existing record before it offers to save anyway.
interface DuplicateHit {
existing_id: number;
existing_title: string;
}
const duplicate = ref<DuplicateHit | null>(null);
const forceCreate = ref(false);
function duplicateFrom(err: unknown): DuplicateHit | null {
if (!(err instanceof ApiError) || err.status !== 409) return null;
const body = err.body as Record<string, unknown>;
if (!body.duplicate) return null;
return {
existing_id: Number(body.existing_id),
existing_title: String(body.existing_title ?? "an existing snippet"),
};
}
async function load() {
if (!isEditing.value) {
// Recording from a project page carries the project through, so the snippet
// lands where the work is instead of unfiled.
if (route.query.projectId) {
projectId.value = Number(route.query.projectId);
await loadSystems();
}
await nextTick();
nameRef.value?.focus();
return;
}
loading.value = true;
loadError.value = null;
try {
const s = await getSnippet(editId.value!);
const f = s.snippet;
form.value = {
name: f.name,
code: f.code,
language: f.language,
signature: f.signature,
when_to_use: f.when_to_use,
};
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
.filter((t) => t !== "snippet" && t !== f.language)
.join(", ");
projectId.value = s.project_id ?? null;
systemIds.value = (s.systems ?? []).map((sys) => sys.id);
await loadSystems();
} catch {
loadError.value = "Couldn't load this snippet to edit.";
} finally {
loading.value = false;
}
}
onMounted(load);
function parseTags(): string[] {
return tagsText.value
.split(",")
.map((t) => t.trim())
.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;
const payload: SnippetInput = {
name: form.value.name.trim(),
code: form.value.code,
language: form.value.language.trim(),
signature: form.value.signature.trim(),
when_to_use: form.value.when_to_use.trim(),
locations: cleanLocations(),
tags: parseTags(),
project_id: projectId.value,
system_ids: systemIds.value,
};
if (forceCreate.value) payload.force = true;
try {
const result = isEditing.value
? await updateSnippet(editId.value!, payload)
: await createSnippet(payload);
toast.show(isEditing.value ? "Snippet saved" : "Snippet created");
router.push(`/snippets/${result.id}`);
} catch (err) {
// A blocked near-duplicate isn't a failure — it's the record telling you
// this already exists. Offer the existing one rather than a red toast.
const dup = duplicateFrom(err);
if (dup) {
duplicate.value = dup;
saving.value = false;
return;
}
toast.show("Failed to save snippet", "error");
} finally {
saving.value = false;
}
}
async function saveAnyway() {
duplicate.value = null;
forceCreate.value = true;
await save();
}
function cancel() {
if (isEditing.value) router.push(`/snippets/${editId.value}`);
else router.push("/snippets");
}
</script>
<template>
<main class="snippet-editor" @keydown.ctrl.s.prevent="save" @keydown.meta.s.prevent="save">
<router-link :to="isEditing ? `/snippets/${editId}` : '/snippets'" class="back-link">
{{ isEditing ? "Back to snippet" : "Snippets" }}
</router-link>
<h1>{{ isEditing ? "Edit snippet" : "New snippet" }}</h1>
<div v-if="loading" class="state-msg">Loading</div>
<p v-else-if="loadError" class="error-msg">{{ loadError }}</p>
<form v-else class="form" @submit.prevent="save">
<div class="field">
<label for="sn-name">Name <span class="required">*</span></label>
<input
id="sn-name"
ref="nameRef"
v-model="form.name"
type="text"
class="fs-input input mono"
placeholder="useDebouncedRef"
@keydown.escape="cancel"
/>
</div>
<div class="field">
<label for="sn-when">When to reach for it</label>
<input
id="sn-when"
v-model="form.when_to_use"
type="text"
class="fs-input input"
placeholder="Debounce a reactive ref that updates too often"
@keydown.escape="cancel"
/>
<p class="hint">Shown in the recall menu keep it sharp.</p>
</div>
<div class="field-row">
<div class="field">
<label for="sn-lang">Language</label>
<input
id="sn-lang"
v-model="form.language"
type="text"
class="fs-input input"
placeholder="typescript"
@keydown.escape="cancel"
/>
</div>
<div class="field">
<label for="sn-sig">Signature</label>
<input
id="sn-sig"
v-model="form.signature"
type="text"
class="fs-input input mono"
placeholder="useDebouncedRef(value, ms)"
@keydown.escape="cancel"
/>
</div>
</div>
<fieldset class="location-set">
<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="fs-input input mono" placeholder="repo" aria-label="Repo" @keydown.escape="cancel" />
<input v-model="loc.path" type="text" class="fs-input input mono" placeholder="path" aria-label="Path" @keydown.escape="cancel" />
<input v-model="loc.symbol" type="text" class="fs-input 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">
<label for="sn-code">Code <span class="required">*</span></label>
<textarea
id="sn-code"
v-model="form.code"
class="fs-input input mono code-area"
rows="14"
spellcheck="false"
placeholder="Paste the reusable implementation…"
></textarea>
</div>
<div class="field">
<label for="sn-tags">Tags</label>
<input
id="sn-tags"
v-model="tagsText"
type="text"
class="fs-input input"
placeholder="composable, ui (comma-separated)"
@keydown.escape="cancel"
/>
<p class="hint">Extra tags. Language and snippet are added automatically.</p>
</div>
<div class="field">
<label for="sn-project">Project</label>
<ProjectSelector id="sn-project" v-model="projectId" />
<p class="hint">
Snippets surface proactively within their project; search reaches across all of them.
</p>
</div>
<div v-if="projectId" class="field">
<span class="field-label">Systems</span>
<div v-if="projectSystems.length" class="systems">
<label v-for="s in projectSystems" :key="s.id" class="system-opt">
<input type="checkbox" :value="s.id" v-model="systemIds" />
<span>{{ s.name }}</span>
</label>
</div>
<p v-else class="hint">No systems in this project yet.</p>
</div>
<div v-if="duplicate" class="duplicate" role="alert">
<p class="duplicate-title">This may already be recorded</p>
<p class="duplicate-body">
A similar snippet exists
<router-link :to="`/snippets/${duplicate.existing_id}`">
{{ duplicate.existing_title }}
</router-link
>. Reuse or edit that one rather than keeping two copies of the same thing.
</p>
<div class="duplicate-actions">
<button type="button" class="btn-secondary" @click="saveAnyway">
Record it anyway
</button>
</div>
</div>
<div class="actions">
<button type="button" class="btn-secondary" @click="cancel">Cancel</button>
<button type="submit" class="btn-primary" :disabled="!canSave">
{{ saving ? "Saving…" : isEditing ? "Save changes" : "Create snippet" }}
</button>
</div>
</form>
</main>
</template>
<style scoped>
.snippet-editor {
max-width: 760px;
margin: 2rem auto;
padding: 0 var(--fs-layout-page-pad);
overflow-x: clip;
}
.snippet-editor h1 {
margin: 0 0 1.25rem;
}
.back-link {
display: inline-block;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--fs-text-secondary);
text-decoration: none;
}
.back-link:hover {
color: var(--fs-accent);
}
.form {
display: flex;
flex-direction: column;
gap: 1.1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.field label,
.location-set legend {
font-size: 0.8rem;
font-weight: 500;
color: var(--fs-text-primary);
}
.hint {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
margin: 0.1rem 0 0;
}
.hint-inline {
font-weight: 400;
color: var(--fs-text-tertiary);
}
/* remainder over .fs-input (components.css, canon #2336; m302) */
.input {
width: 100%;
box-sizing: border-box;
}
.mono {
font-family: var(--fs-font-mono);
}
.code-area {
resize: vertical;
line-height: 1.6;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
tab-size: 2;
}
.location-set {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 0.85rem 1rem 1rem;
margin: 0;
}
.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(--fs-border-color);
background: transparent;
color: var(--fs-text-tertiary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
}
.loc-remove:hover {
border-color: var(--fs-error);
color: var(--fs-error);
}
.loc-add {
margin-top: 0.15rem;
padding: 0.35rem 0.7rem;
border: 1px dashed var(--fs-border-color);
background: transparent;
color: var(--fs-text-secondary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.82rem;
font-family: inherit;
}
.loc-add:hover {
border-color: var(--fs-accent);
color: var(--fs-accent);
}
@media (max-width: 600px) {
.loc-row {
grid-template-columns: 1fr 1fr;
}
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--fs-text-primary);
}
.systems {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 160px;
overflow-y: auto;
}
.system-opt {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.85rem;
color: var(--fs-text-primary);
cursor: pointer;
}
.system-opt input {
accent-color: var(--fs-accent);
cursor: pointer;
}
.duplicate {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.85rem 1rem;
border: 1px solid var(--fs-border-color);
border-left: 3px solid var(--fs-warning);
border-radius: 8px;
background: var(--fs-surface-raised);
}
.duplicate-title {
margin: 0;
font-size: 0.85rem;
font-weight: 500;
color: var(--fs-text-primary);
}
.duplicate-body {
margin: 0;
font-size: 0.8rem;
line-height: 1.5;
color: var(--fs-text-secondary);
}
.duplicate-body a {
color: var(--fs-accent);
}
.duplicate-actions {
display: flex;
justify-content: flex-end;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.25rem;
}
@media (max-width: 600px) {
.field-row {
grid-template-columns: 1fr;
}
}
</style>