Compare commits

..

1 commit

Author SHA1 Message Date
4d4ffa160a 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>
2026-05-09 11:37:22 +01:00
77 changed files with 4659 additions and 632 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

@ -1,4 +1,4 @@
FROM node:25-slim AS builder
FROM node:22-slim AS builder
WORKDIR /build
@ -7,7 +7,7 @@ COPY . .
RUN npm ci
RUN npm run build
FROM node:25-alpine
FROM node:22-alpine
LABEL org.opencontainers.image.title="VaultLink Local CLI"
LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client"

View file

@ -47,25 +47,24 @@ vaultlink \
### Required
| Option | Description |
| ------------------------- | --------------------------------------------- |
| `-l, --local-path <path>` | Local directory to sync |
| `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) |
| `-t, --token <token>` | Authentication token |
| `-v, --vault-name <name>` | Vault name on server |
| Option | Description |
|--------|-------------|
| `-l, --local-path <path>` | Local directory to sync |
| `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) |
| `-t, --token <token>` | Authentication token |
| `-v, --vault-name <name>` | Vault name on server |
### Optional
| Option | Default | Description |
| ------------------------------------ | ------- | ----------------------------------------------- |
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
| `--line-endings <mode>` | `auto` | Line ending style: auto, lf, crlf |
| `-q, --quiet` | - | Suppress startup banner for non-interactive use |
| `-h, --help` | - | Show help |
| `-V, --version` | - | Show version |
| Option | Default | Description |
|--------|---------|-------------|
| `--sync-concurrency <number>` | `1` | Concurrent sync operations |
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
| `-h, --help` | - | Show help |
| `-V, --version` | - | Show version |
### Auto-Ignored Patterns
@ -75,32 +74,22 @@ vaultlink \
### Examples
Basic usage:
```bash
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default
```
With ignore patterns:
```bash
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
--ignore-pattern "**/*.tmp" \
--ignore-pattern "*.tmp" \
--ignore-pattern ".DS_Store" \
--ignore-pattern "node_modules/**"
```
With debug logging and quiet startup:
With debug logging:
```bash
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
--log-level DEBUG --quiet
```
Force LF line endings (useful for cross-platform vaults):
```bash
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
--line-endings lf
--log-level DEBUG
```
## Docker Deployment
@ -187,7 +176,6 @@ services:
## Development
Build:
```bash
npm run build
# or from the parent folder, run
@ -195,13 +183,11 @@ docker build -f local-client-cli/Dockerfile .
```
Test:
```bash
npm test
```
Docker build:
```bash
cd frontend
docker build -f local-client-cli/Dockerfile -t vault-link-cli:test .

View file

@ -11,16 +11,18 @@
"build": "webpack --mode production",
"test": "tsx --test 'src/**/*.test.ts'"
},
"devDependencies": {
"dependencies": {
"commander": "^14.0.2",
"watcher": "^2.3.1",
"@types/node": "^25.0.2",
"watcher": "^2.3.1"
},
"devDependencies": {
"@types/node": "^24.8.1",
"sync-client": "file:../sync-client",
"ts-loader": "^9.5.4",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"tsx": "^4.21.0",
"typescript": "5.9.3",
"webpack": "^5.103.0",
"tsx": "^4.20.6",
"typescript": "5.8.3",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
}
}

View file

@ -55,10 +55,13 @@ test("parseArgs - parse with optional arguments", () => {
"mytoken",
"-v",
"default",
"--sync-concurrency",
"5",
"--max-file-size-mb",
"20"
]);
assert.equal(args.syncConcurrency, 5);
assert.equal(args.maxFileSizeMB, 20);
});
@ -150,6 +153,25 @@ test("parseArgs - default log level is INFO", () => {
assert.equal(args.logLevel, LogLevel.INFO);
});
test("parseArgs - parse DEBUG log level", () => {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default",
"--log-level",
"DEBUG"
]);
assert.equal(args.logLevel, LogLevel.DEBUG);
});
test("parseArgs - parse ERROR log level", () => {
const args = parseArgs([
"node",
@ -169,32 +191,28 @@ test("parseArgs - parse ERROR log level", () => {
assert.equal(args.logLevel, LogLevel.ERROR);
});
test("parseArgs - log level is case insensitive", () => {
const args = parseArgs([
"node",
"cli.js",
"-l",
"/path/to/vault",
"-r",
"https://sync.example.com",
"-t",
"mytoken",
"-v",
"default",
"--log-level",
"debug"
]);
test("parseArgs - reads required options from environment variables", () => {
process.env.VAULTLINK_LOCAL_PATH = "/env/path";
process.env.VAULTLINK_REMOTE_URI = "https://env.example.com";
process.env.VAULTLINK_TOKEN = "env-token";
process.env.VAULTLINK_VAULT_NAME = "env-vault";
try {
const args = parseArgs(["node", "cli.js"]);
assert.equal(args.localPath, "/env/path");
assert.equal(args.remoteUri, "https://env.example.com");
assert.equal(args.token, "env-token");
assert.equal(args.vaultName, "env-vault");
} finally {
delete process.env.VAULTLINK_LOCAL_PATH;
delete process.env.VAULTLINK_REMOTE_URI;
delete process.env.VAULTLINK_TOKEN;
delete process.env.VAULTLINK_VAULT_NAME;
}
assert.equal(args.logLevel, LogLevel.DEBUG);
});
test("parseArgs - CLI arguments take precedence over environment variables", () => {
process.env.VAULTLINK_TOKEN = "env-token";
try {
const args = parseArgs([
test("parseArgs - throws on invalid log level", () => {
assert.throws(() => {
parseArgs([
"node",
"cli.js",
"-l",
@ -202,12 +220,11 @@ test("parseArgs - CLI arguments take precedence over environment variables", ()
"-r",
"https://sync.example.com",
"-t",
"cli-token",
"mytoken",
"-v",
"default"
"default",
"--log-level",
"INVALID"
]);
assert.equal(args.token, "cli-token");
} finally {
delete process.env.VAULTLINK_TOKEN;
}
}, /Invalid log level/);
});

View file

@ -1,54 +1,19 @@
import { Command, Option } from "commander";
import { Command } from "commander";
import packageJson from "../package.json";
import { LogLevel } from "sync-client";
export const LINE_ENDING_MODES = ["auto", "lf", "crlf"] as const;
export type LineEndingMode = (typeof LINE_ENDING_MODES)[number];
interface CliArgs {
export interface CliArgs {
remoteUri: string;
token: string;
vaultName: string;
localPath: string;
syncConcurrency?: number;
maxFileSizeMB?: number;
ignorePatterns?: string[];
webSocketRetryIntervalMs?: number;
logLevel: LogLevel;
health?: string;
enableTelemetry?: boolean;
quiet: boolean;
lineEndings: LineEndingMode;
}
const VALID_PROTOCOLS = ["http://", "https://", "ws://", "wss://"];
const REQUIRED_OPTIONS = {
localPath: {
flags: "-l, --local-path <path>",
env: "VAULTLINK_LOCAL_PATH"
},
remoteUri: {
flags: "-r, --remote-uri <uri>",
env: "VAULTLINK_REMOTE_URI"
},
token: { flags: "-t, --token <token>", env: "VAULTLINK_TOKEN" },
vaultName: {
flags: "-v, --vault-name <name>",
env: "VAULTLINK_VAULT_NAME"
}
} as const;
function requireOption<T>(
value: T | undefined,
name: keyof typeof REQUIRED_OPTIONS
): T {
if (value === undefined) {
const { flags, env } = REQUIRED_OPTIONS[name];
throw new Error(
`required option '${flags}' not specified (or set ${env})`
);
}
return value;
}
export function parseArgs(argv: string[]): CliArgs {
@ -60,85 +25,41 @@ export function parseArgs(argv: string[]): CliArgs {
"VaultLink Local CLI - Sync your vault to the local filesystem"
)
.version(packageJson.version)
.addOption(
new Option(
REQUIRED_OPTIONS.localPath.flags,
"Local directory path to sync"
).env(REQUIRED_OPTIONS.localPath.env)
.option("-l, --local-path <path>", "Local directory path to sync")
.option("-r, --remote-uri <uri>", "Remote server URI")
.option("-t, --token <token>", "Authentication token")
.option("-v, --vault-name <name>", "Vault name")
.option(
"--sync-concurrency <number>",
"[OPTIONAL] Number of concurrent sync operations",
parseInt
)
.addOption(
new Option(
REQUIRED_OPTIONS.remoteUri.flags,
"Remote server URI"
).env(REQUIRED_OPTIONS.remoteUri.env)
.option(
"--max-file-size-mb <number>",
"[OPTIONAL] Maximum file size in MB",
parseInt
)
.addOption(
new Option(
REQUIRED_OPTIONS.token.flags,
"Authentication token"
).env(REQUIRED_OPTIONS.token.env)
.option(
"--ignore-pattern <pattern...>",
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
)
.addOption(
new Option(REQUIRED_OPTIONS.vaultName.flags, "Vault name").env(
REQUIRED_OPTIONS.vaultName.env
)
.option(
"--websocket-retry-interval-ms <number>",
"[OPTIONAL] WebSocket retry interval in milliseconds",
parseInt
)
.addOption(
new Option(
"--max-file-size-mb <number>",
"[OPTIONAL] Maximum file size in MB"
)
.argParser(parseInt)
.env("VAULTLINK_MAX_FILE_SIZE_MB")
.option(
"--log-level <level>",
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
"INFO"
)
.addOption(
new Option(
"--ignore-pattern <pattern...>",
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
).env("VAULTLINK_IGNORE_PATTERNS")
.option(
"--health <path>",
"[OPTIONAL] Path to health status file for Docker healthcheck"
)
.addOption(
new Option(
"--websocket-retry-interval-ms <number>",
"[OPTIONAL] WebSocket retry interval in milliseconds"
)
.argParser(parseInt)
.env("VAULTLINK_WEBSOCKET_RETRY_INTERVAL_MS")
)
.addOption(
new Option(
"--log-level <level>",
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)"
)
.default("INFO")
.env("VAULTLINK_LOG_LEVEL")
)
.addOption(
new Option(
"--health <path>",
"[OPTIONAL] Path to health status file for Docker healthcheck"
).env("VAULTLINK_HEALTH")
)
.addOption(
new Option(
"--enable-telemetry",
"[OPTIONAL] Enable telemetry (disabled by default)"
).env("VAULTLINK_ENABLE_TELEMETRY")
)
.addOption(
new Option(
"-q, --quiet",
"[OPTIONAL] Suppress startup banner for non-interactive use"
).env("VAULTLINK_QUIET")
)
.addOption(
new Option(
"--line-endings <mode>",
"[OPTIONAL] Line ending style: auto (platform default), lf, crlf"
)
.default("auto")
.choices([...LINE_ENDING_MODES])
.env("VAULTLINK_LINE_ENDINGS")
.option(
"--enable-telemetry",
"[OPTIONAL] Enable telemetry (disabled by default)"
)
.addHelpText(
"after",
@ -146,13 +67,9 @@ export function parseArgs(argv: string[]): CliArgs {
Examples:
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
--ignore-pattern ".git/**" --ignore-pattern "**/*.tmp"
--ignore-pattern ".git/**" --ignore-pattern "*.tmp"
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
--log-level DEBUG --quiet
Environment variables:
All options can be configured via VAULTLINK_ prefixed environment variables.
CLI arguments take precedence over environment variables.
--log-level DEBUG
`
);
@ -164,6 +81,7 @@ Environment variables:
const remoteUri = opts.remoteUri as string | undefined;
const token = opts.token as string | undefined;
const vaultName = opts.vaultName as string | undefined;
const syncConcurrency = opts.syncConcurrency as number | undefined;
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
const ignorePattern = opts.ignorePattern as string[] | undefined;
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
@ -172,23 +90,22 @@ Environment variables:
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
const health = opts.health as string | undefined;
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
const quiet = (opts.quiet as boolean | undefined) ?? false;
const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto";
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
const requiredLocalPath = requireOption(localPath, "localPath");
const requiredRemoteUri = requireOption(remoteUri, "remoteUri");
const requiredToken = requireOption(token, "token");
const requiredVaultName = requireOption(vaultName, "vaultName");
// Validate remote URI protocol
if (
!VALID_PROTOCOLS.some((prefix) => requiredRemoteUri.startsWith(prefix))
) {
if (localPath === undefined) {
throw new Error(
`Invalid remote URI '${requiredRemoteUri}'. Must start with ${VALID_PROTOCOLS.join(", ")}`
"required option '-l, --local-path <path>' not specified"
);
}
if (remoteUri === undefined) {
throw new Error("required option '--remote-uri <uri>' not specified");
}
if (token === undefined) {
throw new Error("required option '--token <token>' not specified");
}
if (vaultName === undefined) {
throw new Error("required option '--vault-name <name>' not specified");
}
// Validate and parse log level
const logLevelUpper = logLevelStr.toUpperCase();
@ -203,27 +120,17 @@ Environment variables:
}
const logLevel = logLevelUpper;
const isLineEndingMode = (value: string): value is LineEndingMode =>
(LINE_ENDING_MODES as readonly string[]).includes(value);
if (!isLineEndingMode(lineEndingsStr)) {
throw new Error(
`Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${LINE_ENDING_MODES.join(", ")}`
);
}
const lineEndings = lineEndingsStr;
return {
localPath: requiredLocalPath,
remoteUri: requiredRemoteUri,
token: requiredToken,
vaultName: requiredVaultName,
localPath,
remoteUri,
token,
vaultName,
syncConcurrency,
maxFileSizeMB: maxFileSizeMb,
ignorePatterns: ignorePattern,
webSocketRetryIntervalMs: websocketRetryIntervalMs,
logLevel,
health,
enableTelemetry,
quiet,
lineEndings
enableTelemetry
};
}

View file

@ -5,27 +5,24 @@ import type { NetworkConnectionStatus } from "sync-client";
import {
SyncClient,
DEFAULT_SETTINGS,
Logger,
LogLevel,
LogLine,
type SyncSettings,
type StoredDatabase
} from "sync-client";
import { parseArgs, type LineEndingMode } from "./args";
import { NodeFileSystemOperations, VAULTLINK_DIR } from "./node-filesystem";
import { parseArgs } from "./args";
import { NodeFileSystemOperations } from "./node-filesystem";
import { FileWatcher } from "./file-watcher";
import { formatLogLine } from "./logger-formatter";
import { formatLogLine, colorize, styleText } from "./logger-formatter";
import packageJson from "../package.json";
function writeHealthStatus(
logger: Logger,
filePath: string,
connectionStatus: NetworkConnectionStatus
): void {
try {
fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus));
} catch (error) {
logger.error(
console.error(
`Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}`
);
}
@ -38,41 +35,12 @@ const LOG_LEVEL_ORDER = {
[LogLevel.ERROR]: 3
};
function createLogHandler(minLevel: LogLevel): (logLine: LogLine) => void {
return (logLine: LogLine): void => {
if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[minLevel]) {
// eslint-disable-next-line no-console
console.log(formatLogLine(logLine));
}
};
}
const HEALTH_CHECK_INTERVAL_MS = 30 * 1000;
const PROGRESS_LOG_INTERVAL_MS = 2000;
function resolveLineEndings(mode: LineEndingMode): string {
switch (mode) {
case "lf":
return "\n";
case "crlf":
return "\r\n";
case "auto":
return process.platform === "win32" ? "\r\n" : "\n";
}
}
async function main(): Promise<void> {
const args = parseArgs(process.argv);
const absolutePath = path.resolve(args.localPath);
const logHandler = createLogHandler(args.logLevel);
// Boot-time messages are emitted directly through logHandler before the
// SyncClient (and its Logger) exist; afterwards every log line flows
// through client.logger.
const emitBoot = (level: LogLevel, message: string): void => {
logHandler(new LogLine(level, message));
};
if (!fsSync.existsSync(absolutePath)) {
fsSync.mkdirSync(absolutePath, { recursive: true });
}
@ -80,31 +48,38 @@ async function main(): Promise<void> {
try {
const stats = await fs.stat(absolutePath);
if (!stats.isDirectory()) {
emitBoot(LogLevel.ERROR, `${absolutePath} is not a directory`);
console.error(
colorize(`Error: ${absolutePath} is not a directory`, "red")
);
process.exit(1);
}
} catch (error) {
emitBoot(
LogLevel.ERROR,
`Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`
console.error(
colorize(
`Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`,
"red"
)
);
process.exit(1);
}
if (!args.quiet) {
emitBoot(LogLevel.INFO, `VaultLink Local CLI v${packageJson.version}`);
emitBoot(LogLevel.INFO, `Local path: ${absolutePath}`);
emitBoot(LogLevel.INFO, `Remote URI: ${args.remoteUri}`);
emitBoot(LogLevel.INFO, `Vault name: ${args.vaultName}`);
if (args.lineEndings !== "auto") {
emitBoot(
LogLevel.INFO,
`Line endings: ${args.lineEndings.toUpperCase()}`
);
}
}
console.log(
styleText("VaultLink Local CLI", "bold", "cyan") +
colorize(` v${packageJson.version}`, "dim")
);
console.log(colorize("=".repeat(50), "dim"));
console.log(
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}`
);
console.log(
`${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}`
);
console.log(
`${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}`
);
console.log("");
const dataDir = path.join(absolutePath, VAULTLINK_DIR);
const dataDir = path.join(absolutePath, ".vaultlink");
const dataFile = path.join(dataDir, "sync-data.json");
await fs.mkdir(dataDir, { recursive: true });
@ -113,7 +88,8 @@ async function main(): Promise<void> {
const ignorePatterns = [
...(args.ignorePatterns ?? []),
`${VAULTLINK_DIR}/**`
".vaultlink/**",
".git/**"
];
const settings: SyncSettings = {
@ -121,6 +97,8 @@ async function main(): Promise<void> {
remoteUri: args.remoteUri,
token: args.token,
vaultName: args.vaultName,
syncConcurrency:
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
ignorePatterns,
webSocketRetryIntervalMs:
@ -141,9 +119,11 @@ async function main(): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
database = JSON.parse(content) as Partial<StoredDatabase>;
} catch {
emitBoot(
LogLevel.WARNING,
`Cannot read data file at ${dataFile}`
console.error(
colorize(
`Cannot read data file at ${dataFile}`,
"yellow"
)
);
}
@ -153,27 +133,23 @@ async function main(): Promise<void> {
};
},
save: async ({ database: persistedDatabase }) => {
// settings can't be updated when running with this CLI
await fs.writeFile(
dataFile,
JSON.stringify(persistedDatabase, null, 2)
);
}
},
nativeLineEndings: resolveLineEndings(args.lineEndings)
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
});
if (args.health !== undefined) {
const healthFile = args.health;
const writeHealth = (): void => {
const healthInterval = setInterval(() => {
void client.checkConnection().then((status) => {
writeHealthStatus(client.logger, healthFile, status);
writeHealthStatus(healthFile, status);
});
};
writeHealth();
const healthInterval = setInterval(
writeHealth,
HEALTH_CHECK_INTERVAL_MS
);
}, HEALTH_CHECK_INTERVAL_MS);
const clearHealthInterval = (): void => {
clearInterval(healthInterval);
};
@ -182,10 +158,17 @@ async function main(): Promise<void> {
process.on("exit", clearHealthInterval);
}
client.logger.onLogEmitted.add(logHandler);
// Add colored log formatter with level filtering
client.logger.onLogEmitted.add((logLine) => {
// Only show messages at or above the configured log level
if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[args.logLevel]) {
console.log(formatLogLine(logLine));
}
});
client.logger.info("Starting sync client");
const fileWatcher = new FileWatcher(absolutePath, client, ignorePatterns);
const fileWatcher = new FileWatcher(absolutePath, client);
client.onWebSocketStatusChanged.add(() => {
const isConnected = client.isWebSocketConnected;
@ -194,54 +177,26 @@ async function main(): Promise<void> {
);
});
let syncBatchSize = 0;
let totalSyncOps = 0;
let lastProgressLogTime = 0;
client.onRemainingOperationsCountChanged.add((remaining) => {
if (remaining > syncBatchSize) {
syncBatchSize = remaining;
}
if (remaining === 0) {
if (syncBatchSize > 0) {
totalSyncOps += syncBatchSize;
client.logger.info(
`Sync batch complete (${syncBatchSize} operations)`
);
syncBatchSize = 0;
}
client.logger.info("All sync operations completed");
} else {
const now = Date.now();
if (now - lastProgressLogTime >= PROGRESS_LOG_INTERVAL_MS) {
client.logger.info(
`Syncing: ${remaining} operations remaining`
);
lastProgressLogTime = now;
}
client.logger.info(`${remaining} sync operations remaining`);
}
});
let isShuttingDown = false;
const gracefulShutdown = async (signal: string): Promise<void> => {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
client.logger.info(`${signal} received, shutting down gracefully`);
console.log(
colorize(
`\n${signal} received. Shutting down gracefully...`,
"yellow"
)
);
fileWatcher.stop();
await client.waitUntilFinished();
await client.destroy();
if (totalSyncOps > 0) {
client.logger.info(
`Shutdown complete (${totalSyncOps} operations synced)`
);
} else {
client.logger.info("Shutdown complete");
}
console.log(colorize("Shutdown complete", "green"));
process.exit(0);
};
@ -255,21 +210,27 @@ async function main(): Promise<void> {
try {
const connectionStatus = await client.checkConnection();
if (!connectionStatus.isSuccessful) {
client.logger.error(
`Cannot connect to server: ${connectionStatus.serverMessage}`
console.error(
colorize(
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`,
"red"
)
);
process.exit(1);
}
if (!args.quiet) {
client.logger.info("Server connection successful");
}
console.log(`${colorize("✓", "green")} Server connection successful`);
console.log(colorize("Press Ctrl+C to stop", "dim"));
console.log("");
await client.start();
fileWatcher.start();
} catch (error) {
client.logger.error(
`Fatal error: ${error instanceof Error ? error.message : String(error)}`
console.error(
colorize(
`Fatal error: ${error instanceof Error ? error.message : String(error)}`,
"red"
)
);
fileWatcher.stop();
@ -279,9 +240,11 @@ async function main(): Promise<void> {
}
main().catch((error: unknown) => {
// eslint-disable-next-line no-console
console.error(
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`
colorize(
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
"red"
)
);
process.exit(1);
});

View file

@ -1,20 +1,15 @@
import Watcher from "watcher";
import * as path from "path";
import type { SyncClient, RelativePath } from "sync-client";
import { toUnixPath, matchesGlob } from "./path-utils";
export class FileWatcher {
private watcher: Watcher | undefined;
private isRunning = false;
private readonly ignorePatterns: string[];
public constructor(
private readonly basePath: string,
private readonly client: SyncClient,
ignorePatterns: string[] = []
) {
this.ignorePatterns = ignorePatterns;
}
private readonly client: SyncClient
) {}
public start(): void {
if (this.isRunning) {
@ -27,8 +22,7 @@ export class FileWatcher {
recursive: true,
renameDetection: true,
renameTimeout: 125,
ignoreInitial: true,
ignore: (filePath: string): boolean => this.shouldIgnore(filePath)
ignoreInitial: true
});
this.watcher.on("add", (filePath: string) => {
@ -62,32 +56,66 @@ export class FileWatcher {
this.client.logger.info("File watcher stopped");
}
private shouldIgnore(filePath: string): boolean {
const rel = toUnixPath(path.relative(this.basePath, filePath));
return this.ignorePatterns.some((pattern) => matchesGlob(rel, pattern));
}
private handleCreate(relativePath: RelativePath): void {
this.client.syncLocallyCreatedFile(relativePath);
this.client
.syncLocallyCreatedFile(relativePath)
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync created file ${relativePath}: ${this.formatError(err)}`
);
});
}
private handleChange(relativePath: RelativePath): void {
this.client.syncLocallyUpdatedFile({ relativePath });
this.client
.syncLocallyUpdatedFile({ relativePath })
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync updated file ${relativePath}: ${this.formatError(err)}`
);
});
}
private handleDelete(relativePath: RelativePath): void {
this.client.syncLocallyDeletedFile(relativePath);
this.client
.syncLocallyDeletedFile(relativePath)
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync deleted file ${relativePath}: ${this.formatError(err)}`
);
});
}
private handleRename(oldPath: RelativePath, newPath: RelativePath): void {
this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`);
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
});
this.client
.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
})
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync renamed file ${oldPath} -> ${newPath}: ${this.formatError(err)}`
);
});
}
private toRelativePath(absolutePath: string): RelativePath {
return toUnixPath(path.relative(this.basePath, absolutePath));
const relative = path.relative(this.basePath, absolutePath);
return this.toUnixPath(relative);
}
/**
* Convert a native platform path to forward slashes
*/
private toUnixPath(nativePath: string): string {
if (path.sep === "\\") {
return nativePath.replace(/\\/g, "/");
}
return nativePath;
}
private formatError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
}

View file

@ -1,5 +1,4 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* Healthcheck script for Docker container

View file

@ -1,50 +0,0 @@
import { test } from "node:test";
import * as assert from "node:assert/strict";
import { formatLogLine } from "./logger-formatter";
import { LogLevel } from "sync-client";
test("formatLogLine - includes level and message", () => {
const logLine = {
timestamp: new Date("2024-01-15T10:30:45.123Z"),
level: LogLevel.INFO,
message: "Test message"
};
const result = formatLogLine(logLine);
assert.ok(result.includes("INFO"));
assert.ok(result.includes("Test message"));
});
test("formatLogLine - ERROR level messages contain bold escape", () => {
const logLine = {
timestamp: new Date("2024-01-15T10:30:45.123Z"),
level: LogLevel.ERROR,
message: "Error occurred"
};
const result = formatLogLine(logLine);
assert.ok(result.includes("\x1b[1m"));
});
test("formatLogLine - highlights file paths in quotes", () => {
const logLine = {
timestamp: new Date("2024-01-15T10:30:45.123Z"),
level: LogLevel.INFO,
message: 'Syncing "notes/test.md"'
};
const result = formatLogLine(logLine);
assert.ok(result.includes("\x1b[35m"));
});
test("formatLogLine - highlights standalone numbers but not numbers in versions", () => {
const logLine = {
timestamp: new Date("2024-01-15T10:30:45.123Z"),
level: LogLevel.INFO,
message: "Listed 42 files from v1.2.3"
};
const result = formatLogLine(logLine);
assert.ok(result.includes("\x1b[36m42\x1b[0m"));
assert.ok(!result.includes("\x1b[36m1\x1b[0m."));
});

View file

@ -1,21 +1,36 @@
import { LogLevel, type LogLine } from "sync-client";
const colors = {
// ANSI color codes
export const colors = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
// Foreground colors
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
gray: "\x1b[90m"
} as const;
function colorize(text: string, color: keyof typeof colors): string {
export function colorize(text: string, color: keyof typeof colors): string {
return `${colors[color]}${text}${colors.reset}`;
}
/**
* Helper function to apply multiple color modifiers to text
*/
export function styleText(
text: string,
...modifiers: (keyof typeof colors)[]
): string {
const prefix = modifiers.map((m) => colors[m]).join("");
return `${prefix}${text}${colors.reset}`;
}
function formatTimestamp(date: Date): string {
const [time] = date.toTimeString().split(" ");
const ms = date.getMilliseconds().toString().padStart(3, "0");

View file

@ -1,32 +1,31 @@
import * as fs from "fs/promises";
import type { Dirent } from "fs";
import * as path from "path";
import { randomUUID } from "crypto";
import type {
FileSystemOperations,
RelativePath,
TextWithCursors
} from "sync-client";
import { toUnixPath } from "./path-utils";
// VaultLink's per-vault metadata directory. Holds the persisted sync database
// and the tmp files atomicWrite renames into place; the matching `${VAULTLINK_DIR}/**`
// ignore pattern keeps everything in here invisible to the file watcher.
export const VAULTLINK_DIR = ".vaultlink";
export class NodeFileSystemOperations implements FileSystemOperations {
public constructor(private readonly basePath: string) { }
public constructor(private readonly basePath: string) {}
public async listFilesRecursively(
directory: RelativePath | undefined
): Promise<RelativePath[]> {
const files: RelativePath[] = [];
await this.walkDirectory(directory ?? "", files);
await this.walkDirectory(
directory !== undefined ? this.toNativePath(directory) : "",
files
);
return files;
}
public async read(relativePath: RelativePath): Promise<Uint8Array> {
const fullPath = path.join(this.basePath, relativePath);
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
return await fs.readFile(fullPath);
} catch (error) {
@ -40,12 +39,15 @@ export class NodeFileSystemOperations implements FileSystemOperations {
relativePath: RelativePath,
content: Uint8Array
): Promise<void> {
const fullPath = path.join(this.basePath, relativePath);
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
const dir = path.dirname(fullPath);
try {
await fs.mkdir(dir, { recursive: true });
await this.atomicWrite(fullPath, content);
await fs.writeFile(fullPath, content);
} catch (error) {
throw new Error(
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
@ -57,12 +59,15 @@ export class NodeFileSystemOperations implements FileSystemOperations {
relativePath: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
const fullPath = path.join(this.basePath, relativePath);
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
const currentContent = await fs.readFile(fullPath, "utf-8");
const result = updater({ text: currentContent, cursors: [] });
await this.atomicWrite(fullPath, result.text, "utf-8");
await fs.writeFile(fullPath, result.text, "utf-8");
return result.text;
} catch (error) {
throw new Error(
@ -72,7 +77,10 @@ export class NodeFileSystemOperations implements FileSystemOperations {
}
public async getFileSize(relativePath: RelativePath): Promise<number> {
const fullPath = path.join(this.basePath, relativePath);
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
const stats = await fs.stat(fullPath);
return stats.size;
@ -84,7 +92,10 @@ export class NodeFileSystemOperations implements FileSystemOperations {
}
public async exists(relativePath: RelativePath): Promise<boolean> {
const fullPath = path.join(this.basePath, relativePath);
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
await fs.access(fullPath);
return true;
@ -94,7 +105,10 @@ export class NodeFileSystemOperations implements FileSystemOperations {
}
public async createDirectory(relativePath: RelativePath): Promise<void> {
const fullPath = path.join(this.basePath, relativePath);
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
await fs.mkdir(fullPath, { recursive: false });
} catch (error) {
@ -105,7 +119,10 @@ export class NodeFileSystemOperations implements FileSystemOperations {
}
public async delete(relativePath: RelativePath): Promise<void> {
const fullPath = path.join(this.basePath, relativePath);
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
await fs.unlink(fullPath);
} catch (error) {
@ -119,8 +136,14 @@ export class NodeFileSystemOperations implements FileSystemOperations {
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
const oldFullPath = path.join(this.basePath, oldPath);
const newFullPath = path.join(this.basePath, newPath);
const oldFullPath = path.join(
this.basePath,
this.toNativePath(oldPath)
);
const newFullPath = path.join(
this.basePath,
this.toNativePath(newPath)
);
const newDir = path.dirname(newFullPath);
try {
@ -133,44 +156,6 @@ export class NodeFileSystemOperations implements FileSystemOperations {
}
}
private async atomicWrite(
fullPath: string,
content: Uint8Array | string,
encoding?: BufferEncoding
): Promise<void> {
const tmpDir = path.join(this.basePath, VAULTLINK_DIR);
await fs.mkdir(tmpDir, { recursive: true });
const tmpPath = path.join(tmpDir, `atomic-write-${randomUUID()}.tmp`);
try {
await fs.writeFile(tmpPath, content, encoding);
const fd = await fs.open(tmpPath, "r");
try {
await fd.datasync();
} finally {
await fd.close();
}
await fs.rename(tmpPath, fullPath);
await this.syncDirectory(path.dirname(fullPath));
} catch (error) {
await fs.unlink(tmpPath).catch(() => undefined);
throw error;
}
}
// Make the rename durable by fsync'ing the destination's parent directory.
// Skipped on Windows: fsync on a directory handle isn't supported there
private async syncDirectory(dir: string): Promise<void> {
if (process.platform === "win32") {
return;
}
const fd = await fs.open(dir, "r");
try {
await fd.sync();
} finally {
await fd.close();
}
}
private async walkDirectory(
relativePath: string,
files: RelativePath[]
@ -194,8 +179,28 @@ export class NodeFileSystemOperations implements FileSystemOperations {
await this.walkDirectory(entryRelativePath, files);
} else if (entry.isFile()) {
// Always return forward slashes
files.push(toUnixPath(entryRelativePath));
files.push(this.toUnixPath(entryRelativePath));
}
}
}
/**
* Convert a forward-slash path to native platform path separators
*/
private toNativePath(relativePath: string): string {
if (path.sep === "\\") {
return relativePath.replace(/\//g, "\\");
}
return relativePath;
}
/**
* Convert a native platform path to forward slashes
*/
private toUnixPath(nativePath: string): string {
if (path.sep === "\\") {
return nativePath.replace(/\\/g, "/");
}
return nativePath;
}
}

View file

@ -1,60 +0,0 @@
import { test } from "node:test";
import * as assert from "node:assert/strict";
import { matchesGlob, toUnixPath } from "./path-utils";
test("matchesGlob - exact match", () => {
assert.equal(matchesGlob(".DS_Store", ".DS_Store"), true);
assert.equal(matchesGlob("other", ".DS_Store"), false);
});
test("matchesGlob - dir/** matches directory and contents", () => {
assert.equal(matchesGlob(".git", ".git/**"), true);
assert.equal(matchesGlob(".git/config", ".git/**"), true);
assert.equal(matchesGlob(".git/refs/heads/main", ".git/**"), true);
assert.equal(matchesGlob(".gitignore", ".git/**"), false);
});
test("matchesGlob - * matches within a single segment", () => {
assert.equal(matchesGlob("foo.tmp", "*.tmp"), true);
assert.equal(matchesGlob("bar.tmp", "*.tmp"), true);
assert.equal(matchesGlob("foo.md", "*.tmp"), false);
assert.equal(matchesGlob("dir/foo.tmp", "*.tmp"), false);
});
test("matchesGlob - **/*.ext matches at any depth", () => {
assert.equal(matchesGlob("foo.tmp", "**/*.tmp"), true);
assert.equal(matchesGlob("dir/foo.tmp", "**/*.tmp"), true);
assert.equal(matchesGlob("a/b/c/foo.tmp", "**/*.tmp"), true);
assert.equal(matchesGlob("foo.md", "**/*.tmp"), false);
});
test("matchesGlob - ? matches single character", () => {
assert.equal(matchesGlob("a.md", "?.md"), true);
assert.equal(matchesGlob("ab.md", "?.md"), false);
assert.equal(matchesGlob(".md", "?.md"), false);
});
test("matchesGlob - dots are literal", () => {
assert.equal(matchesGlob(".DS_Store", ".DS_Store"), true);
assert.equal(matchesGlob("xDS_Store", ".DS_Store"), false);
});
test("matchesGlob - node_modules/** matches directory tree", () => {
assert.equal(matchesGlob("node_modules", "node_modules/**"), true);
assert.equal(matchesGlob("node_modules/foo", "node_modules/**"), true);
assert.equal(
matchesGlob("node_modules/foo/bar/baz.js", "node_modules/**"),
true
);
assert.equal(matchesGlob("not_node_modules", "node_modules/**"), false);
});
test("matchesGlob - **/ prefix matches zero or more segments", () => {
assert.equal(matchesGlob("test.log", "**/test.log"), true);
assert.equal(matchesGlob("dir/test.log", "**/test.log"), true);
assert.equal(matchesGlob("a/b/test.log", "**/test.log"), true);
});
test("toUnixPath - forward slashes unchanged", () => {
assert.equal(toUnixPath("foo/bar/baz"), "foo/bar/baz");
});

View file

@ -1,21 +0,0 @@
import * as path from "path";
// Convert a native platform path to forward slashes (no-op on non-Windows)
export function toUnixPath(nativePath: string): string {
return nativePath.split(path.sep).join(path.posix.sep);
}
// Match a file path against a glob pattern.
//
// Behaves like Node's path.matchesGlob with one extension: `dir/**` matches
// the directory `dir` itself, not only its descendants. The watcher feeds us
// a directory's relative path (e.g. ".git") at the same time it's about to
// recurse into it, and the natural way for users to write the ignore pattern
// is `.git/**` — under stdlib semantics that pattern would let the directory
// through and only block its children, defeating the prune.
export function matchesGlob(filePath: string, pattern: string): boolean {
if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) {
return true;
}
return path.matchesGlob(filePath, pattern);
}

View file

@ -18,5 +18,7 @@
"declarationMap": true,
"sourceMap": true
},
"exclude": ["dist"]
"exclude": [
"dist"
]
}

View file

@ -2,32 +2,32 @@ const path = require("path");
const webpack = require("webpack");
module.exports = {
entry: {
cli: "./src/cli.ts",
healthcheck: "./src/healthcheck.ts"
},
target: "node",
mode: "production",
optimization: {
minimize: false
},
module: {
rules: [
{
test: /\.ts$/,
use: "ts-loader"
}
]
},
resolve: {
extensions: [".ts", ".js"]
},
output: {
globalObject: "this",
filename: "[name].js",
path: path.resolve(__dirname, "dist")
},
plugins: [
entry: {
cli: "./src/cli.ts",
healthcheck: "./src/healthcheck.ts"
},
target: "node",
mode: "production",
optimization: {
minimize: false
},
module: {
rules: [
{
test: /\.ts$/,
use: "ts-loader"
}
]
},
resolve: {
extensions: [".ts", ".js"]
},
output: {
globalObject: "this",
filename: "[name].js",
path: path.resolve(__dirname, "dist")
},
plugins: [
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
]
]
};

View file

@ -8,7 +8,6 @@ The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definiti
**Note:** The Obsidian API is still in early alpha and is subject to change at any time!
This sample plugin demonstrates some of the basic functionality the plugin API can do.
- Adds a ribbon icon, which shows a Notice when clicked.
- Adds a command "Open Sample Modal" which opens a Modal.
- Adds a plugin setting tab to the settings page.
@ -58,6 +57,31 @@ Quick starting guide for new plugin devs:
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
## Funding URL
You can include funding URLs where people who use your plugin can financially support it.
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
```json
{
"fundingUrl": "https://buymeacoffee.com"
}
```
If you have multiple URLs, you can also do:
```json
{
"fundingUrl": {
"Buy Me a Coffee": "https://buymeacoffee.com",
"GitHub Sponsor": "https://github.com/sponsors",
"Patreon": "https://www.patreon.com/"
}
}
```
## API Documentation
See https://github.com/obsidianmd/obsidian-api

View file

@ -13,25 +13,25 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^25.0.2",
"@types/node": "^24.8.1",
"css-loader": "^7.1.2",
"date-fns": "^4.1.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.3.2",
"mini-css-extract-plugin": "^2.9.4",
"obsidian": "1.11.0",
"reconcile-text": "^0.11.0",
"fs-extra": "^11.3.0",
"mini-css-extract-plugin": "^2.9.2",
"obsidian": "1.10.2",
"reconcile-text": "^0.8.0",
"resolve-url-loader": "^5.0.0",
"sass": "^1.96.0",
"sass": "^1.91.0",
"sass-loader": "^16.0.6",
"sync-client": "file:../sync-client",
"terser-webpack-plugin": "^5.3.16",
"ts-loader": "^9.5.4",
"terser-webpack-plugin": "^5.3.14",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"tsx": "^4.21.0",
"typescript": "5.9.3",
"tsx": "^4.20.6",
"typescript": "5.8.3",
"url": "^0.11.4",
"webpack": "^5.103.0",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
}
}

View file

@ -135,14 +135,14 @@ export default class VaultLinkPlugin extends Plugin {
nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
...(IS_DEBUG_BUILD
? {
fetch: debugging.slowFetchFactory(1),
webSocket: debugging.slowWebSocketFactory(1, new Logger())
}
fetch: debugging.slowFetchFactory(1),
webSocket: debugging.slowWebSocketFactory(1, new Logger())
}
: {})
});
if (IS_DEBUG_BUILD) {
debugging.logToConsole(client.logger);
debugging.logToConsole(client);
}
return client;
@ -231,9 +231,9 @@ export default class VaultLinkPlugin extends Plugin {
}
}
),
this.app.vault.on("create", (file: TAbstractFile) => {
this.app.vault.on("create", async (file: TAbstractFile) => {
if (file instanceof TFile) {
client.syncLocallyCreatedFile(file.path);
await client.syncLocallyCreatedFile(file.path);
}
}),
this.app.vault.on("modify", async (file: TAbstractFile) => {
@ -241,14 +241,14 @@ export default class VaultLinkPlugin extends Plugin {
await this.rateLimitedUpdate(file.path, client);
}
}),
this.app.vault.on("delete", (file: TAbstractFile) => {
client.syncLocallyDeletedFile(file.path);
this.app.vault.on("delete", async (file: TAbstractFile) => {
await client.syncLocallyDeletedFile(file.path);
}),
this.app.vault.on(
"rename",
(file: TAbstractFile, oldPath: string) => {
async (file: TAbstractFile, oldPath: string) => {
if (file instanceof TFile) {
client.syncLocallyUpdatedFile({
await client.syncLocallyUpdatedFile({
oldPath,
relativePath: file.path
});
@ -267,11 +267,13 @@ export default class VaultLinkPlugin extends Plugin {
if (!this.rateLimitedUpdatesPerFile.has(path)) {
this.rateLimitedUpdatesPerFile.set(
path,
rateLimit(async () => {
client.syncLocallyUpdatedFile({
relativePath: path
});
}, MIN_WAIT_BETWEEN_UPDATES_IN_MS)
rateLimit(
async () =>
client.syncLocallyUpdatedFile({
relativePath: path
}),
MIN_WAIT_BETWEEN_UPDATES_IN_MS
)
);
}
await this.rateLimitedUpdatesPerFile.get(path)?.();

View file

@ -14,9 +14,7 @@ export function renderCursorsInFileExplorer(
app: App
): void {
const fileExplorers = app.workspace.getLeavesOfType("file-explorer");
if (fileExplorers.length == 0) {
return;
}
if (fileExplorers.length == 0) return;
const [fileExplorer] = fileExplorers;
@ -36,7 +34,7 @@ export function renderCursorsInFileExplorer(
(parent) => {
cursors.forEach((cursor) => {
cursor.documentsWithCursors.forEach((document) => {
if (document.relativePath.startsWith(key)) {
if (document.relative_path.startsWith(key)) {
parent.appendChild(
createSpan({
text: cursor.userName,

View file

@ -61,7 +61,7 @@ export class RemoteCursorsPluginValue implements PluginValue {
return clientCursors.flatMap((cursor) =>
cursor.cursors.map((span) => ({
name: client.userName,
path: cursor.relativePath,
path: cursor.relative_path,
deviceId: client.deviceId,
isOutdated: client.isOutdated,
span: { ...span }
@ -132,8 +132,7 @@ export class RemoteCursorsPluginValue implements PluginValue {
]
)
},
edited,
"Markdown"
edited
);
reconciled.cursors.forEach(({ id, position }) => {

View file

@ -266,8 +266,9 @@ export class SyncSettingsTab extends PluginSettingTab {
new Notice("Checking connection to the server...");
new Notice(
(await this.syncClient.checkConnection())
.serverMessage
(
await this.syncClient.checkConnection()
).serverMessage
);
await this.statusDescription.updateConnectionState();
} else {
@ -350,6 +351,22 @@ export class SyncSettingsTab extends PluginSettingTab {
})
);
new Setting(containerEl)
.setName("Sync concurrency")
.setDesc(
"How many concurrent sync operations to run. Setting this value higher may increase the overall performance, however, it will require more memory as well. If you notice frequent crashes, especially on mobile, set this to 1."
)
.addSlider((text) =>
text
.setLimits(1, 16, 1)
.setDynamicTooltip()
.setInstant(false)
.setValue(this.syncClient.getSettings().syncConcurrency)
.onChange(async (value) =>
this.syncClient.setSetting("syncConcurrency", value)
)
);
new Setting(containerEl)
.setName("Maximum file size to be uploaded (MB)")
.setDesc(
@ -467,6 +484,40 @@ export class SyncSettingsTab extends PluginSettingTab {
);
})
);
new Setting(containerEl)
.setName("Minimum save interval (ms)")
.setDesc(
"The minimum time between saving settings and database to disk, in milliseconds. Lower values save more frequently but may impact performance."
)
.addText((input) =>
input
.setValue(
this.syncClient
.getSettings()
.minimumSaveIntervalMs.toString()
)
.onChange(async (value) => {
if (value === "") {
return;
}
let parsedValue = Number.parseInt(value, 10);
if (Number.isNaN(parsedValue) || parsedValue < 0) {
parsedValue =
this.syncClient.getSettings()
.minimumSaveIntervalMs;
}
if (value !== parsedValue.toString()) {
input.setValue(parsedValue.toString());
}
return this.syncClient.setSetting(
"minimumSaveIntervalMs",
parsedValue
);
})
);
}
private setStatusDescriptionSubscription(

View file

@ -88,7 +88,7 @@ export class StatusDescription {
text: ` and has indexed approximately `
});
container.createSpan({
text: `${this.syncClient.syncedDocumentCount}`,
text: `${this.syncClient.documentCount}`,
cls: "number"
});
container.createSpan({

View file

@ -6,7 +6,12 @@
"strict": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"lib": ["DOM", "ES2024"]
"lib": [
"DOM",
"ES2024"
]
},
"exclude": ["./dist"]
"exclude": [
"./dist"
]
}

View file

@ -46,7 +46,8 @@ module.exports = (env, argv) => ({
const source = path.resolve(__dirname, "dist");
const destinations = [
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link"
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link",
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
];
destinations.forEach((destination) => {
fs.copy(source, destination)

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,
}))
}