CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 37s
#2533. theme.css claimed "removing this block is a rename sweep across the
components, tracked separately" — written in 67a529a, never filed, which made
the comment itself an instance of the survey's presence-without-reference
pattern. This is that sweep.
73 alias declarations deleted; 69 files rewritten; every --color-*-style name
now references its --fs-* token directly. Mechanical by construction: the map
IS the alias block, applied longest-name-first with a boundary guard so
--color-text never matched inside --color-text-muted. Zero survivors outside
theme.css, verified by grep rather than assumed.
One deliberate survivor: --color-shadow stays DECLARED, because it was never
an alias — it is a literal value the design system has no token for. Marked
in place as a recorded gap: promote it to an --fs-* token when a second app
needs it, don't copy the line.
Nothing is lost mode-wise: the aliases' resolve-at-use-time trick (which
absorbed 48 dark-mode overrides) lives one layer down in the --fs-* tokens'
own derivations, which is why the sweep is a pure rename. Both CSS checkers
green.
Why now rather than never: check_snippets_against_design_system reports every
--color-* reference as "unknown — renders as NOTHING", and nine recipe
snippets recorded from components.css carried the deprecated names, making
them prior art pointing the wrong way. With the sweep in, the checker's
report over re-recorded snippets should be EMPTY — the acceptance test that
proves the checker was right all along (#2517's correction).
Refs #2533
494 lines
14 KiB
Vue
494 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from "vue";
|
|
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
|
|
import DiffView from "@/components/DiffView.vue";
|
|
import type { DiffLine } from "@/composables/useAssist";
|
|
|
|
interface NoteVersion {
|
|
id: number;
|
|
note_id: number;
|
|
title: string;
|
|
tags: string[];
|
|
body?: string;
|
|
pin_kind: "auto" | "manual" | null;
|
|
pin_label: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
const props = defineProps<{
|
|
noteId: number;
|
|
currentBody: string;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'restore', body: string, tags: string[]): void;
|
|
(e: 'close'): void;
|
|
}>();
|
|
|
|
const versions = ref<NoteVersion[]>([]);
|
|
const selectedVersion = ref<NoteVersion | null>(null);
|
|
const loading = ref(false);
|
|
const loadingDetail = ref(false);
|
|
|
|
const diff = computed<DiffLine[]>(() => {
|
|
if (!selectedVersion.value?.body) return [];
|
|
const a = props.currentBody;
|
|
const b = selectedVersion.value.body;
|
|
|
|
const aLines = a.split('\n');
|
|
const bLines = b.split('\n');
|
|
const m = aLines.length, n = bLines.length;
|
|
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
for (let i = m - 1; i >= 0; i--)
|
|
for (let j = n - 1; j >= 0; j--)
|
|
dp[i][j] = aLines[i] === bLines[j]
|
|
? dp[i+1][j+1] + 1
|
|
: Math.max(dp[i+1][j], dp[i][j+1]);
|
|
const result: DiffLine[] = [];
|
|
let i = 0, j = 0;
|
|
while (i < m && j < n) {
|
|
if (aLines[i] === bLines[j]) { result.push({ type: 'equal', text: aLines[i++] }); j++; }
|
|
else if (dp[i+1][j] >= dp[i][j+1]) result.push({ type: 'delete', text: aLines[i++] });
|
|
else result.push({ type: 'insert', text: bLines[j++] });
|
|
}
|
|
while (i < m) result.push({ type: 'delete', text: aLines[i++] });
|
|
while (j < n) result.push({ type: 'insert', text: bLines[j++] });
|
|
return result;
|
|
});
|
|
|
|
function formatDate(iso: string): string {
|
|
const d = new Date(iso);
|
|
return d.toLocaleString(undefined, {
|
|
month: 'short', day: 'numeric', year: 'numeric',
|
|
hour: '2-digit', minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
async function loadVersions() {
|
|
loading.value = true;
|
|
try {
|
|
const data = await apiGet<{ versions: NoteVersion[] }>(
|
|
`/api/notes/${props.noteId}/versions`
|
|
);
|
|
versions.value = data.versions;
|
|
if (versions.value.length > 0) {
|
|
await selectVersionItem(versions.value[0]);
|
|
}
|
|
} catch {
|
|
// silent
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function selectVersionItem(v: NoteVersion) {
|
|
if (v.body !== undefined) {
|
|
selectedVersion.value = v;
|
|
return;
|
|
}
|
|
loadingDetail.value = true;
|
|
try {
|
|
const full = await apiGet<NoteVersion>(
|
|
`/api/notes/${props.noteId}/versions/${v.id}`
|
|
);
|
|
// Cache body back onto list item
|
|
const listItem = versions.value.find(x => x.id === v.id);
|
|
if (listItem) listItem.body = full.body;
|
|
selectedVersion.value = full;
|
|
} catch {
|
|
// silent
|
|
} finally {
|
|
loadingDetail.value = false;
|
|
}
|
|
}
|
|
|
|
function restore() {
|
|
if (!selectedVersion.value?.body) return;
|
|
emit('restore', selectedVersion.value.body, selectedVersion.value.tags ?? []);
|
|
}
|
|
|
|
// ── Pin / unpin / edit-label state ──────────────────────────────────────────
|
|
|
|
const editingLabel = ref(false);
|
|
const draftLabel = ref("");
|
|
const pinSaving = ref(false);
|
|
|
|
function startEditLabel() {
|
|
if (!selectedVersion.value) return;
|
|
draftLabel.value = selectedVersion.value.pin_label ?? "";
|
|
editingLabel.value = true;
|
|
}
|
|
|
|
function cancelEditLabel() {
|
|
editingLabel.value = false;
|
|
draftLabel.value = "";
|
|
}
|
|
|
|
async function savePin() {
|
|
if (!selectedVersion.value || pinSaving.value) return;
|
|
pinSaving.value = true;
|
|
try {
|
|
const updated = await pinNoteVersion(
|
|
props.noteId, selectedVersion.value.id, draftLabel.value || null,
|
|
);
|
|
// Reflect server state in the local list + selected version.
|
|
const idx = versions.value.findIndex(v => v.id === updated.id);
|
|
if (idx >= 0) {
|
|
versions.value[idx].pin_kind = updated.pin_kind;
|
|
versions.value[idx].pin_label = updated.pin_label;
|
|
}
|
|
if (selectedVersion.value) {
|
|
selectedVersion.value.pin_kind = updated.pin_kind;
|
|
selectedVersion.value.pin_label = updated.pin_label;
|
|
}
|
|
editingLabel.value = false;
|
|
} catch {
|
|
// silent — leaving the form open lets the user retry
|
|
} finally {
|
|
pinSaving.value = false;
|
|
}
|
|
}
|
|
|
|
async function unpinSelected() {
|
|
if (!selectedVersion.value || pinSaving.value) return;
|
|
pinSaving.value = true;
|
|
try {
|
|
await unpinNoteVersion(props.noteId, selectedVersion.value.id);
|
|
const idx = versions.value.findIndex(v => v.id === selectedVersion.value!.id);
|
|
if (idx >= 0) {
|
|
versions.value[idx].pin_kind = null;
|
|
versions.value[idx].pin_label = null;
|
|
}
|
|
if (selectedVersion.value) {
|
|
selectedVersion.value.pin_kind = null;
|
|
selectedVersion.value.pin_label = null;
|
|
}
|
|
} catch {
|
|
// silent
|
|
} finally {
|
|
pinSaving.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(loadVersions);
|
|
</script>
|
|
|
|
<template>
|
|
<teleport to="body">
|
|
<div class="modal-overlay" @click.self="emit('close')">
|
|
<div class="modal-card history-modal">
|
|
<div class="history-header">
|
|
<h3 class="history-title">Version History</h3>
|
|
<button class="history-close" aria-label="Close version history" @click="emit('close')">×</button>
|
|
</div>
|
|
|
|
<div class="history-body">
|
|
<!-- Left: version list -->
|
|
<div class="history-list">
|
|
<div v-if="loading" class="history-empty">Loading...</div>
|
|
<div v-else-if="!versions.length" class="history-empty">No versions yet.</div>
|
|
<div
|
|
v-for="v in versions"
|
|
:key="v.id"
|
|
:class="['history-item', { selected: selectedVersion?.id === v.id }]"
|
|
@click="selectVersionItem(v)"
|
|
>
|
|
<div class="history-item-title">
|
|
<span
|
|
v-if="v.pin_kind === 'manual'"
|
|
class="pin-badge pin-badge-manual"
|
|
:title="v.pin_label || 'Pinned'"
|
|
aria-label="manually pinned"
|
|
>●</span>
|
|
<span
|
|
v-else-if="v.pin_kind === 'auto'"
|
|
class="pin-badge pin-badge-auto"
|
|
:title="v.pin_label || 'Auto-pinned (stable)'"
|
|
aria-label="auto-pinned"
|
|
>◐</span>
|
|
{{ v.title || 'Untitled' }}
|
|
</div>
|
|
<div
|
|
v-if="v.pin_kind === 'manual' && v.pin_label"
|
|
class="history-item-label"
|
|
>{{ v.pin_label }}</div>
|
|
<div class="history-item-date">{{ formatDate(v.created_at) }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right: diff -->
|
|
<div class="history-diff">
|
|
<div v-if="loadingDetail" class="history-empty">Loading version...</div>
|
|
<div v-else-if="!selectedVersion" class="history-empty">Select a version to compare.</div>
|
|
<template v-else>
|
|
<div class="version-pin-controls">
|
|
<!-- Label editor -->
|
|
<div v-if="editingLabel" class="pin-label-form">
|
|
<input
|
|
v-model="draftLabel"
|
|
maxlength="500"
|
|
type="text"
|
|
class="pin-label-input"
|
|
placeholder="Optional label (e.g. 'post-network-refresh runbook')"
|
|
/>
|
|
<button class="btn-pin-save" :disabled="pinSaving" @click="savePin">
|
|
{{ pinSaving ? "Saving…" : "Save" }}
|
|
</button>
|
|
<button class="btn-pin-cancel" @click="cancelEditLabel">Cancel</button>
|
|
</div>
|
|
|
|
<!-- Default: kind-aware action row -->
|
|
<div v-else class="pin-actions">
|
|
<span v-if="selectedVersion.pin_kind === 'manual'" class="pin-state">
|
|
<span class="pin-badge pin-badge-manual">●</span>
|
|
Manually pinned{{ selectedVersion.pin_label ? `: ${selectedVersion.pin_label}` : '' }}
|
|
</span>
|
|
<span v-else-if="selectedVersion.pin_kind === 'auto'" class="pin-state">
|
|
<span class="pin-badge pin-badge-auto">◐</span>
|
|
Auto-pinned{{ selectedVersion.pin_label ? `: ${selectedVersion.pin_label}` : '' }}
|
|
</span>
|
|
|
|
<button
|
|
v-if="selectedVersion.pin_kind === null"
|
|
class="btn-pin"
|
|
:disabled="pinSaving"
|
|
@click="startEditLabel"
|
|
>Pin version</button>
|
|
<button
|
|
v-else-if="selectedVersion.pin_kind === 'manual'"
|
|
class="btn-pin-edit"
|
|
:disabled="pinSaving"
|
|
@click="startEditLabel"
|
|
>Edit label</button>
|
|
<button
|
|
v-else
|
|
class="btn-pin"
|
|
:disabled="pinSaving"
|
|
@click="startEditLabel"
|
|
>Pin permanently</button>
|
|
<button
|
|
v-if="selectedVersion.pin_kind === 'manual'"
|
|
class="btn-unpin"
|
|
:disabled="pinSaving"
|
|
@click="unpinSelected"
|
|
>Unpin</button>
|
|
</div>
|
|
</div>
|
|
<DiffView :diff="diff" />
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="history-footer">
|
|
<button
|
|
class="btn-primary"
|
|
:disabled="!selectedVersion?.body"
|
|
@click="restore"
|
|
>Restore this version</button>
|
|
<button class="modal-btn" @click="emit('close')">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.history-modal {
|
|
max-width: 900px;
|
|
width: 95%;
|
|
height: 80vh;
|
|
max-height: 700px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0;
|
|
}
|
|
|
|
.history-header {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.9rem 1.25rem;
|
|
border-bottom: 1px solid var(--fs-border-color);
|
|
}
|
|
|
|
.history-title {
|
|
margin: 0;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.history-close {
|
|
background: none;
|
|
border: none;
|
|
font-size: 1.25rem;
|
|
cursor: pointer;
|
|
color: var(--fs-text-tertiary);
|
|
line-height: 1;
|
|
padding: 0.1rem 0.3rem;
|
|
}
|
|
.history-close:hover { color: var(--fs-text-primary); }
|
|
|
|
.history-body {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.history-list {
|
|
width: 220px;
|
|
flex-shrink: 0;
|
|
border-right: 1px solid var(--fs-border-color);
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.history-item {
|
|
padding: 0.6rem 0.9rem;
|
|
cursor: pointer;
|
|
border-left: 3px solid transparent;
|
|
border-bottom: 1px solid var(--fs-border-color);
|
|
}
|
|
.history-item:hover { background: var(--fs-surface-raised); }
|
|
.history-item.selected {
|
|
border-left-color: var(--fs-accent);
|
|
background: var(--fs-surface-raised);
|
|
}
|
|
|
|
.history-item-title {
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
color: var(--fs-text-primary);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.history-item-date {
|
|
font-size: 0.75rem;
|
|
color: var(--fs-text-tertiary);
|
|
margin-top: 0.15rem;
|
|
}
|
|
|
|
.history-diff {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.history-empty {
|
|
padding: 1rem;
|
|
font-size: 0.85rem;
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
|
|
.history-footer {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
justify-content: flex-end;
|
|
padding: 0.75rem 1.25rem;
|
|
border-top: 1px solid var(--fs-border-color);
|
|
}
|
|
|
|
|
|
/* ── Pin badges + label rendering ───────────────────────────────────────── */
|
|
.pin-badge {
|
|
display: inline-block;
|
|
width: 0.7rem;
|
|
text-align: center;
|
|
margin-right: 0.35rem;
|
|
font-size: 0.85em;
|
|
line-height: 1;
|
|
}
|
|
.pin-badge-manual { color: var(--fs-accent); }
|
|
.pin-badge-auto { color: var(--fs-text-tertiary); }
|
|
|
|
.history-item-label {
|
|
font-size: 0.72rem;
|
|
color: var(--fs-accent);
|
|
font-style: italic;
|
|
margin-top: 0.15rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ── Pin controls above the diff ────────────────────────────────────────── */
|
|
.version-pin-controls {
|
|
padding: 0.4rem 0.5rem 0.5rem;
|
|
border-bottom: 1px solid var(--fs-border-color);
|
|
font-size: 0.82rem;
|
|
}
|
|
.pin-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.pin-state {
|
|
font-style: italic;
|
|
color: var(--fs-text-tertiary);
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.btn-pin, .btn-pin-edit, .btn-unpin {
|
|
padding: 0.25rem 0.7rem;
|
|
font-size: 0.78rem;
|
|
background: transparent;
|
|
color: inherit;
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: 999px;
|
|
cursor: pointer;
|
|
}
|
|
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
|
|
background: rgba(99, 102, 241, 0.12);
|
|
border-color: var(--fs-accent);
|
|
}
|
|
.btn-unpin:hover:not(:disabled) {
|
|
background: rgba(239, 68, 68, 0.10);
|
|
border-color: rgba(239, 68, 68, 0.5);
|
|
}
|
|
.pin-label-form {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
align-items: center;
|
|
}
|
|
.pin-label-input {
|
|
flex: 1;
|
|
padding: 0.3rem 0.5rem;
|
|
font-size: 0.85rem;
|
|
background: var(--fs-surface-page);
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-sm);
|
|
color: inherit;
|
|
}
|
|
.pin-label-input:focus {
|
|
outline: none;
|
|
border-color: var(--fs-accent);
|
|
}
|
|
.btn-pin-save, .btn-pin-cancel {
|
|
padding: 0.3rem 0.7rem;
|
|
font-size: 0.78rem;
|
|
background: transparent;
|
|
color: inherit;
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-sm);
|
|
cursor: pointer;
|
|
}
|
|
.btn-pin-save:hover:not(:disabled) {
|
|
background: rgba(99, 102, 241, 0.12);
|
|
border-color: var(--fs-accent);
|
|
}
|
|
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
|
|
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
|
|
opacity: 0.5;
|
|
cursor: progress;
|
|
}
|
|
</style>
|