Add history-ui workspace and supporting server endpoints
Some checks failed
Check / build (pull_request) Has been cancelled
E2E tests / build (pull_request) Has been cancelled
Publish CLI / publish-docker (pull_request) Has been cancelled
Publish server Docker image / publish-docker (pull_request) Has been cancelled

Splits history-ui out of asch/fix-everything into its own branch off
main. Includes the Svelte workspace, the three dedicated server
endpoints (list_vaults, fetch_vault_history, fetch_document_versions),
SPA asset embedding via rust-embed, and the matching TS mirror types in
sync-client.

Note: this branch will not compile against main on its own — the new
endpoints depend on database/error/config additions that live on
asch/fix-everything. Intended to be merged once asch/fix-everything
lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andras Schmelczer 2026-05-09 11:37:22 +01:00
parent 40fbd42b92
commit 4d4ffa160a
53 changed files with 4174 additions and 20 deletions

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VaultLink2</title>
<link rel="icon" href="data:," />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -0,0 +1,16 @@
{
"name": "history-ui",
"version": "0.14.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --host 0.0.0.0",
"build": "vite build",
"test": "echo 'no tests yet'"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.0.0",
"vite": "^6.0.0"
}
}

View file

@ -0,0 +1,78 @@
<script lang="ts">
import { auth, nav, toasts } from "./lib/stores.svelte";
import { listVaults } from "./lib/api";
import Login from "./components/Login.svelte";
import VaultPicker from "./components/VaultPicker.svelte";
import Dashboard from "./components/Dashboard.svelte";
import ToastContainer from "./components/ToastContainer.svelte";
let restoring = $state(true);
$effect(() => {
const saved = auth.tryRestore();
if (!saved) {
restoring = false;
return;
}
listVaults(saved.token)
.then((response) => {
auth.authenticate(
saved.token,
response.userName,
response.vaults
);
if (
saved.vaultId &&
response.vaults.some(
(v) => v.name === saved.vaultId
)
) {
auth.selectVault(saved.vaultId);
}
restoring = false;
})
.catch(() => {
restoring = false;
});
});
</script>
{#if restoring}
<div class="loading-screen">
<div class="spinner"></div>
</div>
{:else if !auth.token}
<Login />
{:else if !auth.isAuthenticated}
<VaultPicker />
{:else}
<Dashboard
selectedDocumentId={nav.current.kind === "document" ? nav.current.documentId : undefined}
/>
{/if}
<ToastContainer />
<style>
.loading-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--bg-tertiary);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,101 @@
:root {
--bg: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--bg-hover: #30363d;
--border: #30363d;
--border-light: #21262d;
--text: #e6edf3;
--text-muted: #8b949e;
--text-subtle: #6e7681;
--accent: #58a6ff;
--accent-hover: #79c0ff;
--green: #3fb950;
--green-bg: rgba(63, 185, 80, 0.15);
--red: #f85149;
--red-bg: rgba(248, 81, 73, 0.15);
--orange: #d29922;
--orange-bg: rgba(210, 153, 34, 0.15);
--purple: #bc8cff;
--purple-bg: rgba(188, 140, 255, 0.15);
--blue: #58a6ff;
--blue-bg: rgba(88, 166, 255, 0.15);
--mono: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Noto Sans, Helvetica, Arial, sans-serif;
--radius: 6px;
--radius-sm: 4px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
button {
font-family: inherit;
font-size: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
input {
font-family: inherit;
font-size: inherit;
color: inherit;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 12px;
outline: none;
transition: border-color 0.15s;
}
input:focus {
border-color: var(--accent);
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--bg-hover);
}

View file

@ -0,0 +1,346 @@
<script lang="ts">
import type { VersionEvent } from "../lib/view-types";
import {
absoluteTime,
formatBytes
} from "../lib/stores.svelte";
interface Props {
versions: VersionEvent[];
loading: boolean;
hasMore: boolean;
onLoadMore: () => void;
onSelectDocument: (documentId: string) => void;
onTimeTravel: (vaultUpdateId: number) => void;
}
let {
versions,
loading,
hasMore,
onLoadMore,
onSelectDocument,
onTimeTravel
}: Props = $props();
function timeOfDay(dateStr: string): string {
return new Date(dateStr).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit"
});
}
// Group by day
let grouped = $derived.by(() => {
const groups: { date: string; items: VersionEvent[] }[] = [];
const sortedDesc = [...versions].sort(
(a, b) => b.vaultUpdateId - a.vaultUpdateId
);
for (const v of sortedDesc) {
const date = new Date(v.updatedDate).toLocaleDateString(
"en-US",
{ month: "long", day: "numeric", year: "numeric" }
);
const last = groups.at(-1);
if (last && last.date === date) {
last.items.push(v);
} else {
groups.push({ date, items: [v] });
}
}
return groups;
});
const actionColors: Record<string, string> = {
created: "var(--green)",
updated: "var(--blue)",
renamed: "var(--orange)",
deleted: "var(--red)",
restored: "var(--purple)"
};
const actionBgColors: Record<string, string> = {
created: "var(--green-bg)",
updated: "var(--blue-bg)",
renamed: "var(--orange-bg)",
deleted: "var(--red-bg)",
restored: "var(--purple-bg)"
};
</script>
<div class="feed">
{#if loading && versions.length === 0}
<div class="feed-loading">Loading activity...</div>
{:else if versions.length === 0}
<div class="feed-empty">
No activity yet. Documents will appear here as sync clients
make changes.
</div>
{:else}
{#each grouped as group}
<div class="day-group">
<div class="day-header">{group.date}</div>
<div class="items-list">
{#each group.items as event}
<div class="feed-item">
<button
class="feed-item-main"
onclick={() =>
onSelectDocument(event.documentId)}
>
<div class="feed-timeline">
<div
class="timeline-dot"
style="background: {actionColors[
event.action
]}"
></div>
</div>
<div class="feed-content">
<div class="feed-header">
<span
class="action-pill"
style="color: {actionColors[
event.action
]}; background: {actionBgColors[
event.action
]}"
>
{event.action}
</span>
<span class="feed-path">
{#if event.action === "renamed" && event.previousPath}
<span class="prev-path"
>{event.previousPath}</span
>
<span class="arrow"
>&rarr;</span
>
{/if}
<span
class:deleted={event.action ===
"deleted"}
>
{event.relativePath}
</span>
</span>
</div>
<div class="feed-meta">
<span class="feed-user"
>{event.userId}</span
>
<span class="feed-dot"
>&middot;</span
>
<span class="feed-size"
>{formatBytes(
event.contentSize
)}</span
>
</div>
</div>
</button>
<button
class="feed-time-btn"
title="Time travel to {absoluteTime(event.updatedDate)}"
onclick={(e) => {
e.stopPropagation();
onTimeTravel(event.vaultUpdateId);
}}
>
{timeOfDay(event.updatedDate)}
</button>
</div>
{/each}
</div>
</div>
{/each}
{#if hasMore}
<div class="load-more">
<button class="load-more-btn" onclick={onLoadMore}>
Load older activity
</button>
</div>
{/if}
{/if}
</div>
<style>
.feed {
flex: 1;
overflow-y: auto;
padding: 0 0 16px;
}
.feed-loading,
.feed-empty {
padding: 48px 16px;
text-align: center;
color: var(--text-muted);
}
.day-group {
margin-bottom: 8px;
}
.day-header {
position: sticky;
top: 0;
z-index: 1;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
background: var(--bg);
border-bottom: 1px solid var(--border-light);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.feed-item {
display: flex;
align-items: stretch;
width: 100%;
transition: background 0.1s;
}
.feed-item:hover {
background: var(--bg-hover);
}
.feed-item-main {
display: flex;
gap: 12px;
flex: 1;
min-width: 0;
padding: 10px 0 10px 16px;
text-align: left;
}
.items-list {
position: relative;
}
.items-list::before {
content: "";
position: absolute;
left: 21px;
top: 0;
bottom: 0;
width: 2px;
background: var(--border);
}
.feed-timeline {
display: flex;
flex-direction: column;
align-items: center;
width: 12px;
flex-shrink: 0;
padding-top: 6px;
position: relative;
}
.timeline-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.feed-content {
flex: 1;
min-width: 0;
}
.feed-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.action-pill {
font-size: 11px;
font-weight: 600;
padding: 1px 8px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.3px;
flex-shrink: 0;
}
.feed-path {
font-family: var(--mono);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.prev-path {
color: var(--text-muted);
text-decoration: line-through;
}
.arrow {
color: var(--text-subtle);
margin: 0 4px;
}
.deleted {
text-decoration: line-through;
opacity: 0.6;
}
.feed-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
font-size: 12px;
color: var(--text-muted);
}
.feed-dot {
color: var(--text-subtle);
}
.feed-time-btn {
display: flex;
align-items: center;
padding: 0 16px;
font-size: 12px;
font-family: var(--mono);
color: var(--text-muted);
white-space: nowrap;
flex-shrink: 0;
border-left: 1px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
.feed-time-btn:hover {
color: var(--accent);
border-left-color: var(--border-light);
}
.load-more {
padding: 16px;
text-align: center;
}
.load-more-btn {
padding: 8px 20px;
font-size: 13px;
color: var(--accent);
border: 1px solid var(--border);
border-radius: var(--radius);
transition: background 0.15s;
}
.load-more-btn:hover {
background: var(--bg-hover);
}
</style>

View file

@ -0,0 +1,167 @@
<script lang="ts">
interface Props {
title: string;
message: string;
confirmLabel: string;
destructive?: boolean;
loading?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
let {
title,
message,
confirmLabel,
destructive = false,
loading = false,
onConfirm,
onCancel
}: Props = $props();
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") onCancel();
}
</script>
<svelte:window on:keydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="backdrop" onclick={onCancel} role="presentation">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div
class="dialog"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-label={title}
>
<h3 class="dialog-title">{title}</h3>
<p class="dialog-message">{message}</p>
<div class="dialog-actions">
<button class="btn-cancel" onclick={onCancel} disabled={loading}>
Cancel
</button>
<button
class="btn-confirm"
class:destructive
onclick={onConfirm}
disabled={loading}
>
{#if loading}
<span class="btn-spinner"></span>
{/if}
{confirmLabel}
</button>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
animation: fade-in 0.15s ease-out;
}
.dialog {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
max-width: 480px;
width: calc(100% - 32px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
animation: scale-in 0.2s ease-out;
}
.dialog-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
}
.dialog-message {
font-size: 14px;
color: var(--text-muted);
line-height: 1.5;
margin-bottom: 24px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.btn-cancel {
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
transition: background 0.15s;
}
.btn-cancel:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text);
}
.btn-confirm {
padding: 8px 16px;
background: var(--accent);
color: #fff;
font-weight: 600;
border-radius: var(--radius);
transition: background 0.15s;
display: flex;
align-items: center;
gap: 6px;
}
.btn-confirm:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-confirm.destructive {
background: var(--red);
}
.btn-confirm.destructive:hover:not(:disabled) {
background: #f97583;
}
.btn-confirm:disabled,
.btn-cancel:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scale-in {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View file

@ -0,0 +1,508 @@
<script lang="ts">
import {
auth,
nav,
toasts,
buildTree,
enrichVersions,
relativeTime,
formatBytes,
type View
} from "../lib/stores.svelte";
import type { DocumentVersionWithoutContent } from "../lib/types/DocumentVersionWithoutContent";
import type { VaultHistoryResponse } from "../lib/types/VaultHistoryResponse";
import type { VersionEvent, TreeNode } from "../lib/view-types";
import FileTree from "./FileTree.svelte";
import ActivityFeed from "./ActivityFeed.svelte";
import DocumentDetail from "./DocumentDetail.svelte";
import TimeSlider from "./TimeSlider.svelte";
import Header from "./Header.svelte";
interface Props {
selectedDocumentId?: string;
}
let { selectedDocumentId }: Props = $props();
// Data
let latestDocuments = $state<DocumentVersionWithoutContent[]>([]);
let historyVersions = $state<DocumentVersionWithoutContent[]>([]);
let historyHasMore = $state(false);
let loadingDocs = $state(true);
let loadingHistory = $state(true);
let showDeleted = $state(false);
let searchQuery = $state("");
let activeTab = $state<"activity" | "files">("activity");
// Time travel
let maxUpdateId = $state(0);
let minUpdateId = $state(0);
let timeSliderValue = $state<number | null>(null);
// Derived
let tree = $derived(buildTree(latestDocuments, showDeleted));
let enrichedHistory = $derived(enrichVersions(historyVersions));
let stats = $derived({
totalDocs: latestDocuments.filter((d) => !d.isDeleted).length,
deletedDocs: latestDocuments.filter((d) => d.isDeleted).length,
totalSize: latestDocuments
.filter((d) => !d.isDeleted)
.reduce((sum, d) => sum + d.contentSize, 0),
users: [...new Set(latestDocuments.map((d) => d.userId))]
});
let filteredTree = $derived.by(() => {
if (!searchQuery) return tree;
return filterTree(tree, searchQuery.toLowerCase());
});
function filterTree(node: TreeNode, query: string): TreeNode {
if (!node.isFolder) {
return node.name.toLowerCase().includes(query) ? node : { ...node, children: [] };
}
const filteredChildren = node.children
.map((c) => filterTree(c, query))
.filter((c) => c.isFolder ? c.children.length > 0 : true)
.filter((c) => !c.isFolder || c.children.length > 0);
return { ...node, children: filteredChildren };
}
// Time travel: compute vault state at a given updateId
let timeFilteredDocs = $derived.by(() => {
if (timeSliderValue === null || timeSliderValue >= maxUpdateId) {
return latestDocuments;
}
// From all history, find the latest version per documentId at or before timeSliderValue
const byDoc = new Map<string, DocumentVersionWithoutContent>();
for (const v of historyVersions) {
if (v.vaultUpdateId <= timeSliderValue) {
const existing = byDoc.get(v.documentId);
if (
!existing ||
v.vaultUpdateId > existing.vaultUpdateId
) {
byDoc.set(v.documentId, v);
}
}
}
return [...byDoc.values()];
});
let timeFilteredTree = $derived(
buildTree(
timeSliderValue !== null && timeSliderValue < maxUpdateId
? timeFilteredDocs
: latestDocuments,
showDeleted
)
);
let displayTree = $derived(
searchQuery ? filteredTree : timeFilteredTree
);
// Load data
async function loadData() {
const api = auth.api;
if (!api) return;
loadingDocs = true;
loadingHistory = true;
api.ping().then((ping) => {
auth.serverVersion = ping.serverVersion;
});
try {
const response = await api.fetchLatestDocuments();
latestDocuments = response.latestDocuments;
maxUpdateId = Number(response.lastUpdateId);
} catch (e) {
toasts.add("Failed to load documents", "error");
} finally {
loadingDocs = false;
}
try {
const response = await api.fetchVaultHistory(500);
historyVersions = response.versions;
historyHasMore = response.hasMore;
if (historyVersions.length > 0) {
minUpdateId = Math.min(
...historyVersions.map((v) => v.vaultUpdateId)
);
maxUpdateId = Math.max(
maxUpdateId,
Math.max(
...historyVersions.map((v) => v.vaultUpdateId)
)
);
}
} catch (e) {
toasts.add("Failed to load history", "error");
} finally {
loadingHistory = false;
}
}
async function loadMoreHistory() {
const api = auth.api;
if (!api || !historyHasMore) return;
const oldest = Math.min(
...historyVersions.map((v) => v.vaultUpdateId)
);
try {
const response = await api.fetchVaultHistory(500, oldest);
historyVersions = [...historyVersions, ...response.versions];
historyHasMore = response.hasMore;
minUpdateId = Math.min(
minUpdateId,
...response.versions.map((v) => v.vaultUpdateId)
);
} catch {
toasts.add("Failed to load more history", "error");
}
}
function selectDocument(documentId: string) {
nav.goto({ kind: "document", documentId });
}
function handleRefresh() {
loadData();
}
$effect(() => {
if (auth.isAuthenticated) {
loadData();
}
});
</script>
<div class="dashboard">
<Header
vaultId={auth.vaultId}
serverVersion={auth.serverVersion}
onRefresh={handleRefresh}
/>
<div class="main-layout">
<!-- Sidebar -->
<aside class="sidebar">
{#if !loadingDocs}
<div class="sidebar-stats">
<div class="stat">
<span class="stat-value">{stats.totalDocs}</span>
<span class="stat-label">files</span>
</div>
<div class="stat">
<span class="stat-value"
>{formatBytes(stats.totalSize)}</span
>
<span class="stat-label">total</span>
</div>
<div class="stat">
<span class="stat-value">{stats.users.length}</span>
<span class="stat-label"
>user{stats.users.length !== 1 ? "s" : ""}</span
>
</div>
</div>
{/if}
<div class="sidebar-search">
<input
type="text"
placeholder="Filter files..."
bind:value={searchQuery}
/>
</div>
<div class="sidebar-controls">
<label class="toggle-label">
<input
type="checkbox"
bind:checked={showDeleted}
/>
Show deleted
</label>
</div>
<div class="sidebar-tree">
{#if loadingDocs}
<div class="loading-placeholder">Loading...</div>
{:else}
<FileTree
node={displayTree}
selectedId={selectedDocumentId ?? null}
onSelect={selectDocument}
/>
{/if}
</div>
</aside>
<!-- Main content -->
<main class="content">
{#if maxUpdateId > 0}
<div class="time-slider-container">
<TimeSlider
min={minUpdateId}
max={maxUpdateId}
value={timeSliderValue}
versions={historyVersions}
onchange={(v) => {
timeSliderValue = v;
}}
/>
</div>
{/if}
{#if selectedDocumentId}
<DocumentDetail
documentId={selectedDocumentId}
onClose={() => nav.goHome()}
onRestore={handleRefresh}
/>
{:else}
<div class="tabs">
<button
class="tab"
class:active={activeTab === "activity"}
onclick={() => (activeTab = "activity")}
>
Activity
</button>
<button
class="tab"
class:active={activeTab === "files"}
onclick={() => (activeTab = "files")}
>
Files
</button>
</div>
{#if activeTab === "activity"}
<ActivityFeed
versions={enrichedHistory}
loading={loadingHistory}
hasMore={historyHasMore}
onLoadMore={loadMoreHistory}
onSelectDocument={selectDocument}
onTimeTravel={(id) => {
timeSliderValue = id >= maxUpdateId ? null : id;
}}
/>
{:else}
<div class="file-list">
{#each latestDocuments
.filter((d) => showDeleted || !d.isDeleted)
.sort((a, b) => b.vaultUpdateId - a.vaultUpdateId) as doc}
<button
class="file-row"
class:deleted={doc.isDeleted}
onclick={() =>
selectDocument(doc.documentId)}
>
<span class="file-icon"
>{doc.isDeleted
? "🗑"
: "📄"}</span
>
<span class="file-path"
>{doc.relativePath}</span
>
<span class="file-meta">
{formatBytes(doc.contentSize)}
&middot;
{doc.userId}
&middot;
{relativeTime(doc.updatedDate)}
</span>
</button>
{/each}
</div>
{/if}
{/if}
</main>
</div>
</div>
<style>
.dashboard {
display: flex;
flex-direction: column;
height: 100%;
}
.main-layout {
display: flex;
flex: 1;
overflow: hidden;
}
.sidebar {
width: 280px;
min-width: 280px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
background: var(--bg-secondary);
overflow: hidden;
}
.sidebar-stats {
display: flex;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.stat-value {
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.stat-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sidebar-search {
padding: 8px 12px;
}
.sidebar-search input {
width: 100%;
font-size: 13px;
padding: 6px 10px;
}
.sidebar-controls {
padding: 4px 16px 8px;
}
.toggle-label {
font-size: 12px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.toggle-label input[type="checkbox"] {
width: auto;
accent-color: var(--accent);
}
.sidebar-tree {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.loading-placeholder {
padding: 16px;
color: var(--text-muted);
text-align: center;
font-size: 13px;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.time-slider-container {
padding: 8px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
}
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
padding: 0 16px;
}
.tab {
padding: 10px 16px;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.file-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.file-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 16px;
text-align: left;
transition: background 0.1s;
}
.file-row:hover {
background: var(--bg-hover);
}
.file-row.deleted {
opacity: 0.5;
}
.file-row.deleted .file-path {
text-decoration: line-through;
}
.file-icon {
font-size: 16px;
flex-shrink: 0;
}
.file-path {
font-family: var(--mono);
font-size: 13px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,288 @@
<script lang="ts">
interface Props {
oldContent: string;
newContent: string;
oldLabel: string;
newLabel: string;
}
let { oldContent, newContent, oldLabel, newLabel }: Props = $props();
interface DiffLine {
type: "add" | "remove" | "context";
content: string;
oldLineNo: number | null;
newLineNo: number | null;
}
let diffLines = $derived.by((): DiffLine[] => {
const oldLines = oldContent.split("\n");
const newLines = newContent.split("\n");
// Simple line-by-line diff using LCS
const lines: DiffLine[] = [];
const lcs = computeLCS(oldLines, newLines);
let oi = 0;
let ni = 0;
let oldLineNo = 1;
let newLineNo = 1;
for (const match of lcs) {
// Remove lines before match
while (oi < match.oldIndex) {
lines.push({
type: "remove",
content: oldLines[oi],
oldLineNo: oldLineNo++,
newLineNo: null
});
oi++;
}
// Add lines before match
while (ni < match.newIndex) {
lines.push({
type: "add",
content: newLines[ni],
oldLineNo: null,
newLineNo: newLineNo++
});
ni++;
}
// Context line
lines.push({
type: "context",
content: oldLines[oi],
oldLineNo: oldLineNo++,
newLineNo: newLineNo++
});
oi++;
ni++;
}
// Remaining removes
while (oi < oldLines.length) {
lines.push({
type: "remove",
content: oldLines[oi],
oldLineNo: oldLineNo++,
newLineNo: null
});
oi++;
}
// Remaining adds
while (ni < newLines.length) {
lines.push({
type: "add",
content: newLines[ni],
oldLineNo: null,
newLineNo: newLineNo++
});
ni++;
}
return lines;
});
let stats = $derived({
added: diffLines.filter((l) => l.type === "add").length,
removed: diffLines.filter((l) => l.type === "remove").length
});
interface LCSMatch {
oldIndex: number;
newIndex: number;
}
function computeLCS(a: string[], b: string[]): LCSMatch[] {
const m = a.length;
const n = b.length;
// For large files, use a simpler approach
if (m * n > 1_000_000) {
return simpleDiff(a, b);
}
const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array(n + 1).fill(0)
);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// Backtrack
const matches: LCSMatch[] = [];
let i = m;
let j = n;
while (i > 0 && j > 0) {
if (a[i - 1] === b[j - 1]) {
matches.unshift({ oldIndex: i - 1, newIndex: j - 1 });
i--;
j--;
} else if (dp[i - 1][j] > dp[i][j - 1]) {
i--;
} else {
j--;
}
}
return matches;
}
function simpleDiff(a: string[], b: string[]): LCSMatch[] {
// Hash-based matching for large files
const bMap = new Map<string, number[]>();
for (let j = 0; j < b.length; j++) {
const arr = bMap.get(b[j]);
if (arr) arr.push(j);
else bMap.set(b[j], [j]);
}
const matches: LCSMatch[] = [];
let lastJ = -1;
for (let i = 0; i < a.length; i++) {
const candidates = bMap.get(a[i]);
if (!candidates) continue;
for (const j of candidates) {
if (j > lastJ) {
matches.push({ oldIndex: i, newIndex: j });
lastJ = j;
break;
}
}
}
return matches;
}
</script>
<div class="diff-view">
<div class="diff-header">
<span class="diff-label">{oldLabel}</span>
<span class="diff-arrow">&rarr;</span>
<span class="diff-label">{newLabel}</span>
<span class="diff-stats">
<span class="diff-added">+{stats.added}</span>
<span class="diff-removed">-{stats.removed}</span>
</span>
</div>
<div class="diff-content">
{#each diffLines as line}
<div class="diff-line {line.type}">
<span class="line-no old-no">
{line.oldLineNo ?? ""}
</span>
<span class="line-no new-no">
{line.newLineNo ?? ""}
</span>
<span class="line-marker">
{#if line.type === "add"}+{:else if line.type === "remove"}-{:else}&nbsp;{/if}
</span>
<span class="line-content">{line.content}</span>
</div>
{/each}
</div>
</div>
<style>
.diff-view {
display: flex;
flex-direction: column;
height: 100%;
}
.diff-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.diff-label {
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
}
.diff-arrow {
color: var(--text-subtle);
}
.diff-stats {
margin-left: auto;
display: flex;
gap: 8px;
font-family: var(--mono);
font-size: 12px;
}
.diff-added {
color: var(--green);
}
.diff-removed {
color: var(--red);
}
.diff-content {
flex: 1;
overflow: auto;
font-family: var(--mono);
font-size: 13px;
line-height: 1.5;
}
.diff-line {
display: flex;
white-space: pre;
min-height: 20px;
}
.diff-line.add {
background: var(--green-bg);
}
.diff-line.remove {
background: var(--red-bg);
}
.line-no {
display: inline-block;
width: 48px;
text-align: right;
padding-right: 8px;
color: var(--text-subtle);
user-select: none;
flex-shrink: 0;
}
.line-marker {
display: inline-block;
width: 20px;
text-align: center;
flex-shrink: 0;
user-select: none;
}
.diff-line.add .line-marker {
color: var(--green);
}
.diff-line.remove .line-marker {
color: var(--red);
}
.line-content {
flex: 1;
padding-right: 16px;
}
</style>

View file

@ -0,0 +1,729 @@
<script lang="ts">
import {
auth,
toasts,
relativeTime,
absoluteTime,
formatBytes,
inferAction,
isTextFile,
isImageFile,
fileExtension
} from "../lib/stores.svelte";
import type { DocumentVersionWithoutContent } from "../lib/types/DocumentVersionWithoutContent";
import type { DocumentVersion } from "../lib/types/DocumentVersion";
import type { ActionType } from "../lib/view-types";
import DiffView from "./DiffView.svelte";
import ConfirmDialog from "./ConfirmDialog.svelte";
interface Props {
documentId: string;
onClose: () => void;
onRestore: () => void;
}
let { documentId, onClose, onRestore }: Props = $props();
let versions = $state<DocumentVersionWithoutContent[]>([]);
let loading = $state(true);
let selectedVersion = $state<DocumentVersionWithoutContent | null>(null);
let loadedContent = $state<string | null>(null);
let loadedContentBytes = $state<ArrayBuffer | null>(null);
let loadingContent = $state(false);
let activeTab = $state<"preview" | "diff">("preview");
// Diff state
let diffOldContent = $state<string | null>(null);
let diffNewContent = $state<string | null>(null);
let diffOldLabel = $state("");
let diffNewLabel = $state("");
// Restore state
let showRestoreDialog = $state(false);
let restoreTarget = $state<DocumentVersionWithoutContent | null>(null);
let restoring = $state(false);
let latest = $derived(versions.at(-1) ?? null);
let isDeleted = $derived(latest?.isDeleted ?? false);
let currentPath = $derived(latest?.relativePath ?? "");
// Derive action types
let versionEvents = $derived(
versions.map((v, i) => ({
version: v,
action: inferAction(v, i > 0 ? versions[i - 1] : undefined) as ActionType,
previousPath: i > 0 && versions[i - 1].relativePath !== v.relativePath
? versions[i - 1].relativePath
: undefined
}))
);
async function loadVersions() {
const api = auth.api;
if (!api) return;
loading = true;
try {
versions = await api.fetchDocumentVersions(documentId);
// Auto-select latest
if (versions.length > 0) {
await selectVersion(versions.at(-1)!);
}
} catch {
toasts.add("Failed to load document versions", "error");
} finally {
loading = false;
}
}
async function selectVersion(v: DocumentVersionWithoutContent) {
selectedVersion = v;
activeTab = "preview";
diffOldContent = null;
diffNewContent = null;
loadingContent = true;
loadedContent = null;
loadedContentBytes = null;
const api = auth.api;
if (!api) return;
try {
if (isTextFile(v.relativePath) || fileExtension(v.relativePath) === "") {
const fullVersion = await api.fetchDocumentVersion(
documentId,
v.vaultUpdateId
);
const bytes = Uint8Array.from(atob(fullVersion.contentBase64), c => c.charCodeAt(0));
const decoder = new TextDecoder("utf-8", { fatal: false });
loadedContent = decoder.decode(bytes);
loadedContentBytes = bytes.buffer;
} else if (isImageFile(v.relativePath)) {
loadedContentBytes = await api.fetchDocumentVersionContent(
documentId,
v.vaultUpdateId
);
} else {
loadedContentBytes = await api.fetchDocumentVersionContent(
documentId,
v.vaultUpdateId
);
}
} catch {
toasts.add("Failed to load content", "error");
} finally {
loadingContent = false;
}
}
async function showDiff(v: DocumentVersionWithoutContent, idx: number) {
const api = auth.api;
if (!api || idx === 0) return;
activeTab = "diff";
loadingContent = true;
const prev = versions[idx - 1];
try {
const [oldVer, newVer] = await Promise.all([
api.fetchDocumentVersion(documentId, prev.vaultUpdateId),
api.fetchDocumentVersion(documentId, v.vaultUpdateId)
]);
const decode = (b64: string) => {
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
};
diffOldContent = decode(oldVer.contentBase64);
diffNewContent = decode(newVer.contentBase64);
diffOldLabel = `v${prev.vaultUpdateId}`;
diffNewLabel = `v${v.vaultUpdateId}`;
} catch {
toasts.add("Failed to load diff", "error");
} finally {
loadingContent = false;
}
}
function confirmRestore(v: DocumentVersionWithoutContent) {
restoreTarget = v;
showRestoreDialog = true;
}
async function executeRestore() {
const api = auth.api;
if (!api || !restoreTarget || !latest) return;
restoring = true;
try {
// Restore = re-submit the target version's bytes at its path
// as if it were a fresh edit. `update_document` short-circuits
// on `is_deleted`, so resurrecting a deleted doc has to go
// through `create_document`; a live doc takes the normal
// update path with the current latest as its parent.
const bytes = await api.fetchDocumentVersionContent(
documentId,
restoreTarget.vaultUpdateId
);
if (latest.isDeleted) {
await api.createDocument(
latest.vaultUpdateId,
restoreTarget.relativePath,
bytes
);
} else {
await api.updateBinaryDocument(
documentId,
latest.vaultUpdateId,
restoreTarget.relativePath,
bytes
);
}
toasts.add(
`Restored to version #${restoreTarget.vaultUpdateId}`,
"success"
);
showRestoreDialog = false;
restoreTarget = null;
onRestore();
await loadVersions();
} catch (e) {
toasts.add(`Restore failed: ${e}`, "error");
} finally {
restoring = false;
}
}
function getImageUrl(buffer: ArrayBuffer, path: string): string {
const ext = fileExtension(path);
const mimeMap: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
ico: "image/x-icon",
bmp: "image/bmp"
};
const mime = mimeMap[ext] ?? "application/octet-stream";
const blob = new Blob([buffer], { type: mime });
return URL.createObjectURL(blob);
}
$effect(() => {
loadVersions();
});
const actionColors: Record<string, string> = {
created: "var(--green)",
updated: "var(--blue)",
renamed: "var(--orange)",
deleted: "var(--red)",
restored: "var(--purple)"
};
const actionBgColors: Record<string, string> = {
created: "var(--green-bg)",
updated: "var(--blue-bg)",
renamed: "var(--orange-bg)",
deleted: "var(--red-bg)",
restored: "var(--purple-bg)"
};
</script>
<div class="detail">
<!-- Header -->
<div class="detail-header">
<button class="back-btn" onclick={onClose} title="Back">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<div class="header-info">
<div class="header-path">
<span class="path-text" class:deleted-path={isDeleted}>
{currentPath}
</span>
{#if isDeleted}
<span class="status-badge deleted-badge">Deleted</span>
{:else}
<span class="status-badge active-badge">Active</span>
{/if}
</div>
<div class="header-meta">
<span class="doc-id" title={documentId}>
{documentId.substring(0, 8)}...
</span>
{#if latest}
<span>&middot;</span>
<span>{versions.length} version{versions.length !== 1 ? "s" : ""}</span>
<span>&middot;</span>
<span>Last by {latest.userId}</span>
{/if}
</div>
</div>
</div>
{#if loading}
<div class="detail-loading">Loading versions...</div>
{:else}
<!-- Content area -->
<div class="detail-body">
<div class="content-panel">
{#if selectedVersion}
<div class="content-tabs">
<button
class="content-tab"
class:active={activeTab === "preview"}
onclick={() => (activeTab = "preview")}
>
Preview
</button>
<button
class="content-tab"
class:active={activeTab === "diff"}
onclick={() => {
if (selectedVersion) {
const idx = versions.indexOf(selectedVersion);
if (idx > 0) showDiff(selectedVersion, idx);
}
}}
disabled={versions.indexOf(selectedVersion) === 0}
>
Diff
</button>
<div class="content-tab-spacer"></div>
<span class="viewing-label">
Viewing v#{selectedVersion.vaultUpdateId}
&middot;
{relativeTime(selectedVersion.updatedDate)}
</span>
</div>
<div class="content-view">
{#if loadingContent}
<div class="content-loading">Loading content...</div>
{:else if activeTab === "diff" && diffOldContent !== null && diffNewContent !== null}
<DiffView
oldContent={diffOldContent}
newContent={diffNewContent}
oldLabel={diffOldLabel}
newLabel={diffNewLabel}
/>
{:else if activeTab === "preview"}
{#if isTextFile(selectedVersion.relativePath) || fileExtension(selectedVersion.relativePath) === ""}
<pre class="text-content">{loadedContent ?? ""}</pre>
{:else if isImageFile(selectedVersion.relativePath) && loadedContentBytes}
<div class="image-preview">
<img
src={getImageUrl(loadedContentBytes, selectedVersion.relativePath)}
alt={selectedVersion.relativePath}
/>
</div>
{:else}
<div class="binary-placeholder">
<div class="binary-icon">📦</div>
<div class="binary-label">Binary file</div>
<div class="binary-size">
{formatBytes(selectedVersion.contentSize)}
</div>
</div>
{/if}
{/if}
</div>
{/if}
</div>
<!-- Version timeline -->
<div class="version-panel">
<div class="version-panel-header">Version History</div>
<div class="version-list">
{#each [...versionEvents].reverse() as event, i}
{@const v = event.version}
{@const isSelected = selectedVersion?.vaultUpdateId === v.vaultUpdateId}
<div class="version-item" class:selected={isSelected}>
<button
class="version-main"
onclick={() => selectVersion(v)}
>
<div class="version-left">
<span class="version-id">#{v.vaultUpdateId}</span>
<span
class="version-action"
style="color: {actionColors[event.action]}; background: {actionBgColors[event.action]}"
>
{event.action}
</span>
</div>
<div class="version-right">
<span class="version-user">{v.userId}</span>
<span
class="version-time"
title={absoluteTime(v.updatedDate)}
>
{relativeTime(v.updatedDate)}
</span>
<span class="version-size">{formatBytes(v.contentSize)}</span>
</div>
</button>
{#if event.previousPath}
<div class="version-rename">
{event.previousPath} &rarr; {v.relativePath}
</div>
{/if}
<div class="version-actions">
{#if i < versionEvents.length - 1}
<button
class="version-btn"
onclick={() => {
const realIdx = versions.indexOf(v);
showDiff(v, realIdx);
}}
>
Diff
</button>
{/if}
{#if v !== latest}
<button
class="version-btn restore-btn"
onclick={() => confirmRestore(v)}
>
Restore
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
{#if showRestoreDialog && restoreTarget}
<ConfirmDialog
title="Restore Version"
message={`Restore "${currentPath}" to version #${restoreTarget.vaultUpdateId} from ${absoluteTime(restoreTarget.updatedDate)}? This creates a new version with the old content. Current content is preserved in history.`}
confirmLabel="Restore"
destructive={false}
loading={restoring}
onConfirm={executeRestore}
onCancel={() => {
showRestoreDialog = false;
restoreTarget = null;
}}
/>
{/if}
<style>
.detail {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.detail-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
flex-shrink: 0;
}
.back-btn {
padding: 6px;
border-radius: var(--radius-sm);
color: var(--text-muted);
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
.back-btn:hover {
color: var(--text);
background: var(--bg-hover);
}
.header-info {
flex: 1;
min-width: 0;
}
.header-path {
display: flex;
align-items: center;
gap: 8px;
}
.path-text {
font-family: var(--mono);
font-size: 15px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deleted-path {
text-decoration: line-through;
opacity: 0.6;
}
.status-badge {
font-size: 10px;
font-weight: 600;
padding: 1px 8px;
border-radius: 10px;
text-transform: uppercase;
flex-shrink: 0;
}
.active-badge {
color: var(--green);
background: var(--green-bg);
}
.deleted-badge {
color: var(--red);
background: var(--red-bg);
}
.header-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
display: flex;
gap: 6px;
}
.doc-id {
font-family: var(--mono);
cursor: help;
}
.detail-loading {
padding: 48px;
text-align: center;
color: var(--text-muted);
}
.detail-body {
display: flex;
flex: 1;
overflow: hidden;
}
.content-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content-tabs {
display: flex;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid var(--border);
background: var(--bg);
flex-shrink: 0;
}
.content-tab {
padding: 8px 12px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
.content-tab:hover:not(:disabled) {
color: var(--text);
}
.content-tab.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.content-tab:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.content-tab-spacer {
flex: 1;
}
.viewing-label {
font-size: 12px;
color: var(--text-subtle);
font-family: var(--mono);
}
.content-view {
flex: 1;
overflow: auto;
}
.content-loading {
padding: 48px;
text-align: center;
color: var(--text-muted);
}
.text-content {
padding: 16px;
font-family: var(--mono);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
tab-size: 4;
}
.image-preview {
padding: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.image-preview img {
max-width: 100%;
max-height: 60vh;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.binary-placeholder {
padding: 64px;
text-align: center;
color: var(--text-muted);
}
.binary-icon {
font-size: 48px;
margin-bottom: 12px;
}
.binary-label {
font-size: 16px;
font-weight: 500;
}
.binary-size {
font-size: 14px;
margin-top: 4px;
}
/* Version panel */
.version-panel {
width: 320px;
min-width: 320px;
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg-secondary);
}
.version-panel-header {
padding: 10px 16px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.version-list {
flex: 1;
overflow-y: auto;
}
.version-item {
border-bottom: 1px solid var(--border-light);
padding: 8px 12px;
transition: background 0.1s;
}
.version-item:hover {
background: var(--bg-hover);
}
.version-item.selected {
background: var(--blue-bg);
}
.version-main {
width: 100%;
text-align: left;
}
.version-left {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.version-id {
font-family: var(--mono);
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.version-action {
font-size: 10px;
font-weight: 600;
padding: 0 6px;
border-radius: 8px;
text-transform: uppercase;
}
.version-right {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-muted);
}
.version-rename {
font-size: 11px;
color: var(--orange);
font-family: var(--mono);
margin: 4px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.version-actions {
display: flex;
gap: 8px;
margin-top: 6px;
}
.version-btn {
font-size: 11px;
color: var(--accent);
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: background 0.15s;
}
.version-btn:hover {
background: var(--bg-hover);
}
.restore-btn {
color: var(--orange);
}
</style>

View file

@ -0,0 +1,124 @@
<script lang="ts">
import type { TreeNode } from "../lib/view-types";
import FileTree from "./FileTree.svelte";
interface Props {
node: TreeNode;
selectedId: string | null;
onSelect: (documentId: string) => void;
depth?: number;
}
let { node, selectedId, onSelect, depth = 0 }: Props = $props();
let expanded = $state<Record<string, boolean>>({});
function toggle(path: string) {
expanded[path] = !expanded[path];
}
function isExpanded(path: string): boolean {
return expanded[path] ?? true;
}
</script>
{#if node.isFolder && depth === 0}
{#each node.children as child}
<FileTree
node={child}
{selectedId}
{onSelect}
depth={depth + 1}
/>
{/each}
{:else if node.isFolder}
<div class="tree-folder">
<button
class="tree-item folder"
style="padding-left: {depth * 16}px"
onclick={() => toggle(node.path)}
>
<span class="expand-icon"
>{isExpanded(node.path) ? "▾" : "▸"}</span
>
<span class="folder-icon">📁</span>
<span class="node-name">{node.name}</span>
</button>
{#if isExpanded(node.path)}
{#each node.children as child}
<FileTree
node={child}
{selectedId}
{onSelect}
depth={depth + 1}
/>
{/each}
{/if}
</div>
{:else}
<button
class="tree-item file"
class:selected={node.document?.documentId === selectedId}
class:deleted={node.isDeleted}
style="padding-left: {depth * 16 + 8}px"
onclick={() =>
node.document && onSelect(node.document.documentId)}
>
<span class="file-icon">{node.isDeleted ? "○" : "●"}</span>
<span class="node-name">{node.name}</span>
</button>
{/if}
<style>
.tree-item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 3px 12px;
font-size: 13px;
text-align: left;
transition: background 0.1s;
white-space: nowrap;
overflow: hidden;
}
.tree-item:hover {
background: var(--bg-hover);
}
.tree-item.selected {
background: var(--blue-bg);
}
.tree-item.deleted {
opacity: 0.4;
}
.tree-item.deleted .node-name {
text-decoration: line-through;
}
.expand-icon {
font-size: 10px;
width: 12px;
flex-shrink: 0;
color: var(--text-muted);
}
.folder-icon {
font-size: 14px;
flex-shrink: 0;
}
.file-icon {
font-size: 8px;
flex-shrink: 0;
color: var(--text-subtle);
}
.node-name {
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -0,0 +1,144 @@
<script lang="ts">
import { auth } from "../lib/stores.svelte";
interface Props {
vaultId: string;
serverVersion: string;
onRefresh: () => void;
}
let { vaultId, serverVersion, onRefresh }: Props = $props();
</script>
<header class="header">
<div class="header-left">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
<span class="header-title">VaultLink</span>
<span class="header-sep">/</span>
<span class="header-vault">{vaultId}</span>
</div>
<div class="header-right">
<span class="server-version">v{serverVersion}</span>
<button class="header-btn" onclick={onRefresh} title="Refresh">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16"
/>
</svg>
</button>
{#if auth.availableVaults.length > 1}
<button
class="header-btn"
onclick={() => auth.deselectVault()}
title="Switch vault"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</button>
{/if}
<button
class="header-btn"
onclick={() => auth.logout()}
title="Sign out"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
</div>
</header>
<style>
.header {
display: flex;
align-items: center;
justify-content: space-between;
height: 48px;
padding: 0 16px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
}
.header-title {
font-weight: 600;
font-size: 15px;
}
.header-sep {
color: var(--text-subtle);
}
.header-vault {
font-family: var(--mono);
font-size: 14px;
color: var(--accent);
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.server-version {
font-size: 12px;
color: var(--text-subtle);
font-family: var(--mono);
}
.header-btn {
padding: 6px;
border-radius: var(--radius-sm);
color: var(--text-muted);
transition: color 0.15s, background 0.15s;
}
.header-btn:hover {
color: var(--text);
background: var(--bg-hover);
}
</style>

View file

@ -0,0 +1,176 @@
<script lang="ts">
import { auth } from "../lib/stores.svelte";
import { listVaults } from "../lib/api";
let token = $state("");
let error = $state("");
let loading = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
if (!token.trim()) {
error = "Token is required.";
return;
}
error = "";
loading = true;
try {
const response = await listVaults(token.trim());
auth.authenticate(
token.trim(),
response.userName,
response.vaults
);
} catch {
error = "Authentication failed. Check your token.";
} finally {
loading = false;
}
}
</script>
<div class="login-page">
<div class="login-card">
<div class="logo">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<h1>VaultLink</h1>
</div>
<p class="subtitle">Vault History Browser</p>
<form onsubmit={handleSubmit}>
<label>
<span>Token</span>
<input
type="password"
bind:value={token}
placeholder="Enter your access token"
disabled={loading}
/>
</label>
{#if error}
<div class="error">{error}</div>
{/if}
<button type="submit" class="btn-primary" disabled={loading}>
{#if loading}
<span class="btn-spinner"></span>
Connecting...
{:else}
Connect
{/if}
</button>
</form>
</div>
</div>
<style>
.login-page {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background: var(--bg);
}
.login-card {
width: 100%;
max-width: 400px;
padding: 40px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow);
}
.logo {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
color: var(--text);
}
.logo h1 {
font-size: 24px;
font-weight: 600;
}
.subtitle {
color: var(--text-muted);
margin-bottom: 32px;
font-size: 14px;
}
form {
display: flex;
flex-direction: column;
gap: 20px;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
}
label span {
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
}
input {
width: 100%;
}
.error {
color: var(--red);
font-size: 13px;
padding: 8px 12px;
background: var(--red-bg);
border-radius: var(--radius-sm);
}
.btn-primary {
width: 100%;
padding: 10px 16px;
background: var(--accent);
color: #fff;
font-weight: 600;
border-radius: var(--radius);
transition: background 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,191 @@
<script lang="ts">
import type { DocumentVersionWithoutContent } from "../lib/types/DocumentVersionWithoutContent";
import { relativeTime, absoluteTime } from "../lib/stores.svelte";
interface Props {
min: number;
max: number;
value: number | null;
versions: DocumentVersionWithoutContent[];
onchange: (value: number | null) => void;
}
let { min, max, value, versions, onchange }: Props = $props();
let isNow = $derived(value === null || value >= max);
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
const v = parseInt(target.value, 10);
if (v >= max) {
onchange(null);
} else {
onchange(v);
}
}
function snapToNow() {
onchange(null);
}
let currentVersion = $derived(
value !== null
? versions.find((v) => v.vaultUpdateId === value) ??
versions.reduce(
(closest, v) =>
Math.abs(v.vaultUpdateId - (value ?? max)) <
Math.abs(
closest.vaultUpdateId - (value ?? max)
)
? v
: closest,
versions[0]
)
: null
);
</script>
<div class="time-slider">
<div class="slider-label">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span class="label-text">Time Travel</span>
</div>
<div class="slider-track">
<input
type="range"
min={min}
max={max}
value={value ?? max}
oninput={handleInput}
/>
</div>
<div class="slider-info">
{#if isNow}
<span class="now-badge">Now</span>
{:else if currentVersion}
<span
class="time-info"
title={absoluteTime(currentVersion.updatedDate)}
>
#{value}
&middot;
{relativeTime(currentVersion.updatedDate)}
</span>
{:else}
<span class="time-info">#{value}</span>
{/if}
</div>
{#if !isNow}
<button class="snap-btn" onclick={snapToNow} title="Back to now">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</button>
{/if}
</div>
<style>
.time-slider {
display: flex;
align-items: center;
gap: 12px;
}
.slider-label {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-muted);
flex-shrink: 0;
}
.label-text {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.slider-track {
flex: 1;
min-width: 120px;
}
.slider-track input[type="range"] {
width: 100%;
height: 4px;
appearance: none;
background: var(--bg-tertiary);
border-radius: 2px;
outline: none;
border: none;
padding: 0;
}
.slider-track input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
background: var(--accent);
border-radius: 50%;
cursor: pointer;
transition: transform 0.1s;
}
.slider-track input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.slider-info {
flex-shrink: 0;
min-width: 100px;
}
.now-badge {
font-size: 11px;
font-weight: 600;
color: var(--green);
background: var(--green-bg);
padding: 2px 10px;
border-radius: 10px;
text-transform: uppercase;
}
.time-info {
font-size: 12px;
color: var(--text-muted);
font-family: var(--mono);
}
.snap-btn {
padding: 4px;
color: var(--accent);
border-radius: var(--radius-sm);
transition: background 0.15s;
flex-shrink: 0;
}
.snap-btn:hover {
background: var(--bg-hover);
}
</style>

View file

@ -0,0 +1,80 @@
<script lang="ts">
import { toasts } from "../lib/stores.svelte";
const typeColors: Record<string, string> = {
success: "var(--green)",
error: "var(--red)",
info: "var(--accent)"
};
</script>
{#if toasts.items.length > 0}
<div class="toast-container">
{#each toasts.items as toast (toast.id)}
<div
class="toast"
style="border-left-color: {typeColors[toast.type]}"
>
<span class="toast-message">{toast.message}</span>
<button
class="toast-dismiss"
onclick={() => toasts.dismiss(toast.id)}
>
&times;
</button>
</div>
{/each}
</div>
{/if}
<style>
.toast-container {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 400px;
}
.toast {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-left-width: 3px;
border-radius: var(--radius);
box-shadow: var(--shadow);
animation: slide-in 0.2s ease-out;
}
.toast-message {
flex: 1;
font-size: 13px;
}
.toast-dismiss {
font-size: 18px;
color: var(--text-muted);
padding: 0 4px;
}
.toast-dismiss:hover {
color: var(--text);
}
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
</style>

View file

@ -0,0 +1,198 @@
<script lang="ts">
import { auth } from "../lib/stores.svelte";
import { relativeTime } from "../lib/stores.svelte";
import type { VaultInfo } from "../lib/types/VaultInfo";
function select(vault: VaultInfo) {
auth.selectVault(vault.name);
}
function formatStats(vault: VaultInfo): string {
const docs = vault.documentCount === 1
? "1 document"
: `${vault.documentCount} documents`;
if (!vault.createdAt) return docs;
return `${docs} · created ${relativeTime(vault.createdAt)}`;
}
</script>
<div class="picker-page">
<div class="picker-card">
<div class="picker-header">
<div class="logo">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<div>
<h1>Select a vault</h1>
<p class="user-info">
Signed in as <strong>{auth.userName}</strong>
<button class="logout-link" onclick={() => auth.logout()}>Sign out</button>
</p>
</div>
</div>
</div>
{#if auth.availableVaults.length === 0}
<div class="empty">
<p>No vaults found</p>
<p class="empty-hint">
Vaults are created when a sync client first connects.
</p>
</div>
{:else}
<ul class="vault-list">
{#each auth.availableVaults as vault}
<li>
<button class="vault-item" onclick={() => select(vault)}>
<svg class="vault-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<div class="vault-details">
<span class="vault-name">{vault.name}</span>
<span class="vault-stats">{formatStats(vault)}</span>
</div>
<svg class="vault-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<style>
.picker-page {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background: var(--bg);
}
.picker-card {
width: 100%;
max-width: 480px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow);
overflow: hidden;
}
.picker-header {
padding: 32px 32px 24px;
}
.logo {
display: flex;
align-items: flex-start;
gap: 12px;
color: var(--text);
}
.logo svg {
margin-top: 2px;
flex-shrink: 0;
}
.logo h1 {
font-size: 20px;
font-weight: 600;
}
.user-info {
font-size: 13px;
color: var(--text-muted);
margin-top: 4px;
}
.user-info strong {
color: var(--text);
font-weight: 500;
}
.logout-link {
color: var(--text-subtle);
font-size: 13px;
text-decoration: underline;
margin-left: 8px;
padding: 0;
}
.logout-link:hover {
color: var(--text-muted);
}
.vault-list {
list-style: none;
border-top: 1px solid var(--border);
}
.vault-list li + li {
border-top: 1px solid var(--border-light);
}
.vault-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 24px;
text-align: left;
color: var(--text);
transition: background 0.12s;
}
.vault-item:hover {
background: var(--bg-hover);
}
.vault-icon {
color: var(--text-muted);
flex-shrink: 0;
}
.vault-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.vault-name {
font-family: var(--mono);
font-size: 14px;
}
.vault-stats {
font-size: 12px;
color: var(--text-subtle);
}
.vault-arrow {
color: var(--text-subtle);
flex-shrink: 0;
}
.empty {
padding: 32px;
text-align: center;
border-top: 1px solid var(--border);
}
.empty p {
color: var(--text-muted);
}
.empty-hint {
font-size: 13px;
color: var(--text-subtle);
margin-top: 8px;
}
</style>

View file

@ -0,0 +1,146 @@
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
import type { DocumentVersion } from "./types/DocumentVersion";
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse";
import type { ListVaultsResponse } from "./types/ListVaultsResponse";
import type { PingResponse } from "./types/PingResponse";
import type { VaultHistoryResponse } from "./types/VaultHistoryResponse";
async function fetchJsonWithToken<T>(
path: string,
token: string,
init?: RequestInit
): Promise<T> {
const response = await fetch(path, {
...init,
headers: {
Authorization: `Bearer ${token}`,
"device-id": "history-ui",
...init?.headers
}
});
if (!response.ok) {
const body = await response.text();
throw new Error(`HTTP ${response.status}: ${body}`);
}
return response.json() as Promise<T>;
}
export async function listVaults(token: string): Promise<ListVaultsResponse> {
return fetchJsonWithToken("/vaults", token);
}
export class ApiClient {
constructor(
private vaultId: string,
private token: string
) {}
private get baseUrl(): string {
return `/vaults/${encodeURIComponent(this.vaultId)}`;
}
private async fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
return fetchJsonWithToken(path, this.token, init);
}
async ping(): Promise<PingResponse> {
return this.fetchJson(`${this.baseUrl}/ping`);
}
async fetchLatestDocuments(): Promise<FetchLatestDocumentsResponse> {
return this.fetchJson(`${this.baseUrl}/documents`);
}
async fetchDocumentVersions(
documentId: string
): Promise<DocumentVersionWithoutContent[]> {
return this.fetchJson(
`${this.baseUrl}/documents/${documentId}/versions`
);
}
async fetchDocumentVersion(
documentId: string,
vaultUpdateId: number
): Promise<DocumentVersion> {
return this.fetchJson(
`${this.baseUrl}/documents/${documentId}/versions/${vaultUpdateId}`
);
}
async fetchDocumentVersionContent(
documentId: string,
vaultUpdateId: number
): Promise<ArrayBuffer> {
const response = await fetch(
`${this.baseUrl}/documents/${documentId}/versions/${vaultUpdateId}/content`,
{
headers: {
Authorization: `Bearer ${this.token}`,
"device-id": "history-ui"
}
}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.arrayBuffer();
}
async fetchVaultHistory(
limit?: number,
beforeUpdateId?: number
): Promise<VaultHistoryResponse> {
const params = new URLSearchParams();
if (limit !== undefined) params.set("limit", String(limit));
if (beforeUpdateId !== undefined)
params.set("before_update_id", String(beforeUpdateId));
const qs = params.toString();
return this.fetchJson(`${this.baseUrl}/history${qs ? `?${qs}` : ""}`);
}
/**
* Upload a new version of an existing (non-deleted) document. The
* server treats this like any other edit server-side merging,
* path dedupe, and broadcast still apply. Used by the UI to restore
* an old version by re-submitting its bytes on top of the latest.
*/
async updateBinaryDocument(
documentId: string,
parentVersionId: number,
relativePath: string,
content: ArrayBuffer
): Promise<DocumentUpdateResponse> {
const form = new FormData();
form.append("parent_version_id", String(parentVersionId));
form.append("relative_path", relativePath);
form.append("content", new Blob([content]));
return this.fetchJson(
`${this.baseUrl}/documents/${documentId}/binary`,
{ method: "PUT", body: form }
);
}
/**
* Create a new document. Used by the UI to restore a deleted
* document: `update_document` short-circuits on `is_deleted`, so
* resurrection has to go through `create_document` which detects
* an existing doc at the same path, merges or dedupes as needed,
* and returns the resulting version.
*/
async createDocument(
lastSeenVaultUpdateId: number,
relativePath: string,
content: ArrayBuffer
): Promise<DocumentUpdateResponse> {
const form = new FormData();
form.append("last_seen_vault_update_id", String(lastSeenVaultUpdateId));
form.append("relative_path", relativePath);
form.append("content", new Blob([content]));
return this.fetchJson(`${this.baseUrl}/documents`, {
method: "POST",
body: form
});
}
}

View file

@ -0,0 +1,290 @@
import { ApiClient } from "./api";
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
import type { VaultInfo } from "./types/VaultInfo";
import type { VersionEvent, ActionType, TreeNode } from "./view-types";
class AuthStore {
token = $state("");
userName = $state("");
vaultId = $state("");
serverVersion = $state("");
availableVaults = $state<VaultInfo[]>([]);
isAuthenticated = $state(false);
api = $state<ApiClient | null>(null);
authenticate(token: string, userName: string, vaults: VaultInfo[]) {
this.token = token;
this.userName = userName;
this.availableVaults = vaults;
sessionStorage.setItem("vaultlink_token", token);
}
selectVault(vaultId: string) {
this.vaultId = vaultId;
this.isAuthenticated = true;
this.api = new ApiClient(vaultId, this.token);
sessionStorage.setItem("vaultlink_vault", vaultId);
}
deselectVault() {
this.vaultId = "";
this.isAuthenticated = false;
this.api = null;
sessionStorage.removeItem("vaultlink_vault");
}
logout() {
this.token = "";
this.userName = "";
this.vaultId = "";
this.serverVersion = "";
this.availableVaults = [];
this.isAuthenticated = false;
this.api = null;
sessionStorage.removeItem("vaultlink_token");
sessionStorage.removeItem("vaultlink_vault");
}
tryRestore(): { token: string; vaultId?: string } | null {
const token = sessionStorage.getItem("vaultlink_token");
if (!token) return null;
const vaultId = sessionStorage.getItem("vaultlink_vault") ?? undefined;
return { token, vaultId };
}
}
export const auth = new AuthStore();
// Navigation
export type View =
| { kind: "dashboard" }
| { kind: "document"; documentId: string };
class NavStore {
current = $state<View>({ kind: "dashboard" });
goto(view: View) {
this.current = view;
}
goHome() {
this.current = { kind: "dashboard" };
}
}
export const nav = new NavStore();
// Toasts
interface Toast {
id: number;
message: string;
type: "success" | "error" | "info";
}
class ToastStore {
items = $state<Toast[]>([]);
private nextId = 0;
add(message: string, type: Toast["type"] = "info") {
const id = this.nextId++;
this.items.push({ id, message, type });
setTimeout(() => this.dismiss(id), 5000);
}
dismiss(id: number) {
this.items = this.items.filter((t) => t.id !== id);
}
}
export const toasts = new ToastStore();
// Utilities
export function inferAction(
version: DocumentVersionWithoutContent,
previousVersion?: DocumentVersionWithoutContent
): ActionType {
if (version.isDeleted) return "deleted";
if (!previousVersion) return "created";
if (previousVersion.isDeleted && !version.isDeleted) return "restored";
if (previousVersion.relativePath !== version.relativePath) return "renamed";
return "updated";
}
export function enrichVersions(
versions: DocumentVersionWithoutContent[]
): VersionEvent[] {
// versions should be sorted by vaultUpdateId ascending
const sorted = [...versions].sort(
(a, b) => a.vaultUpdateId - b.vaultUpdateId
);
const byDoc = new Map<string, DocumentVersionWithoutContent[]>();
for (const v of sorted) {
let arr = byDoc.get(v.documentId);
if (!arr) {
arr = [];
byDoc.set(v.documentId, arr);
}
arr.push(v);
}
return sorted.map((v) => {
const docVersions = byDoc.get(v.documentId)!;
const idx = docVersions.indexOf(v);
const prev = idx > 0 ? docVersions[idx - 1] : undefined;
const action = inferAction(v, prev);
return {
...v,
action,
previousPath: action === "renamed" ? prev?.relativePath : undefined
};
});
}
export function buildTree(
documents: DocumentVersionWithoutContent[],
showDeleted: boolean
): TreeNode {
const root: TreeNode = {
name: "",
path: "",
isFolder: true,
children: []
};
const filtered = showDeleted
? documents
: documents.filter((d) => !d.isDeleted);
for (const doc of filtered) {
const parts = doc.relativePath.split("/");
let current = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isFile = i === parts.length - 1;
const path = parts.slice(0, i + 1).join("/");
if (isFile) {
current.children.push({
name: part,
path,
isFolder: false,
children: [],
document: doc,
isDeleted: doc.isDeleted
});
} else {
let folder = current.children.find(
(c) => c.isFolder && c.name === part
);
if (!folder) {
folder = {
name: part,
path,
isFolder: true,
children: []
};
current.children.push(folder);
}
current = folder;
}
}
}
sortTree(root);
return root;
}
function sortTree(node: TreeNode) {
node.children.sort((a, b) => {
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const child of node.children) {
if (child.isFolder) sortTree(child);
}
}
export function relativeTime(dateStr: string): string {
const date = new Date(dateStr);
const now = Date.now();
const diff = now - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: days > 365 ? "numeric" : undefined
});
}
export function absoluteTime(dateStr: string): string {
return new Date(dateStr).toLocaleString();
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
export function fileExtension(path: string): string {
const dot = path.lastIndexOf(".");
return dot > -1 ? path.substring(dot + 1).toLowerCase() : "";
}
export function isTextFile(path: string): boolean {
const textExts = new Set([
"md",
"txt",
"json",
"yaml",
"yml",
"toml",
"xml",
"html",
"css",
"js",
"ts",
"svelte",
"rs",
"py",
"sh",
"bash",
"zsh",
"csv",
"svg",
"log",
"conf",
"cfg",
"ini",
"env",
"gitignore",
"editorconfig"
]);
return textExts.has(fileExtension(path));
}
export function isImageFile(path: string): boolean {
const imageExts = new Set([
"png",
"jpg",
"jpeg",
"gif",
"webp",
"svg",
"ico",
"bmp"
]);
return imageExts.has(fileExtension(path));
}

View file

@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentWithCursors } from "./DocumentWithCursors";
export type ClientCursors = {
userName: string;
deviceId: string;
documentsWithCursors: Array<DocumentWithCursors>;
};

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CreateDocumentVersion = {
relative_path: string;
last_seen_vault_update_id: number;
content: Array<number>;
};

View file

@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentWithCursors } from "./DocumentWithCursors";
export type CursorPositionFromClient = {
documentsWithCursors: Array<DocumentWithCursors>;
};

View file

@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ClientCursors } from "./ClientCursors";
export type CursorPositionFromServer = { clients: Array<ClientCursors> };

View file

@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CursorSpan = { start: number; end: number };

View file

@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentVersion } from "./DocumentVersion";
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
/**
* Response to a create/update document request.
*/
export type DocumentUpdateResponse =
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
| ({ type: "MergingUpdate" } & DocumentVersion);

View file

@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DocumentVersion = {
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
contentBase64: string;
isDeleted: boolean;
userId: string;
deviceId: string;
};

View file

@ -0,0 +1,16 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DocumentVersionWithoutContent = {
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
isDeleted: boolean;
userId: string;
deviceId: string;
contentSize: number;
/**
* True iff this is the first version of the document
*/
isNewFile: boolean;
};

View file

@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CursorSpan } from "./CursorSpan";
export type DocumentWithCursors = {
vaultUpdateId: number | null;
documentId: string;
relativePath: string;
cursors: Array<CursorSpan>;
};

View file

@ -0,0 +1,13 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
/**
* Response to a fetch latest documents request.
*/
export type FetchLatestDocumentsResponse = {
latestDocuments: Array<DocumentVersionWithoutContent>;
/**
* The update ID of the latest document in the response.
*/
lastUpdateId: bigint;
};

View file

@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { VaultInfo } from "./VaultInfo";
/**
* Response to listing vaults accessible to the authenticated user.
*/
export type ListVaultsResponse = {
vaults: Array<VaultInfo>;
hasMore: boolean;
userName: string;
};

View file

@ -0,0 +1,25 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Response to a ping request.
*/
export type PingResponse = {
/**
* Semantic version of the server.
*/
serverVersion: string;
/**
* Whether the client is authenticated based on the sent Authorization
* header.
*/
isAuthenticated: boolean;
/**
* List of file extensions that are allowed to be merged.
*/
mergeableFileExtensions: Array<string>;
/**
* API version ensuring backwards & forwards compatibility between the client
* and server.
*/
supportedApiVersion: number;
};

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SerializedError = {
errorType: string;
message: string;
causes: Array<string>;
};

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type UpdateTextDocumentVersion = {
parentVersionId: number;
relativePath: string | null;
content: Array<number | string>;
};

View file

@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
/**
* Response to a vault history request (paginated).
*/
export type VaultHistoryResponse = {
versions: Array<DocumentVersionWithoutContent>;
hasMore: boolean;
};

View file

@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Summary of a single vault returned by the list-vaults endpoint.
*/
export type VaultInfo = {
name: string;
documentCount: number;
createdAt: string | null;
};

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CursorPositionFromClient } from "./CursorPositionFromClient";
import type { WebSocketHandshake } from "./WebSocketHandshake";
export type WebSocketClientMessage =
| ({ type: "handshake" } & WebSocketHandshake)
| ({ type: "cursorPositions" } & CursorPositionFromClient);

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type WebSocketHandshake = {
token: string;
deviceId: string;
lastSeenVaultUpdateId: number | null;
};

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CursorPositionFromServer } from "./CursorPositionFromServer";
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
export type WebSocketServerMessage =
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
| ({ type: "cursorPositions" } & CursorPositionFromServer);

View file

@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
export type WebSocketVaultUpdate = { document: DocumentVersionWithoutContent };

View file

@ -0,0 +1,22 @@
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
export type ActionType =
| "created"
| "updated"
| "renamed"
| "deleted"
| "restored";
export interface VersionEvent extends DocumentVersionWithoutContent {
action: ActionType;
previousPath?: string;
}
export interface TreeNode {
name: string;
path: string;
isFolder: boolean;
children: TreeNode[];
document?: DocumentVersionWithoutContent;
isDeleted?: boolean;
}

View file

@ -0,0 +1,7 @@
import { mount } from "svelte";
import App from "./App.svelte";
import "./app.css";
const app = mount(App, { target: document.getElementById("app")! });
export default app;

View file

@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess()
};

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"types": ["svelte"]
},
"include": ["src/**/*", "src/**/*.svelte"]
}

View file

@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
export default defineConfig({
plugins: [svelte()],
build: {
outDir: "dist",
emptyOutDir: true
},
server: {
proxy: {
"/vaults": "http://localhost:3010"
}
}
});

View file

@ -6,7 +6,8 @@
"obsidian-plugin",
"test-client",
"deterministic-tests",
"local-client-cli"
"local-client-cli",
"history-ui"
],
"prettier": {
"trailingComma": "none",

View file

@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { VaultInfo } from "./VaultInfo";
/**
* Response to listing vaults accessible to the authenticated user.
*/
export interface ListVaultsResponse {
vaults: VaultInfo[];
hasMore: boolean;
userName: string;
}

View file

@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
/**
* Response to a vault history request (paginated).
*/
export interface VaultHistoryResponse {
versions: DocumentVersionWithoutContent[];
hasMore: boolean;
}

View file

@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Summary of a single vault returned by the list-vaults endpoint.
*/
export interface VaultInfo {
name: string;
documentCount: number;
createdAt: string | null;
}

View file

@ -34,6 +34,8 @@ bimap = "0.6.3"
ts-rs = { version = "10.1", features = ["uuid-impl", "chrono-impl"] }
base64 = "0.22.1"
reconcile-text = { version = "0.8.0", features = ["serde"] }
rust-embed = "8.5"
mime_guess = "2.0"
[profile.release]
codegen-units = 1

View file

@ -1,5 +1,16 @@
// generated by `sqlx migrate build-script`
fn main() {
// trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations");
// Ensure the history-ui dist directory exists so rust-embed can compile
// even when the frontend hasn't been built yet.
let dist_path = std::path::Path::new("../frontend/history-ui/dist");
if !dist_path.exists() {
std::fs::create_dir_all(dist_path).expect("Failed to create history-ui dist directory");
std::fs::write(
dist_path.join("index.html"),
"<!DOCTYPE html><html><body><p>Run <code>npm run build -w history-ui</code> first.</p></body></html>",
)
.expect("Failed to write placeholder index.html");
}
}

View file

@ -4,23 +4,25 @@ mod delete_document;
mod device_id_header;
mod fetch_document_version;
mod fetch_document_version_content;
mod fetch_document_versions;
mod fetch_latest_document_version;
mod fetch_latest_documents;
mod fetch_vault_history;
mod index;
mod list_vaults;
mod ping;
mod requests;
mod responses;
mod update_document;
mod websocket;
use anyhow::{Context as _, Result, anyhow};
use anyhow::{Context as _, Result};
use auth::auth_middleware;
use axum::{
Router,
extract::{DefaultBodyLimit, Request},
http::{self, HeaderValue, Method},
middleware,
response::IntoResponse,
routing::{IntoMakeService, delete, get, post, put},
};
use device_id_header::DEVICE_ID_HEADER_NAME;
@ -41,7 +43,6 @@ use tracing::{Level, info_span};
use crate::{
app_state::AppState,
config::{Config, server_config::ServerConfig},
errors::{client_error, not_found_error},
};
pub async fn create_server(config: Config) -> Result<()> {
@ -54,8 +55,11 @@ pub async fn create_server(config: Config) -> Result<()> {
let app = Router::new()
.nest("/", get_authed_routes(app_state.clone()))
.route("/", get(index::index))
.route("/assets/*path", get(index::spa_assets))
.route("/vaults", get(list_vaults::list_vaults))
.route("/vaults/:vault_id/ping", get(ping::ping))
.route("/vaults/:vault_id/ws", get(websocket::websocket_handler))
.fallback(index::spa_fallback)
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(
app_state.config.server.max_body_size_mb * 1024 * 1024,
@ -91,8 +95,6 @@ pub async fn create_server(config: Config) -> Result<()> {
.on_failure(DefaultOnFailure::new().level(Level::ERROR)),
)
.with_state(app_state)
.fallback(handle_404)
.fallback(handle_405)
.into_make_service();
start_server(app, &server_config).await
@ -120,6 +122,10 @@ fn get_authed_routes(app_state: AppState) -> Router<AppState> {
"/vaults/:vault_id/documents/:document_id/text",
put(update_document::update_text),
)
.route(
"/vaults/:vault_id/documents/:document_id/versions",
get(fetch_document_versions::fetch_document_versions),
)
.route(
"/vaults/:vault_id/documents/:document_id/versions/:vault_update_id",
get(fetch_document_version::fetch_document_version),
@ -132,6 +138,10 @@ fn get_authed_routes(app_state: AppState) -> Router<AppState> {
"/vaults/:vault_id/documents/:document_id",
delete(delete_document::delete_document),
)
.route(
"/vaults/:vault_id/history",
get(fetch_vault_history::fetch_vault_history),
)
.layer(middleware::from_fn_with_state(app_state, auth_middleware))
}
@ -178,11 +188,3 @@ async fn shutdown_signal() {
() = terminate => {},
}
}
async fn handle_404() -> impl IntoResponse {
not_found_error(anyhow!("Page not found"))
}
async fn handle_405() -> impl IntoResponse {
client_error(anyhow!("Method not allowed"))
}

View file

@ -0,0 +1,42 @@
use axum::{
Json,
extract::{Path, State},
};
use log::debug;
use serde::Deserialize;
use crate::{
app_state::{
AppState,
database::models::{DocumentId, DocumentVersionWithoutContent, VaultId},
},
errors::{SyncServerError, server_error},
utils::normalize::normalize,
};
#[derive(Deserialize)]
pub struct FetchDocumentVersionsPathParams {
#[serde(deserialize_with = "normalize")]
vault_id: VaultId,
document_id: DocumentId,
}
#[axum::debug_handler]
pub async fn fetch_document_versions(
Path(FetchDocumentVersionsPathParams {
vault_id,
document_id,
}): Path<FetchDocumentVersionsPathParams>,
State(state): State<AppState>,
) -> Result<Json<Vec<DocumentVersionWithoutContent>>, SyncServerError> {
debug!("Fetching all versions for document `{document_id}` in vault `{vault_id}`");
let versions = state
.database
.get_document_versions(&vault_id, &document_id, None)
.await
.map_err(server_error)?;
Ok(Json(versions))
}

View file

@ -0,0 +1,70 @@
use axum::{
Json,
extract::{Path, Query, State},
};
use log::debug;
use serde::Deserialize;
use super::responses::VaultHistoryResponse;
use crate::{
app_state::{
AppState,
database::models::{VaultId, VaultUpdateId},
},
errors::{SyncServerError, client_error, server_error},
utils::normalize::normalize,
};
const DEFAULT_LIMIT: i64 = 50;
const MAX_LIMIT: i64 = 500;
#[derive(Deserialize)]
pub struct FetchVaultHistoryPathParams {
#[serde(deserialize_with = "normalize")]
vault_id: VaultId,
}
#[derive(Deserialize)]
pub struct QueryParams {
limit: Option<i64>,
before_update_id: Option<VaultUpdateId>,
}
#[axum::debug_handler]
pub async fn fetch_vault_history(
Path(FetchVaultHistoryPathParams { vault_id }): Path<FetchVaultHistoryPathParams>,
Query(QueryParams {
limit,
before_update_id,
}): Query<QueryParams>,
State(state): State<AppState>,
) -> Result<Json<VaultHistoryResponse>, SyncServerError> {
if let Some(id) = before_update_id
&& id <= 0
{
return Err(client_error(anyhow::anyhow!(
"before_update_id must be a positive integer"
)));
}
let limit = limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
debug!(
"Fetching vault history for vault `{vault_id}` (limit={limit}, before={before_update_id:?})"
);
// Fetch one extra row to determine if there are more results
let mut versions = state
.database
.get_vault_history(&vault_id, limit + 1, before_update_id, None)
.await
.map_err(server_error)?;
#[allow(clippy::cast_sign_loss)] // limit is clamped to [1, 500] above
let has_more = versions.len() > limit as usize;
if has_more {
versions.pop();
}
Ok(Json(VaultHistoryResponse { versions, has_more }))
}

View file

@ -1,7 +1,77 @@
use axum::response::{Html, IntoResponse};
use axum::{
body::Body,
extract::{Path, State},
http::{StatusCode, header},
response::{Html, IntoResponse, Response},
};
use log::warn;
use rust_embed::Embed;
pub async fn index() -> impl IntoResponse {
const HTML_CONTENT: &str = include_str!("./assets/index.html");
let html_content = HTML_CONTENT;
Html(html_content)
use crate::app_state::AppState;
#[derive(Embed)]
#[folder = "../frontend/history-ui/dist/"]
struct HistoryUiAssets;
pub async fn index(State(_state): State<AppState>) -> impl IntoResponse {
if let Some(content) = HistoryUiAssets::get("index.html") {
Html(
std::str::from_utf8(content.data.as_ref())
.inspect_err(|e| warn!("Embedded index.html is not valid UTF-8: {e}"))
.unwrap_or("<h1>VaultLink</h1>")
.to_owned(),
)
.into_response()
} else {
warn!("No embedded index.html found — history UI may not have been built");
Html("<h1>VaultLink server</h1>".to_owned()).into_response()
}
}
pub async fn spa_assets(Path(path): Path<String>) -> impl IntoResponse {
// The route is /assets/*path so path is relative to assets/.
// The embedded files include the assets/ prefix from the dist directory.
let full_path = format!("assets/{path}");
if let Some(content) = HistoryUiAssets::get(&full_path) {
let mime = mime_guess::from_path(&full_path).first_or_octet_stream();
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime.as_ref())
.body(Body::from(content.data.to_vec()))
.unwrap_or_else(|_| {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::empty())
.unwrap_or_else(|_| Response::new(Body::empty()))
});
}
// Asset paths must match an embedded file — no SPA fallback.
// Serving index.html here would return 200 with text/html for missing
// .css/.js files, causing the browser to silently ignore the content.
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("Not found"))
.unwrap_or_else(|_| Response::new(Body::from("Not found")))
}
/// SPA fallback for production: serves index.html for client-side routes
/// (e.g. `/documents/123`).
pub async fn spa_fallback() -> impl IntoResponse {
match HistoryUiAssets::get("index.html") {
Some(content) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html")
.body(Body::from(content.data.to_vec()))
.unwrap_or_else(|_| {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::empty())
.unwrap_or_else(|_| Response::new(Body::empty()))
}),
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("Not found"))
.unwrap_or_else(|_| Response::new(Body::from("Not found"))),
}
}

View file

@ -0,0 +1,82 @@
use axum::{
Json,
extract::{Query, State},
};
use axum_extra::{
TypedHeader,
headers::{Authorization, authorization::Bearer},
};
use log::debug;
use serde::Deserialize;
use super::{
auth::authenticate,
responses::{ListVaultsResponse, VaultInfo},
};
use crate::{
app_state::AppState,
config::user_config::{AllowListedVaults, VaultAccess},
errors::{SyncServerError, server_error, unauthenticated_error},
};
const DEFAULT_LIMIT: usize = 50;
const MAX_LIMIT: usize = 200;
#[derive(Deserialize)]
pub struct QueryParams {
limit: Option<usize>,
after: Option<String>,
}
#[axum::debug_handler]
pub async fn list_vaults(
auth_header: Option<TypedHeader<Authorization<Bearer>>>,
Query(QueryParams { limit, after }): Query<QueryParams>,
State(state): State<AppState>,
) -> Result<Json<ListVaultsResponse>, SyncServerError> {
let auth_header = auth_header
.ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Missing Authorization header")))?;
let user = authenticate(&state, auth_header.token().trim())?;
debug!("User `{}` listing accessible vaults", user.name);
let existing_vaults = state.database.list_vaults().await.map_err(server_error)?;
let mut accessible: Vec<String> = match user.vault_access {
VaultAccess::AllowAccessToAll => existing_vaults,
VaultAccess::AllowList(AllowListedVaults { ref allowed }) => existing_vaults
.into_iter()
.filter(|v| allowed.contains(v))
.collect(),
};
// Cursor-based pagination: skip vaults up to and including `after`
if let Some(ref cursor) = after {
accessible.retain(|v| v.as_str() > cursor.as_str());
}
let limit = limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
let has_more = accessible.len() > limit;
accessible.truncate(limit);
let mut vaults = Vec::with_capacity(accessible.len());
for name in accessible {
let stats = state
.database
.get_vault_stats(&name)
.await
.map_err(server_error)?;
vaults.push(VaultInfo {
name,
document_count: stats.document_count,
created_at: stats.created_at,
});
}
Ok(Json(ListVaultsResponse {
vaults,
has_more,
user_name: user.name,
}))
}