feat(scribe): snippet management UI + REST routes; embed snippets on create
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m4s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m4s
Step 6 of the Drafter recall milestone (#227): a human-facing surface for the reusable-code snippets that agents record via MCP, plus the REST API behind it. Backend embeds snippets inline on create/update so they're recallable immediately, not only after a restart. Backend: - routes/snippets.py: GET/POST /api/snippets, GET/PATCH/DELETE /api/snippets/<id>. Share-aware per rule #78 (get_note_for_user + can_write_note), writes performed as the owner; list owner-scoped — mirrors routes/notes.py. Registered in app.py. - services/snippets.py: embed on create/update via a _embed_snippet fire-and-forget helper, covering BOTH the MCP tool and the REST route by construction. A snippet's value is immediate recall, so it can't wait for the startup-only backfill (see issue: MCP create path doesn't embed inline for notes/tasks generally). - tests/test_routes_snippets.py: structural registration + handler/service contract + PATCH-field ↔ update_snippet-kwarg parity (rule #33). Frontend (Vue 3 + TS): - api/snippets.ts: typed client, modeled on api/systems.ts. - views: SnippetListView (search, skeleton/empty/error states), SnippetDetailView (read + copy-to-clipboard, ConfirmDialog delete), SnippetEditorView (create/edit all fields, Ctrl/Cmd+S, Esc, autofocus, validation). v1 quality per rules #24/#27. - router: /snippets, /snippets/new, /snippets/:id, /snippets/:id/edit. - NoteType union widened to include 'snippet'; Snippets nav link added to AppHeader (desktop pill bar + mobile menu). - Design system: Moss --color-action-primary for action buttons, accent --color-primary reserved for tags/brand (Hybrid rule); focus rings; JetBrains Mono for code/name/signature/location. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
|
||||
/** Structured fields parsed out of a snippet note (mirrors the backend
|
||||
* `parse_snippet_fields` — see services/snippets.py). */
|
||||
export interface SnippetFields {
|
||||
name: string;
|
||||
when_to_use: string;
|
||||
signature: string;
|
||||
language: string;
|
||||
repo: string;
|
||||
path: string;
|
||||
symbol: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** A full snippet record: the note dict plus the parsed `snippet` sub-object,
|
||||
* as returned by the backend `snippet_to_dict`. */
|
||||
export interface Snippet {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
tags: string[];
|
||||
note_type: string;
|
||||
project_id: number | null;
|
||||
permission?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
snippet: SnippetFields;
|
||||
}
|
||||
|
||||
/** Lightweight list item from the knowledge preview feed. Note: the `snippet`
|
||||
* field here is a truncated *body preview* (the knowledge feed's naming), not
|
||||
* the parsed fields above. */
|
||||
export interface SnippetListItem {
|
||||
id: number;
|
||||
title: string;
|
||||
tags: string[];
|
||||
note_type: string;
|
||||
snippet: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Create/update payload — discrete fields the backend serializes into the
|
||||
* note (title/body/tags). Empty strings are applied; omitted keys are left
|
||||
* unchanged on update. */
|
||||
export interface SnippetInput {
|
||||
name: string;
|
||||
code: string;
|
||||
language?: string;
|
||||
signature?: string;
|
||||
when_to_use?: string;
|
||||
repo?: string;
|
||||
path?: string;
|
||||
symbol?: string;
|
||||
tags?: string[];
|
||||
project_id?: number | null;
|
||||
}
|
||||
|
||||
export async function listSnippets(
|
||||
params: { q?: string; tag?: string } = {},
|
||||
): Promise<{ snippets: SnippetListItem[]; total: number }> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.q) qs.set("q", params.q);
|
||||
if (params.tag) qs.set("tag", params.tag);
|
||||
const query = qs.toString();
|
||||
return apiGet(`/api/snippets${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function getSnippet(id: number): Promise<Snippet> {
|
||||
return apiGet(`/api/snippets/${id}`);
|
||||
}
|
||||
|
||||
export async function createSnippet(data: SnippetInput): Promise<Snippet> {
|
||||
return apiPost("/api/snippets", data);
|
||||
}
|
||||
|
||||
export async function updateSnippet(
|
||||
id: number,
|
||||
data: Partial<SnippetInput>,
|
||||
): Promise<Snippet> {
|
||||
return apiPatch(`/api/snippets/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteSnippet(id: number): Promise<void> {
|
||||
return apiDelete(`/api/snippets/${id}`);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ router.afterEach(() => {
|
||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,6 +94,7 @@ router.afterEach(() => {
|
||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
|
||||
@@ -69,6 +69,26 @@ const router = createRouter({
|
||||
name: "note-edit",
|
||||
component: () => import("@/views/NoteEditorView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/snippets",
|
||||
name: "snippets",
|
||||
component: () => import("@/views/SnippetListView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/snippets/new",
|
||||
name: "snippet-new",
|
||||
component: () => import("@/views/SnippetEditorView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/snippets/:id",
|
||||
name: "snippet-view",
|
||||
component: () => import("@/views/SnippetDetailView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/snippets/:id/edit",
|
||||
name: "snippet-edit",
|
||||
component: () => import("@/views/SnippetEditorView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/graph",
|
||||
name: "graph",
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { System } from "@/api/systems";
|
||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||
export type TaskKind = "work" | "plan" | "issue";
|
||||
export type NoteType = "note" | "process";
|
||||
export type NoteType = "note" | "process" | "snippet";
|
||||
|
||||
export interface Note {
|
||||
id: number;
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { getSnippet, deleteSnippet, 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 locationParts = computed(() => {
|
||||
const s = snippet.value?.snippet;
|
||||
if (!s) return [];
|
||||
return [s.repo, s.path, s.symbol].filter((p) => p && p.trim());
|
||||
});
|
||||
|
||||
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.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="locationParts.length">
|
||||
<dt>Location</dt>
|
||||
<dd class="location">
|
||||
<code v-for="(p, i) in locationParts" :key="i">{{ p }}</code>
|
||||
</dd>
|
||||
</template>
|
||||
<template v-if="snippet.snippet.language">
|
||||
<dt>Language</dt>
|
||||
<dd>{{ snippet.snippet.language }}</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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,394 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
getSnippet,
|
||||
createSnippet,
|
||||
updateSnippet,
|
||||
type SnippetInput,
|
||||
} from "@/api/snippets";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
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
|
||||
// .trim() never meet an undefined.
|
||||
interface FormState {
|
||||
name: string;
|
||||
code: string;
|
||||
language: string;
|
||||
signature: string;
|
||||
when_to_use: string;
|
||||
repo: string;
|
||||
path: string;
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
const form = ref<FormState>({
|
||||
name: "",
|
||||
code: "",
|
||||
language: "",
|
||||
signature: "",
|
||||
when_to_use: "",
|
||||
repo: "",
|
||||
path: "",
|
||||
symbol: "",
|
||||
});
|
||||
const tagsText = ref("");
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const loadError = ref<string | null>(null);
|
||||
const nameRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const canSave = computed(
|
||||
() => !!form.value.name.trim() && !!form.value.code.trim() && !saving.value,
|
||||
);
|
||||
|
||||
async function load() {
|
||||
if (!isEditing.value) {
|
||||
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,
|
||||
repo: f.repo,
|
||||
path: f.path,
|
||||
symbol: f.symbol,
|
||||
};
|
||||
// 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(", ");
|
||||
} 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);
|
||||
}
|
||||
|
||||
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(),
|
||||
repo: form.value.repo.trim(),
|
||||
path: form.value.path.trim(),
|
||||
symbol: form.value.symbol.trim(),
|
||||
tags: parseTags(),
|
||||
};
|
||||
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 {
|
||||
toast.show("Failed to save snippet", "error");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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="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="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="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="input mono"
|
||||
placeholder="useDebouncedRef(value, ms)"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="field">
|
||||
<label for="sn-code">Code <span class="required">*</span></label>
|
||||
<textarea
|
||||
id="sn-code"
|
||||
v-model="form.code"
|
||||
class="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="input"
|
||||
placeholder="composable, ui (comma-separated)"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
<p class="hint">Extra tags. Language and “snippet” are added automatically.</p>
|
||||
</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(--page-padding-x);
|
||||
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(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.back-link:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.state-msg {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.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-row.three {
|
||||
grid-template-columns: 1fr 1.4fr 1fr;
|
||||
}
|
||||
.field label,
|
||||
.location-set legend {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.required {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0.1rem 0 0;
|
||||
}
|
||||
.hint-inline {
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
}
|
||||
.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(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.85rem 1rem 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
.location-set legend {
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.btn-primary {
|
||||
padding: 0.5rem 1.1rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1.1rem;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.field-row,
|
||||
.field-row.three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { listSnippets, type SnippetListItem } from "@/api/snippets";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToastStore();
|
||||
|
||||
const snippets = ref<SnippetListItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const search = ref("");
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function loadSnippets() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const data = await listSnippets({ q: search.value.trim() || undefined });
|
||||
snippets.value = data.snippets;
|
||||
} catch {
|
||||
error.value = "Couldn't load your snippets.";
|
||||
toast.show("Failed to load snippets", "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(loadSnippets, 300);
|
||||
}
|
||||
|
||||
onMounted(loadSnippets);
|
||||
|
||||
/** Titles are stored as "name — when to reach for it"; split for display. */
|
||||
function splitTitle(title: string): { name: string; when: string } {
|
||||
const idx = title.indexOf(" — ");
|
||||
if (idx === -1) return { name: title, when: "" };
|
||||
return { name: title.slice(0, idx), when: title.slice(idx + 3) };
|
||||
}
|
||||
|
||||
function languageOf(tags: string[]): string {
|
||||
return tags.find((t) => t && t !== "snippet") ?? "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="snippets-list">
|
||||
<div class="page-header">
|
||||
<h1>Snippets</h1>
|
||||
<button class="btn-primary" @click="router.push('/snippets/new')">
|
||||
+ New snippet
|
||||
</button>
|
||||
</div>
|
||||
<p class="page-sub">
|
||||
Reusable functions and components, recorded once so they surface before
|
||||
they're rewritten as a one-off.
|
||||
</p>
|
||||
|
||||
<div class="search-row">
|
||||
<input
|
||||
v-model="search"
|
||||
type="search"
|
||||
class="search-input"
|
||||
placeholder="Search snippets…"
|
||||
aria-label="Search snippets"
|
||||
@input="onSearchInput"
|
||||
@keydown.enter="loadSnippets"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="skeleton-grid">
|
||||
<div class="skeleton-card" v-for="i in 6" :key="i"></div>
|
||||
</div>
|
||||
|
||||
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||||
|
||||
<div v-else-if="snippets.length === 0" class="empty-state-rich">
|
||||
<div class="empty-icon">❭_</div>
|
||||
<p class="empty-title">
|
||||
{{ search.trim() ? "No snippets match your search" : "No snippets kept yet" }}
|
||||
</p>
|
||||
<p class="empty-sub">
|
||||
{{ search.trim()
|
||||
? "Try a different term, or clear the search."
|
||||
: "Record a reusable function or component and it will be offered back to you later." }}
|
||||
</p>
|
||||
<button
|
||||
v-if="!search.trim()"
|
||||
class="empty-action"
|
||||
@click="router.push('/snippets/new')"
|
||||
>
|
||||
New snippet →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="snippets-grid">
|
||||
<div
|
||||
v-for="s in snippets"
|
||||
:key="s.id"
|
||||
class="snippet-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="router.push(`/snippets/${s.id}`)"
|
||||
@keydown.enter="router.push(`/snippets/${s.id}`)"
|
||||
>
|
||||
<div class="card-header">
|
||||
<span class="snippet-name">{{ splitTitle(s.title).name }}</span>
|
||||
<span v-if="languageOf(s.tags)" class="lang-pill">{{ languageOf(s.tags) }}</span>
|
||||
</div>
|
||||
<p v-if="splitTitle(s.title).when" class="snippet-when">
|
||||
{{ splitTitle(s.title).when }}
|
||||
</p>
|
||||
<div class="card-footer">
|
||||
<span class="meta-date">Updated {{ new Date(s.updated_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.snippets-list {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--page-padding-x);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
.page-sub {
|
||||
margin: 0 0 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
/* Moss action-primary per Hybrid — utility action, not a brand moment. */
|
||||
.btn-primary {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.search-row {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.empty-state-rich {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.empty-icon {
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
.empty-sub {
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 1rem;
|
||||
max-width: 44ch;
|
||||
margin-inline: auto;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.empty-action {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.empty-action:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.skeleton-grid,
|
||||
.snippets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.skeleton-card {
|
||||
height: 96px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.snippet-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.9rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.18s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.snippet-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.snippet-card:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.snippet-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
}
|
||||
|
||||
/* Language tag — accent pill per the design system's tag treatment. */
|
||||
.lang-pill {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
padding: 0.12rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.snippet-when {
|
||||
font-size: 0.83rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
.meta-date {
|
||||
font-size: 0.73rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.snippets-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -29,6 +29,7 @@ from scribe.routes.plugin import plugin_bp
|
||||
from scribe.routes.trash import trash_bp
|
||||
from scribe.routes.dashboard import dashboard_bp
|
||||
from scribe.routes.systems import systems_bp
|
||||
from scribe.routes.snippets import snippets_bp
|
||||
from scribe.mcp import mount_mcp
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
@@ -91,6 +92,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(trash_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(systems_bp)
|
||||
app.register_blueprint(snippets_bp)
|
||||
|
||||
@app.before_request
|
||||
async def before_request():
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""REST routes for snippets — reusable functions/components recorded for recall.
|
||||
|
||||
A snippet is a note with note_type='snippet' (see services/snippets.py). These
|
||||
routes feed the web management UI; the MCP tools (mcp/tools/snippets.py) are the
|
||||
agent-facing surface. Both go through services/snippets.py, so the
|
||||
serialize/parse contract and embedding-on-create live in one place (DRY).
|
||||
|
||||
ACL (rule #78): reads/writes of a single snippet resolve through the share-aware
|
||||
`get_note_for_user` + `can_write_note`, and writes are performed as the OWNER so
|
||||
a shared editor isn't rejected by the owner-scoped service — mirroring
|
||||
routes/notes.py. The list is owner-scoped, matching the note-browse surface.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.routes.utils import not_found, parse_pagination
|
||||
from scribe.services import snippets as snippets_svc
|
||||
from scribe.services.access import can_write_note
|
||||
from scribe.services.notes import get_note_for_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
snippets_bp = Blueprint("snippets", __name__, url_prefix="/api/snippets")
|
||||
|
||||
# Fields the create/update payload may carry, mapped straight to the service.
|
||||
_STR_FIELDS = ("name", "code", "language", "signature", "when_to_use", "repo", "path", "symbol")
|
||||
|
||||
|
||||
async def _load_snippet(uid: int, snippet_id: int):
|
||||
"""Share-aware resolve of a snippet by id → (note, permission) or None if it
|
||||
isn't accessible or isn't a snippet."""
|
||||
result = await get_note_for_user(uid, snippet_id)
|
||||
if result is None:
|
||||
return None
|
||||
note, permission = result
|
||||
if note.note_type != snippets_svc.SNIPPET_NOTE_TYPE:
|
||||
return None
|
||||
return note, permission
|
||||
|
||||
|
||||
@snippets_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def list_snippets_route():
|
||||
uid = get_current_user_id()
|
||||
q = request.args.get("q") or None
|
||||
tag = request.args.get("tag", "")
|
||||
limit, offset = parse_pagination()
|
||||
items, total = await snippets_svc.list_snippets(
|
||||
uid, q=q, tag=tag, limit=limit, offset=offset,
|
||||
)
|
||||
return jsonify({"snippets": items, "total": total})
|
||||
|
||||
|
||||
@snippets_bp.route("", methods=["POST"])
|
||||
@login_required
|
||||
async def create_snippet_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json() or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
code = (data.get("code") or "").strip()
|
||||
if not name or not code:
|
||||
return jsonify({"error": "name and code are required"}), 400
|
||||
project_id = data.get("project_id") or None
|
||||
note = await snippets_svc.create_snippet(
|
||||
uid,
|
||||
name=name,
|
||||
code=data.get("code", ""),
|
||||
language=data.get("language", ""),
|
||||
signature=data.get("signature", ""),
|
||||
when_to_use=data.get("when_to_use", ""),
|
||||
repo=data.get("repo", ""),
|
||||
path=data.get("path", ""),
|
||||
symbol=data.get("symbol", ""),
|
||||
tags=data.get("tags"),
|
||||
project_id=project_id,
|
||||
)
|
||||
return jsonify(snippets_svc.snippet_to_dict(note)), 201
|
||||
|
||||
|
||||
@snippets_bp.route("/<int:snippet_id>", methods=["GET"])
|
||||
@login_required
|
||||
async def get_snippet_route(snippet_id: int):
|
||||
uid = get_current_user_id()
|
||||
loaded = await _load_snippet(uid, snippet_id)
|
||||
if loaded is None:
|
||||
return not_found("Snippet")
|
||||
note, permission = loaded
|
||||
data = snippets_svc.snippet_to_dict(note)
|
||||
data["permission"] = permission
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@snippets_bp.route("/<int:snippet_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_snippet_route(snippet_id: int):
|
||||
uid = get_current_user_id()
|
||||
loaded = await _load_snippet(uid, snippet_id)
|
||||
if loaded is None:
|
||||
return not_found("Snippet")
|
||||
note, _ = loaded
|
||||
if not await can_write_note(uid, snippet_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
owner_uid = note.user_id
|
||||
data = await request.get_json() or {}
|
||||
|
||||
# Partial update: only keys present in the payload change (the service
|
||||
# treats None as "leave unchanged"). Empty strings ARE applied — the form
|
||||
# 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}
|
||||
if "tags" in data:
|
||||
kwargs["tags"] = data["tags"]
|
||||
if "project_id" in data:
|
||||
kwargs["project_id"] = data["project_id"] or None
|
||||
|
||||
updated = await snippets_svc.update_snippet(owner_uid, snippet_id, **kwargs)
|
||||
if updated is None:
|
||||
return not_found("Snippet")
|
||||
return jsonify(snippets_svc.snippet_to_dict(updated))
|
||||
|
||||
|
||||
@snippets_bp.route("/<int:snippet_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def delete_snippet_route(snippet_id: int):
|
||||
uid = get_current_user_id()
|
||||
loaded = await _load_snippet(uid, snippet_id)
|
||||
if loaded is None:
|
||||
return not_found("Snippet")
|
||||
note, _ = loaded
|
||||
if not await can_write_note(uid, snippet_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
from scribe.services.trash import delete as trash_delete
|
||||
batch = await trash_delete(note.user_id, "note", snippet_id)
|
||||
if batch is None:
|
||||
return not_found("Snippet")
|
||||
return "", 204
|
||||
@@ -23,6 +23,7 @@ changing any caller (MCP tool, REST route, UI).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from scribe.services import knowledge as knowledge_svc
|
||||
@@ -32,6 +33,25 @@ SNIPPET_NOTE_TYPE = "snippet"
|
||||
SNIPPET_TAG = "snippet"
|
||||
|
||||
|
||||
def _embed_snippet(note) -> None:
|
||||
"""Fire-and-forget embedding refresh for a snippet.
|
||||
|
||||
A snippet's whole value is *immediate* recall — it must join the semantic /
|
||||
auto-inject pool the moment it's recorded, not wait for the startup backfill.
|
||||
Unlike a plain note (embedded at the REST-route boundary only, so its MCP
|
||||
create path defers to restart-backfill), a snippet is recorded primarily via
|
||||
MCP, so we embed here in the service — covering BOTH the MCP tool and the
|
||||
REST route by construction. Mirrors the route pattern: fire-and-forget,
|
||||
text = title + body. Import lazily so the pure serialize/parse helpers can be
|
||||
imported without pulling in the embedding model.
|
||||
"""
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if not text:
|
||||
return
|
||||
from scribe.services.embeddings import upsert_note_embedding
|
||||
asyncio.create_task(upsert_note_embedding(note.id, note.user_id, text))
|
||||
|
||||
|
||||
# --- serialize: structured fields -> note (title/body/tags) ------------------
|
||||
|
||||
def compose_title(name: str, when_to_use: str = "") -> str:
|
||||
@@ -164,8 +184,9 @@ async def create_snippet(
|
||||
tags: list[str] | None = None,
|
||||
project_id: int | None = None,
|
||||
):
|
||||
"""Create a snippet note. Returns the created Note."""
|
||||
return await notes_svc.create_note(
|
||||
"""Create a snippet note (embedded on create for immediate recall). Returns
|
||||
the created Note."""
|
||||
note = await notes_svc.create_note(
|
||||
user_id,
|
||||
title=compose_title(name, when_to_use),
|
||||
body=compose_body(
|
||||
@@ -176,6 +197,8 @@ async def create_snippet(
|
||||
tags=compose_tags(language, tags),
|
||||
project_id=project_id,
|
||||
)
|
||||
_embed_snippet(note)
|
||||
return note
|
||||
|
||||
|
||||
async def get_snippet(user_id: int, snippet_id: int):
|
||||
@@ -254,4 +277,8 @@ async def update_snippet(
|
||||
if project_id is not None:
|
||||
fields["project_id"] = project_id
|
||||
|
||||
return await notes_svc.update_note(user_id, snippet_id, **fields)
|
||||
updated = await notes_svc.update_note(user_id, snippet_id, **fields)
|
||||
if updated is not None:
|
||||
# Title/body changed → refresh the embedding so recall reflects the edit.
|
||||
_embed_snippet(updated)
|
||||
return updated
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Structural tests for the snippets blueprint — registration + handler/service
|
||||
contracts. Full HTTP integration needs a live DB + auth the unit env lacks."""
|
||||
import inspect
|
||||
|
||||
|
||||
def test_snippets_blueprint_registered():
|
||||
from scribe.routes.snippets import snippets_bp
|
||||
assert snippets_bp.name == "snippets"
|
||||
assert snippets_bp.url_prefix == "/api/snippets"
|
||||
|
||||
|
||||
def test_snippets_blueprint_registered_in_app():
|
||||
from scribe.app import create_app
|
||||
app = create_app()
|
||||
assert "snippets" in app.blueprints
|
||||
|
||||
|
||||
def test_snippet_handlers_callable():
|
||||
from scribe.routes import snippets as routes
|
||||
for name in (
|
||||
"list_snippets_route", "create_snippet_route", "get_snippet_route",
|
||||
"update_snippet_route", "delete_snippet_route",
|
||||
):
|
||||
assert callable(getattr(routes, name))
|
||||
|
||||
|
||||
def test_service_functions_take_user_id():
|
||||
"""Routes must call snippet services with user_id — verify the contract."""
|
||||
from scribe.services import snippets as svc
|
||||
for fn_name in ("create_snippet", "list_snippets", "get_snippet", "update_snippet"):
|
||||
fn = getattr(svc, fn_name)
|
||||
assert callable(fn)
|
||||
assert "user_id" in inspect.signature(fn).parameters
|
||||
|
||||
|
||||
def test_update_field_map_matches_service_kwargs():
|
||||
"""Every field the PATCH route forwards must be a real update_snippet kwarg
|
||||
(rule #33 interface-contract parity)."""
|
||||
from scribe.routes import snippets as routes
|
||||
from scribe.services import snippets as svc
|
||||
params = inspect.signature(svc.update_snippet).parameters
|
||||
for field in routes._STR_FIELDS:
|
||||
assert field in params, f"update_snippet has no '{field}' kwarg"
|
||||
assert "tags" in params
|
||||
assert "project_id" in params
|
||||
Reference in New Issue
Block a user