Compare commits
1 commit
main
...
asch/histo
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d4ffa160a |
77 changed files with 4659 additions and 632 deletions
13
frontend/history-ui/index.html
Normal file
13
frontend/history-ui/index.html
Normal 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>
|
||||
16
frontend/history-ui/package.json
Normal file
16
frontend/history-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
78
frontend/history-ui/src/App.svelte
Normal file
78
frontend/history-ui/src/App.svelte
Normal 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>
|
||||
101
frontend/history-ui/src/app.css
Normal file
101
frontend/history-ui/src/app.css
Normal 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);
|
||||
}
|
||||
346
frontend/history-ui/src/components/ActivityFeed.svelte
Normal file
346
frontend/history-ui/src/components/ActivityFeed.svelte
Normal 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"
|
||||
>→</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"
|
||||
>·</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>
|
||||
167
frontend/history-ui/src/components/ConfirmDialog.svelte
Normal file
167
frontend/history-ui/src/components/ConfirmDialog.svelte
Normal 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>
|
||||
508
frontend/history-ui/src/components/Dashboard.svelte
Normal file
508
frontend/history-ui/src/components/Dashboard.svelte
Normal 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)}
|
||||
·
|
||||
{doc.userId}
|
||||
·
|
||||
{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>
|
||||
288
frontend/history-ui/src/components/DiffView.svelte
Normal file
288
frontend/history-ui/src/components/DiffView.svelte
Normal 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">→</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} {/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>
|
||||
729
frontend/history-ui/src/components/DocumentDetail.svelte
Normal file
729
frontend/history-ui/src/components/DocumentDetail.svelte
Normal 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>·</span>
|
||||
<span>{versions.length} version{versions.length !== 1 ? "s" : ""}</span>
|
||||
<span>·</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}
|
||||
·
|
||||
{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} → {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>
|
||||
124
frontend/history-ui/src/components/FileTree.svelte
Normal file
124
frontend/history-ui/src/components/FileTree.svelte
Normal 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>
|
||||
144
frontend/history-ui/src/components/Header.svelte
Normal file
144
frontend/history-ui/src/components/Header.svelte
Normal 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>
|
||||
176
frontend/history-ui/src/components/Login.svelte
Normal file
176
frontend/history-ui/src/components/Login.svelte
Normal 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>
|
||||
191
frontend/history-ui/src/components/TimeSlider.svelte
Normal file
191
frontend/history-ui/src/components/TimeSlider.svelte
Normal 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}
|
||||
·
|
||||
{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>
|
||||
80
frontend/history-ui/src/components/ToastContainer.svelte
Normal file
80
frontend/history-ui/src/components/ToastContainer.svelte
Normal 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)}
|
||||
>
|
||||
×
|
||||
</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>
|
||||
198
frontend/history-ui/src/components/VaultPicker.svelte
Normal file
198
frontend/history-ui/src/components/VaultPicker.svelte
Normal 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>
|
||||
146
frontend/history-ui/src/lib/api.ts
Normal file
146
frontend/history-ui/src/lib/api.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
290
frontend/history-ui/src/lib/stores.svelte.ts
Normal file
290
frontend/history-ui/src/lib/stores.svelte.ts
Normal 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));
|
||||
}
|
||||
8
frontend/history-ui/src/lib/types/ClientCursors.ts
Normal file
8
frontend/history-ui/src/lib/types/ClientCursors.ts
Normal 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>;
|
||||
};
|
||||
|
|
@ -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>;
|
||||
};
|
||||
|
|
@ -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>;
|
||||
};
|
||||
|
|
@ -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> };
|
||||
3
frontend/history-ui/src/lib/types/CursorSpan.ts
Normal file
3
frontend/history-ui/src/lib/types/CursorSpan.ts
Normal 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 };
|
||||
10
frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts
Normal file
10
frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts
Normal 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);
|
||||
12
frontend/history-ui/src/lib/types/DocumentVersion.ts
Normal file
12
frontend/history-ui/src/lib/types/DocumentVersion.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
9
frontend/history-ui/src/lib/types/DocumentWithCursors.ts
Normal file
9
frontend/history-ui/src/lib/types/DocumentWithCursors.ts
Normal 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>;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
11
frontend/history-ui/src/lib/types/ListVaultsResponse.ts
Normal file
11
frontend/history-ui/src/lib/types/ListVaultsResponse.ts
Normal 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;
|
||||
};
|
||||
25
frontend/history-ui/src/lib/types/PingResponse.ts
Normal file
25
frontend/history-ui/src/lib/types/PingResponse.ts
Normal 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;
|
||||
};
|
||||
7
frontend/history-ui/src/lib/types/SerializedError.ts
Normal file
7
frontend/history-ui/src/lib/types/SerializedError.ts
Normal 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>;
|
||||
};
|
||||
|
|
@ -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>;
|
||||
};
|
||||
10
frontend/history-ui/src/lib/types/VaultHistoryResponse.ts
Normal file
10
frontend/history-ui/src/lib/types/VaultHistoryResponse.ts
Normal 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;
|
||||
};
|
||||
10
frontend/history-ui/src/lib/types/VaultInfo.ts
Normal file
10
frontend/history-ui/src/lib/types/VaultInfo.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
7
frontend/history-ui/src/lib/types/WebSocketHandshake.ts
Normal file
7
frontend/history-ui/src/lib/types/WebSocketHandshake.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 };
|
||||
22
frontend/history-ui/src/lib/view-types.ts
Normal file
22
frontend/history-ui/src/lib/view-types.ts
Normal 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;
|
||||
}
|
||||
7
frontend/history-ui/src/main.ts
Normal file
7
frontend/history-ui/src/main.ts
Normal 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;
|
||||
5
frontend/history-ui/svelte.config.js
Normal file
5
frontend/history-ui/svelte.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess()
|
||||
};
|
||||
16
frontend/history-ui/tsconfig.json
Normal file
16
frontend/history-ui/tsconfig.json
Normal 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"]
|
||||
}
|
||||
15
frontend/history-ui/vite.config.ts
Normal file
15
frontend/history-ui/vite.config.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 .
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
#!/usr/bin/env node
|
||||
/* eslint-disable no-console */
|
||||
|
||||
/**
|
||||
* Healthcheck script for Docker container
|
||||
|
|
|
|||
|
|
@ -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."));
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -18,5 +18,7 @@
|
|||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"exclude": ["dist"]
|
||||
"exclude": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
]
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)?.();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@
|
|||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": ["DOM", "ES2024"]
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES2024"
|
||||
]
|
||||
},
|
||||
"exclude": ["./dist"]
|
||||
"exclude": [
|
||||
"./dist"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
"obsidian-plugin",
|
||||
"test-client",
|
||||
"deterministic-tests",
|
||||
"local-client-cli"
|
||||
"local-client-cli",
|
||||
"history-ui"
|
||||
],
|
||||
"prettier": {
|
||||
"trailingComma": "none",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
10
frontend/sync-client/src/services/types/VaultInfo.ts
Normal file
10
frontend/sync-client/src/services/types/VaultInfo.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
42
sync-server/src/server/fetch_document_versions.rs
Normal file
42
sync-server/src/server/fetch_document_versions.rs
Normal 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))
|
||||
}
|
||||
70
sync-server/src/server/fetch_vault_history.rs
Normal file
70
sync-server/src/server/fetch_vault_history.rs
Normal 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 }))
|
||||
}
|
||||
|
|
@ -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"))),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
82
sync-server/src/server/list_vaults.rs
Normal file
82
sync-server/src/server/list_vaults.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue