Compare commits

...

26 commits

Author SHA1 Message Date
3ba0b7a88b wip again
Some checks failed
Check / build (pull_request) Failing after 9s
E2E tests / build (pull_request) Failing after 9s
Publish CLI / publish-docker (pull_request) Failing after 2m46s
Publish server Docker image / publish-docker (pull_request) Failing after 34m36s
2026-04-08 08:06:30 +01:00
53bfbfaa4a .. 2026-04-07 21:28:52 +01:00
5a4723cd00 renames 2026-04-07 21:03:21 +01:00
d5958fcbaa . 2026-04-06 22:01:10 +01:00
1a4e39d57a wip better queue 2026-04-06 21:55:21 +01:00
d034ad5cb3 WIP 2026-04-06 13:01:47 +01:00
0e3e5a99cd Update tests 2026-04-06 13:01:34 +01:00
64ca5a82ef Fix lints 2026-04-06 11:17:18 +01:00
3784418567 Fix testing setup 2026-04-01 23:13:46 +01:00
0897f7a545 Make hash async 2026-04-01 22:29:57 +01:00
22dfdc069b Update imports 2026-04-01 21:58:57 +01:00
7c203bc5c9 Fix mock client event triggering 2026-04-01 21:57:42 +01:00
19e4c39f44 Parallel clean up 2026-04-01 21:46:29 +01:00
03b5c223d6 Reconcile outside of async 2026-04-01 21:46:00 +01:00
1bb1ca99dd Delete shouldn't move 2026-04-01 21:45:45 +01:00
4aeec1b021 Use tempfs 2026-04-01 21:38:57 +01:00
adad2d5703 No more create promis 2026-03-28 18:16:22 +00:00
44947dc3a5 Add vault listing endpoint 2026-03-28 18:15:43 +00:00
9ae1a5e09e Add sync event queue 2026-03-28 17:24:45 +00:00
f3d985cc57 Remove duplicate errors 2026-03-28 12:07:57 +00:00
1c6cd80b64 Update hashing 2026-03-28 12:07:44 +00:00
65d75dec40 Copy types 2026-03-28 12:07:14 +00:00
48234de10d Update types 2026-03-28 11:57:50 +00:00
4493365076 Simplify syncing logic 2026-03-28 11:55:37 +00:00
e8c57b3a37 Extend E2E assertions 2026-03-28 11:46:06 +00:00
904a2737d4 Bump deps, improve e2e test and pick up changes in the plugin 2026-03-28 11:17:18 +00:00
154 changed files with 9976 additions and 6034 deletions

9
.gitignore vendored
View file

@ -7,15 +7,18 @@ node_modules
# Frontend build folders
frontend/*/dist
sync-server/db.sqlite3*
sync-server/databases
# Rust build folders
sync-server/target
sync-server/artifacts
sync-server/bindings/*.ts
# build folders
sync-server/db.sqlite3*
**/databases
*.log
*.sqlx
target
.task

110
CLAUDE.md
View file

@ -1,110 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
VaultLink is a self-hosted Obsidian plugin for real-time collaborative file syncing. The project consists of a Rust-based sync server and a TypeScript frontend with three main components: an Obsidian plugin, a sync client library, and a test client.
## Architecture
### Core Components
- **sync-server/**: Rust-based WebSocket server with SQLite database for document versioning and real-time synchronization
- **frontend/sync-client/**: TypeScript library providing core sync functionality, WebSocket management, and file operations
- **frontend/obsidian-plugin/**: Obsidian plugin that integrates the sync client with Obsidian's API
- **frontend/test-client/**: CLI testing tool for the sync functionality
### Key Technologies
- **Backend**: Rust with Axum framework, SQLite with SQLx, WebSockets for real-time sync
- **Frontend**: TypeScript, Webpack for bundling, Jest for testing
- **Sync Algorithm**: Uses reconcile-text library for operational transformation
## Development Commands
### Server Development
```bash
cd sync-server
cargo run config-e2e.yml # Start development server
cargo test --verbose # Run Rust tests
cargo clippy --all-targets --all-features # Lint Rust code
cargo clippy --all-targets --all-features --fix --allow-dirty --allow-staged # Auto-fix clippy warnings
cargo fmt --all -- --check # Check Rust formatting
cargo fmt --all # Auto-format Rust code
cargo machete --with-metadata # Detect unused dependencies
```
### Frontend Development
```bash
cd frontend
npm run dev # Start development mode (watches sync-client and obsidian-plugin)
npm run build # Build all workspaces
npm run test # Run all tests
npm run lint # Lint and format TypeScript code
```
### Database Setup (Development)
```bash
cd sync-server
sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
cargo sqlx prepare --workspace
```
### Initial Setup
```bash
# Install required cargo tools
cargo install sqlx-cli cargo-machete cargo-edit
```
### Scripts
- `scripts/check.sh`: Full CI check (builds, lints, tests both server and frontend)
- `scripts/check.sh --fix`: Same as above but auto-fixes linting and formatting issues
- `scripts/e2e.sh`: End-to-end testing
- `scripts/clean-up.sh`: Clean logs and database files
- `scripts/bump-version.sh patch`: Publish new version
- `scripts/update-api-types.sh`: Update TypeScript bindings from Rust types
## Code Structure
### Workspace Configuration
The frontend uses npm workspaces with four packages:
- `sync-client`: Core synchronization logic
- `obsidian-plugin`: Obsidian-specific integration
- `test-client`: Testing utilities
- `local-client-cli`: Standalone CLI for VaultLink sync client
### Type Generation
Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/bindings/` and used by frontend packages.
### Key Files
- `sync-server/src/`: Rust server implementation with WebSocket handlers
- `frontend/sync-client/src/sync-client.ts`: Main sync client entry point
- `frontend/obsidian-plugin/src/vault-link-plugin.ts`: Main Obsidian plugin class
- `frontend/sync-client/src/services/sync-service.ts`: Core synchronization logic
## Testing
### Running Tests
- Server: `cargo test --verbose`
- Frontend: `npm run test` (runs Jest across all workspaces)
- E2E: `scripts/e2e.sh`
### Test Structure
- Rust: Unit tests alongside source files
- TypeScript: `.test.ts` files using Jest
- E2E: Uses test-client to simulate multiple concurrent users
## Code Style
### Rust
- Uses extensive Clippy lints (see Cargo.toml)
- Follows pedantic linting rules
- Forbids unsafe code
- Uses cargo fmt with default settings
### TypeScript
- Prettier configuration: 4-space tabs, trailing commas removed, LF line endings
- ESLint with unused imports plugin
- Consistent across all three frontend packages

View file

@ -8,12 +8,12 @@
## Develop
### Install [nvm](https://github.com/nvm-sh/nvm)
### Set up Node.JS 25 with [nvm](https://github.com/nvm-sh/nvm)
- `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash`
- `nvm install 22`
- `nvm use 22`
- Optionally set the system-wide default: `nvm alias default 22`
- `nvm install 25`
- `nvm use 25`
- Optionally, set the system-wide default: `nvm alias default 25`
### Set up Rust

View file

@ -24,6 +24,9 @@ Clients always start with syncing disabled.
- `barrier` — retry until all clients converge to identical file state (60s timeout)
- `enable-sync` / `disable-sync` — simulate going online/offline
**WebSocket control** (per-client):
- `pause-websocket` / `resume-websocket` — buffer/release WebSocket messages for a specific client
**Server control:**
- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process

View file

@ -6,7 +6,7 @@ export const STOP_TIMEOUT_MS = 5_000;
export const CONVERGENCE_TIMEOUT_MS = 60_000;
export const CONVERGENCE_RETRY_DELAY_MS = 500;
export const AGENT_INIT_TIMEOUT_MS = 30_000;
export const IS_SYNC_ENABLED_DEFAULT = false;
export const IS_SYNC_ENABLED_BY_DEFAULT = false;
export const WAIT_TIMEOUT_MS = 60_000;
export const WEBSOCKET_CONNECT_TIMEOUT_MS = 10_000;

View file

@ -3,7 +3,8 @@ import { SyncClient, debugging, LogLevel } from "sync-client";
import { assert } from "./utils/assert";
import { sleep } from "./utils/sleep";
import { withTimeout } from "./utils/with-timeout";
import { IS_SYNC_ENABLED_DEFAULT, WAIT_TIMEOUT_MS, WEBSOCKET_CONNECT_TIMEOUT_MS, WEBSOCKET_POLL_INTERVAL_MS } from "./consts";
import { IS_SYNC_ENABLED_BY_DEFAULT, WAIT_TIMEOUT_MS, WEBSOCKET_CONNECT_TIMEOUT_MS, WEBSOCKET_POLL_INTERVAL_MS } from "./consts";
import { ManagedWebSocketFactory } from "./managed-websocket";
@ -15,9 +16,10 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>;
}> = {};
private isSyncEnabled = IS_SYNC_ENABLED_DEFAULT;
private isSyncEnabled = IS_SYNC_ENABLED_BY_DEFAULT;
private readonly syncErrors: Error[] = [];
private readonly pendingSyncOperations = new Set<Promise<void>>();
private readonly wsFactory = new ManagedWebSocketFactory();
public constructor(
clientId: number,
@ -32,7 +34,6 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
public async init(
fetchImplementation: typeof globalThis.fetch,
webSocketImplementation: typeof globalThis.WebSocket
): Promise<void> {
this.client = await SyncClient.create({
fs: this,
@ -41,7 +42,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
save: async (data) => void (this.data = data)
},
fetch: fetchImplementation,
webSocket: webSocketImplementation
webSocket: this.wsFactory.constructorFn
});
this.client.logger.onLogEmitted.add((line) => {
@ -75,68 +76,14 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
}
}
public async createFile(path: string, content: string): Promise<void> {
this.log(`Creating file ${path} with content: ${content}`);
if (this.files.has(path)) {
throw new Error(`File ${path} already exists`);
}
const contentBytes = new TextEncoder().encode(content);
this.files.set(path, contentBytes);
if (this.isSyncEnabled) {
this.enqueueSync(async () =>
this.client.syncLocallyCreatedFile(path)
);
}
public pauseWebSocket(): void {
this.log("Pausing WebSocket message delivery");
this.wsFactory.pause();
}
public async updateFile(path: string, content: string): Promise<void> {
this.log(`Updating file ${path} with content: ${content}`);
if (!this.files.has(path)) {
throw new Error(
`File ${path} does not exist on client ${this.clientId}`
);
}
const contentBytes = new TextEncoder().encode(content);
this.files.set(path, contentBytes);
if (this.isSyncEnabled) {
this.enqueueSync(async () =>
this.client.syncLocallyUpdatedFile({ relativePath: path })
);
}
}
public async renameFile(oldPath: string, newPath: string): Promise<void> {
this.log(`Renaming file ${oldPath} to ${newPath}`);
const file = this.files.get(oldPath);
if (!file) {
throw new Error(
`File ${oldPath} does not exist on client ${this.clientId}`
);
}
this.files.set(newPath, file);
if (oldPath !== newPath) {
this.files.delete(oldPath);
}
if (this.isSyncEnabled) {
this.enqueueSync(async () =>
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
})
);
}
}
public async deleteFile(path: string): Promise<void> {
this.log(`Deleting file ${path}`);
this.files.delete(path);
if (this.isSyncEnabled) {
this.enqueueSync(async () =>
this.client.syncLocallyDeletedFile(path)
);
}
public resumeWebSocket(): void {
this.log("Resuming WebSocket message delivery");
this.wsFactory.resume();
}
public async waitForSync(): Promise<void> {
@ -191,9 +138,6 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
await this.waitForWebSocket();
}
public async getFiles(): Promise<RelativePath[]> {
return this.listFilesRecursively();
}
public async getFileContent(path: string): Promise<string> {
const bytes = await this.read(path);
@ -226,10 +170,6 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
this.log("Cleanup complete");
}
// Yield the event loop before each FS operation so that the SyncClient's
// async calls create real interleaving points, matching the behavior of
// actual disk I/O. Without this, all FS operations resolve in the same
// microtask, hiding concurrency bugs that only manifest with real latency.
public override async read(path: RelativePath): Promise<Uint8Array> {
await Promise.resolve();
return super.read(path);
@ -240,33 +180,50 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
content: Uint8Array
): Promise<void> {
await Promise.resolve();
return super.write(path, content);
const isNew = !this.files.has(path);
await super.write(path, content);
if (isNew) {
this.enqueueSync(async () => { this.client.syncLocallyCreatedFile(path); }
);
} else {
this.enqueueSync(async () => { this.client.syncLocallyUpdatedFile({ relativePath: path }); }
);
}
}
public override async atomicUpdateText(
path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
await Promise.resolve();
return super.atomicUpdateText(path, updater);
const result = await super.atomicUpdateText(path, updater);
this.enqueueSync(async () => { this.client.syncLocallyUpdatedFile({ relativePath: path }); }
);
return result;
}
public override async exists(path: RelativePath): Promise<boolean> {
await Promise.resolve();
return super.exists(path);
}
public override async delete(path: RelativePath): Promise<void> {
await Promise.resolve();
return super.delete(path);
await super.delete(path);
if (this.isSyncEnabled) {
this.enqueueSync(async () => { this.client.syncLocallyDeletedFile(path); }
);
}
}
public override async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
await Promise.resolve();
return super.rename(oldPath, newPath);
await super.rename(oldPath, newPath);
this.enqueueSync(async () => {
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
});
}
);
}
private async waitForWebSocket(): Promise<void> {

View file

@ -0,0 +1,170 @@
/**
* A WebSocket wrapper that can pause and resume message delivery.
* When paused, incoming messages are buffered. When resumed, buffered
* messages are delivered in order via the onmessage handler.
*/
export class ManagedWebSocket implements WebSocket {
private readonly ws: WebSocket;
private paused = false;
private readonly bufferedMessages: MessageEvent[] = [];
private externalOnMessage: ((event: MessageEvent) => unknown) | null = null;
public constructor(url: string | URL, protocols?: string | string[]) {
this.ws = new WebSocket(url, protocols);
this.ws.onmessage = (event: MessageEvent): void => {
if (this.paused) {
this.bufferedMessages.push(event);
} else {
this.externalOnMessage?.(event);
}
};
}
public pause(): void {
this.paused = true;
}
public resume(): void {
this.paused = false;
const messages = this.bufferedMessages.splice(0);
for (const msg of messages) {
this.externalOnMessage?.(msg);
}
}
get readyState(): number {
return this.ws.readyState;
}
get url(): string {
return this.ws.url;
}
get protocol(): string {
return this.ws.protocol;
}
get extensions(): string {
return this.ws.extensions;
}
get bufferedAmount(): number {
return this.ws.bufferedAmount;
}
get binaryType(): BinaryType {
return this.ws.binaryType;
}
set binaryType(value: BinaryType) {
this.ws.binaryType = value;
}
get onopen(): ((this: WebSocket, ev: Event) => unknown) | null {
return this.ws.onopen;
}
set onopen(handler: ((this: WebSocket, ev: Event) => unknown) | null) {
this.ws.onopen = handler;
}
get onclose(): ((this: WebSocket, ev: CloseEvent) => unknown) | null {
return this.ws.onclose;
}
set onclose(handler: ((this: WebSocket, ev: CloseEvent) => unknown) | null) {
this.ws.onclose = handler;
}
get onerror(): ((this: WebSocket, ev: Event) => unknown) | null {
return this.ws.onerror;
}
set onerror(handler: ((this: WebSocket, ev: Event) => unknown) | null) {
this.ws.onerror = handler;
}
get onmessage(): ((this: WebSocket, ev: MessageEvent) => unknown) | null {
return this.externalOnMessage;
}
set onmessage(
handler: ((this: WebSocket, ev: MessageEvent) => unknown) | null
) {
this.externalOnMessage = handler;
}
public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
this.ws.send(data);
}
public close(code?: number, reason?: string): void {
this.ws.close(code, reason);
}
public addEventListener(
...args: Parameters<WebSocket["addEventListener"]>
): void {
this.ws.addEventListener(...args);
}
public removeEventListener(
...args: Parameters<WebSocket["removeEventListener"]>
): void {
this.ws.removeEventListener(...args);
}
public dispatchEvent(event: Event): boolean {
return this.ws.dispatchEvent(event);
}
static readonly CONNECTING = WebSocket.CONNECTING;
static readonly OPEN = WebSocket.OPEN;
static readonly CLOSING = WebSocket.CLOSING;
static readonly CLOSED = WebSocket.CLOSED;
readonly CONNECTING = WebSocket.CONNECTING;
readonly OPEN = WebSocket.OPEN;
readonly CLOSING = WebSocket.CLOSING;
readonly CLOSED = WebSocket.CLOSED;
}
/**
* Factory that creates ManagedWebSocket instances and tracks them
* for pause/resume control from the test harness
*/
export class ManagedWebSocketFactory {
private readonly instances: ManagedWebSocket[] = [];
public get constructorFn(): typeof globalThis.WebSocket {
const factory = this;
const ctor = function ManagedWS(
url: string | URL,
protocols?: string | string[]
): ManagedWebSocket {
const ws = new ManagedWebSocket(url, protocols);
factory.instances.push(ws);
return ws;
} as unknown as typeof globalThis.WebSocket;
Object.defineProperty(ctor, "CONNECTING", { value: WebSocket.CONNECTING });
Object.defineProperty(ctor, "OPEN", { value: WebSocket.OPEN });
Object.defineProperty(ctor, "CLOSING", { value: WebSocket.CLOSING });
Object.defineProperty(ctor, "CLOSED", { value: WebSocket.CLOSED });
return ctor;
}
public pause(): void {
for (const ws of this.instances) {
ws.pause();
}
}
public resume(): void {
for (const ws of this.instances) {
ws.resume();
}
}
}

View file

@ -40,8 +40,10 @@ export class ServerControl {
const reservation = await findFreePort();
this._port = reservation.port;
// Prefer tmpfs (/host/tmp) over disk-backed /tmp for faster SQLite I/O
const tmpBase = fs.existsSync("/host/tmp") ? "/host/tmp" : os.tmpdir();
this.tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), "vault-link-test-")
path.join(tmpBase, "vault-link-test-")
);
const tempConfigPath = path.join(this.tempDir, "config.yml");
const dbDir = path.join(this.tempDir, "databases");

View file

@ -16,7 +16,9 @@ export type TestStep =
| { type: "pause-server" }
| { type: "resume-server" }
| { type: "barrier" }
| { type: "assert-consistent"; verify?: (state: AssertableState) => void };
| { type: "assert-consistent"; verify?: (state: AssertableState) => void }
| { type: "pause-websocket"; client: number }
| { type: "resume-websocket"; client: number };
export interface TestDefinition {
description?: string;

View file

@ -49,7 +49,7 @@ import { offlineMoveThenRemoteDeleteTest } from "./tests/offline-move-then-remot
import { resetClearsRecentlyDeletedResurrectionTest } from "./tests/reset-clears-recently-deleted-resurrection.test";
import { moveThenDeleteStalePathTest } from "./tests/move-then-delete-stale-path.test";
import { interruptedDeleteRetryTest } from "./tests/interrupted-delete-retry.test";
import { updateSurvivesRemoteDeleteTest } from "./tests/update-survives-remote-delete.test";
import { updateDoesNotSurvivesRemoteDeleteTest } from "./tests/update-survives-remote-delete.test";
import { movePreservesRemoteUpdateTest } from "./tests/move-preserves-remote-update.test";
import { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.test";
import { migrateKeyPreservesExistingTest } from "./tests/migrate-key-preserves-existing.test";
@ -65,6 +65,33 @@ import { createRenameResponseSkipsFileTest } from "./tests/create-rename-respons
import { onlineCreateRenameConcurrentCreateOrphanTest } from "./tests/online-create-rename-concurrent-create-orphan.test";
import { concurrentRenameFirstWinsTest } from "./tests/concurrent-rename-first-wins.test";
import { binaryToTextTransitionTest } from "./tests/binary-to-text-transition.test";
import { textPendingCreateNotDisplacedTest } from "./tests/1-text-pending-create-not-displaced.test";
import { binaryPendingCreateNotDisplacedTest } from "./tests/2-binary-pending-create-not-displaced.test";
import { coalesceUpdateRemoteUpdateDataLossTest } from "./tests/3-coalesce-update-remote-update-data-loss.test";
import { coalescedRemoteUpdateWatermarkLossTest } from "./tests/4-coalesced-remote-update-watermark-loss.test";
import { concurrentDeleteDuringRemoteUpdateTest } from "./tests/5-concurrent-delete-during-remote-update.test";
import { concurrentEditExactSamePositionTest } from "./tests/6-concurrent-edit-exact-same-position.test";
import { concurrentRenameAndCreateAtTargetTest as concurrentRenameAndCreateAtTargetRenameFirstTest } from "./tests/7-concurrent-rename-and-create-at-target.test";
import { concurrentRenameAndCreateAtTargetTest as concurrentRenameAndCreateAtTargetCreateFirstTest } from "./tests/8-concurrent-rename-and-create-at-target.test";
import { concurrentRenameSameTargetTest } from "./tests/9-concurrent-rename-same-target.test";
import { concurrentUpdateDiffConsistencyTest } from "./tests/10-concurrent-update-diff-consistency.test";
import { userParenthesizedFileNotDeletedTest } from "./tests/10-user-parenthesized-file-not-deleted.test";
import { createDeleteNoopTest } from "./tests/11-create-delete-noop.test";
import { createMergeDeleteTest } from "./tests/12-create-merge-delete.test";
import { moveIdenticalContentAmbiguityTest } from "./tests/13-move-identical-content-ambiguity.test";
import { createUpdateCoalesceServerPauseTest } from "./tests/15-create-update-coalesce-server-pause.test";
import { createDuringReconciliationTest } from "./tests/16-create-during-reconciliation.test";
import { createMergePreservesRenamedUpdateTest } from "./tests/17-create-merge-preserves-renamed-update.test";
import { createRenameCreateSamePathTest } from "./tests/18-create-rename-create-same-path.test";
import { moveChainThreeFilesTest } from "./tests/19-move-chain-three-files.test";
import { deleteByOtherClientThenRecreateTest } from "./tests/delete-by-other-client-then-recreate.test";
import { onlineDeleteRecreateRapidCycleTest } from "./tests/online-delete-recreate-rapid-cycle.test";
import { onlineEditVsDeleteConvergenceTest } from "./tests/online-edit-vs-delete-convergence.test";
import { rapidEditDeleteOnlineConvergenceTest } from "./tests/rapid-edit-delete-online-convergence.test";
import { serverPauseDeleteRecreateTest } from "./tests/server-pause-delete-recreate.test";
import { onlineBothCreateSamePathDeconflictTest } from "./tests/online-both-create-same-path-deconflict.test";
import { onlineCreateUpdateWhileOtherCreatesSamePathTest } from "./tests/online-create-update-while-other-creates-same-path.test";
import { displacedFileNotMarkedDeletedTest } from "./tests/displaced-file-not-marked-deleted.test";
export const TESTS: Partial<Record<string, TestDefinition>> = {
"rename-create-conflict": renameCreateConflictTest,
@ -117,7 +144,7 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
"move-then-delete-stale-path": moveThenDeleteStalePathTest,
"offline-delete-vs-remote-update": offlineDeleteVsRemoteUpdateTest,
"interrupted-delete-retry": interruptedDeleteRetryTest,
"update-survives-remote-delete": updateSurvivesRemoteDeleteTest,
"update-survives-remote-delete": updateDoesNotSurvivesRemoteDeleteTest,
"move-preserves-remote-update": movePreservesRemoteUpdateTest,
"recently-deleted-cleared-on-reconnect": recentlyDeletedClearedOnReconnectTest,
"migrate-key-preserves-existing": migrateKeyPreservesExistingTest,
@ -133,4 +160,31 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
"online-create-rename-concurrent-create-orphan": onlineCreateRenameConcurrentCreateOrphanTest,
"concurrent-rename-first-wins": concurrentRenameFirstWinsTest,
"binary-to-text-transition": binaryToTextTransitionTest,
"text-pending-create-not-displaced": textPendingCreateNotDisplacedTest,
"binary-pending-create-not-displaced": binaryPendingCreateNotDisplacedTest,
"coalesce-update-remote-update-data-loss": coalesceUpdateRemoteUpdateDataLossTest,
"coalesced-remote-update-watermark-loss": coalescedRemoteUpdateWatermarkLossTest,
"concurrent-delete-during-remote-update": concurrentDeleteDuringRemoteUpdateTest,
"concurrent-edit-exact-same-position": concurrentEditExactSamePositionTest,
"concurrent-rename-and-create-at-target-rename-first": concurrentRenameAndCreateAtTargetRenameFirstTest,
"concurrent-rename-and-create-at-target-create-first": concurrentRenameAndCreateAtTargetCreateFirstTest,
"concurrent-rename-same-target": concurrentRenameSameTargetTest,
"concurrent-update-diff-consistency": concurrentUpdateDiffConsistencyTest,
"user-parenthesized-file-not-deleted": userParenthesizedFileNotDeletedTest,
"create-delete-noop": createDeleteNoopTest,
"create-merge-delete": createMergeDeleteTest,
"move-identical-content-ambiguity": moveIdenticalContentAmbiguityTest,
"create-update-coalesce-server-pause": createUpdateCoalesceServerPauseTest,
"create-during-reconciliation": createDuringReconciliationTest,
"create-merge-preserves-renamed-update": createMergePreservesRenamedUpdateTest,
"create-rename-create-same-path": createRenameCreateSamePathTest,
"move-chain-three-files": moveChainThreeFilesTest,
"delete-by-other-client-then-recreate": deleteByOtherClientThenRecreateTest,
"online-delete-recreate-rapid-cycle": onlineDeleteRecreateRapidCycleTest,
"online-edit-vs-delete-convergence": onlineEditVsDeleteConvergenceTest,
"rapid-edit-delete-online-convergence": rapidEditDeleteOnlineConvergenceTest,
"server-pause-delete-recreate": serverPauseDeleteRecreateTest,
"online-both-create-same-path-deconflict": onlineBothCreateSamePathDeconflictTest,
"online-create-update-while-other-creates-same-path": onlineCreateUpdateWhileOtherCreatesSamePathTest,
"displaced-file-not-marked-deleted": displacedFileNotMarkedDeletedTest,
};

View file

@ -14,7 +14,7 @@ import {
CONVERGENCE_TIMEOUT_MS,
CONVERGENCE_RETRY_DELAY_MS,
AGENT_INIT_TIMEOUT_MS,
IS_SYNC_ENABLED_DEFAULT
IS_SYNC_ENABLED_BY_DEFAULT
} from "./consts";
import { randomUUID } from "node:crypto";
@ -100,7 +100,7 @@ export class TestRunner {
for (let i = 0; i < count; i++) {
const settings: Partial<SyncSettings> = {
isSyncEnabled: IS_SYNC_ENABLED_DEFAULT,
isSyncEnabled: IS_SYNC_ENABLED_BY_DEFAULT,
token: this.token,
vaultName,
remoteUri: this.remoteUri
@ -115,8 +115,6 @@ export class TestRunner {
await withTimeout(
agent.init(
fetch,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
WebSocket as unknown as typeof globalThis.WebSocket
),
AGENT_INIT_TIMEOUT_MS,
`Client ${i} init timed out after ${AGENT_INIT_TIMEOUT_MS}ms`
@ -138,28 +136,22 @@ export class TestRunner {
private async executeStep(step: TestStep): Promise<void> {
switch (step.type) {
case "create":
await this.getAgent(step.client).createFile(
step.path,
step.content
);
break;
case "update":
await this.getAgent(step.client).updateFile(
await this.getAgent(step.client).write(
step.path,
step.content
new TextEncoder().encode(step.content)
);
break;
case "rename":
await this.getAgent(step.client).renameFile(
await this.getAgent(step.client).rename(
step.oldPath,
step.newPath
);
break;
case "delete":
await this.getAgent(step.client).deleteFile(step.path);
await this.getAgent(step.client).delete(step.path);
break;
case "sync":
@ -199,6 +191,14 @@ export class TestRunner {
await this.assertConsistent(step.verify);
break;
case "pause-websocket":
this.getAgent(step.client).pauseWebSocket();
break;
case "resume-websocket":
this.getAgent(step.client).resumeWebSocket();
break;
default: {
const unknownStep = step as { type: string };
throw new Error(`Unknown step type: ${unknownStep.type}`);
@ -282,7 +282,7 @@ export class TestRunner {
// where background sync could mutate state between reads.
const clientFiles: Map<string, string>[] = [];
for (const agent of this.agents) {
const sortedFiles = (await agent.getFiles()).sort();
const sortedFiles = (await agent.listFilesRecursively()).sort();
const fileMap = new Map<string, string>();
for (const file of sortedFiles) {
const content = await agent.getFileContent(file);

View file

@ -10,19 +10,19 @@ export const textPendingCreateNotDisplacedTest: TestDefinition = {
type: "create",
client: 0,
path: "data.txt",
content: "text data from client 0"
content: "text data from client-0"
},
{
type: "create",
client: 1,
path: "data.txt",
content: "text data from client 1"
content: "text data from client-1"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileExists("data.txt").assertAnyFileContains("data from client 0", "data from client 1") }
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileExists("data.txt").assertAnyFileContains("client-0", "client-1") }
]
};

View file

@ -2,9 +2,10 @@ import type { TestDefinition } from "../test-definition";
export const binaryToTextTransitionTest: TestDefinition = {
description:
"A .bin file is created and synced. Both clients edit it offline, " +
"then it is renamed to .md. Both clients edit different sections " +
"offline again. The second merge should preserve both edits.",
"A .bin file is created and synced. Both clients edit it offline " +
"(binary last-write-wins), then client 0 renames it to .md and " +
"writes a clean text baseline. Both clients edit different sections " +
"offline. The text merge should preserve both edits.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "data.bin", content: "original content" },
@ -16,32 +17,33 @@ export const binaryToTextTransitionTest: TestDefinition = {
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "update", client: 0, path: "data.bin", content: "version A from client 0" },
{ type: "update", client: 1, path: "data.bin", content: "version B from client 1" },
{ type: "update", client: 0, path: "data.bin", content: "version A" },
{ type: "update", client: 1, path: "data.bin", content: "version B" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContainsAny("data.bin", "version A from client 0", "version B from client 1") },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContainsAny("data.bin", "version A", "version B") },
{ type: "disable-sync", client: 1 },
{ type: "rename", client: 0, oldPath: "data.bin", newPath: "data.md" },
{ type: "update", client: 0, path: "data.md", content: "top line\nmiddle line\nbottom line" },
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileExists("data.md") },
{ type: "assert-consistent", verify: (s) => s.assertContent("data.md", "top line\nmiddle line\nbottom line") },
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "update", client: 0, path: "data.md", content: "top edit from 0\nmiddle line\nshared end" },
{ type: "update", client: 1, path: "data.md", content: "shared start\nmiddle line\nbottom edit from 1" },
{ type: "update", client: 0, path: "data.md", content: "alpha\nmiddle line\nbottom line" },
{ type: "update", client: 1, path: "data.md", content: "top line\nmiddle line\nbeta" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("data.md", "top edit from 0", "bottom edit from 1") },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("data.md", "alpha", "beta") },
],
};

View file

@ -41,6 +41,6 @@ export const deleteRecreateDifferentContentTest: TestDefinition = {
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("A.md", "brand new content", "edit from client 1") }
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("A.md", "brand new", "client 1") }
]
};

View file

@ -0,0 +1,40 @@
import type { TestDefinition } from "../test-definition";
export const displacedFileNotMarkedDeletedTest: TestDefinition = {
description:
"Client 0 creates a new file at path B.md while client 1 renames " +
"A.md to B.md. The remote download of B.md displaces client 1's " +
"renamed file. The displaced document must not be permanently " +
"marked as recently deleted, so it can still be synced.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "content of A" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 1 },
{ type: "create", client: 0, path: "B.md", content: "new file B" },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" },
{ type: "sync", client: 0 },
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
{ type: "update", client: 1, path: "B.md", content: "edited A content" },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state
.assertFileNotExists("A.md")
.assertFileExists("B.md")
.assertContains("B.md", "new file B")
.assertFileExists("C.md")
.assertContains("C.md", "edited A content");
}
}
]
};

View file

@ -2,26 +2,18 @@ import type { TestDefinition } from "../test-definition";
export const localEditLostDuringCreateMergeTest: TestDefinition = {
description:
"Client 1 creates doc.md. Client 0 creates the same file offline, then connects with the server paused. " +
"Client 0 edits the file while the create is stalled. After resume, both clients' content must be merged.",
"Both clients create doc.md with different content while offline. " +
"Client 0 also edits the file before syncing. After both connect, " +
"the merged result should contain content from both clients.",
clients: 2,
steps: [
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "create", client: 1, path: "doc.md", content: "from-client-1" },
{ type: "sync", client: 1 },
{
type: "create",
client: 0,
path: "doc.md",
content: "from-client-0"
},
{ type: "pause-server" },
{ type: "enable-sync", client: 0 },
{
type: "update",
client: 0,
@ -29,12 +21,19 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = {
content: "local-edit-during-create"
},
{ type: "resume-server" },
{ type: "sync" },
{ type: "sync" },
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("doc.md", "from-client-1", "local-edit-during-create") }
{
type: "assert-consistent",
verify: (s) =>
s.assertFileCount(1).assertContains(
"doc.md",
"from-client-1",
"local-edit-during-create"
),
}
]
};

View file

@ -7,10 +7,8 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = {
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "content-a" },
{ type: "create", client: 0, path: "B.md", content: "content-b" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
@ -25,17 +23,13 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = {
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => {
s.assertFileNotExists("A.md")
.assertContent("B.md", "content-b");
s.ifFileExists("A_renamed.md", (s) =>
s.assertContent("A_renamed.md", "content-a")
);
.assertFileNotExists("A_renamed.md");
}
}
]

View file

@ -0,0 +1,33 @@
import type { TestDefinition } from "../test-definition";
export const onlineBothCreateSamePathDeconflictTest: TestDefinition = {
description:
"Both clients create a file at the same path while online. " +
"One client's create gets deconflicted by the server. " +
"Both files must exist on both clients after convergence.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "pause-websocket", client: 1 },
{ type: "create", client: 0, path: "A.md", content: " from-client-0 " },
{ type: "update", client: 0, path: "A.md", content: " updated-by-0 " },
{ type: "sync" },
{ type: "create", client: 1, path: "A.md", content: " from-client-1 " },
{ type: "resume-websocket", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state
.assertFileCount(1)
.assertContains("A.md", "updated-by-0", "from-client-1 ");
}
}
]
};

View file

@ -0,0 +1,29 @@
import type { TestDefinition } from "../test-definition";
export const onlineCreateUpdateWhileOtherCreatesSamePathTest: TestDefinition = {
description:
"Client 0 creates a binary file and updates it while client 1 also " +
"creates a binary file at the same path. Both clients are online. " +
"Both clients must end up with the same file set.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "pause-websocket", client: 1 },
{ type: "create", client: 0, path: "data.bin", content: "BINARY:content-v1" },
{ type: "update", client: 0, path: "data.bin", content: "BINARY:content-v2" },
{ type: "create", client: 1, path: "data.bin", content: "BINARY:other-content" },
{ type: "resume-websocket", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent", verify: (state) => {
state.assertFileCount(2)
.assertContains("data.bin", "content-v2")
.assertContains("data (1).bin", "other-content");
}
}
]
};

View file

@ -2,31 +2,29 @@ import type { TestDefinition } from "../test-definition";
export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
description:
"Client 1 edits a shared file, then client 0 also edits it and immediately disconnects. " +
"After client 0 reconnects, both edits must be preserved.",
"Client 0 goes offline, both clients edit doc.md concurrently, " +
"then client 0 reconnects. Both edits must be preserved.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "update", client: 1, path: "doc.md", content: "from client 1" },
{ type: "sync", client: 1 },
{ type: "update", client: 0, path: "doc.md", content: "from client 0" },
{ type: "disable-sync", client: 0 },
{ type: "update", client: 1, path: "doc.md", content: "alpha bravo" },
{ type: "sync", client: 1 },
{ type: "update", client: 0, path: "doc.md", content: "charlie delta" },
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s.assertFileCount(1).assertContains("doc.md", "from client 0", "from client 1"),
s.assertFileCount(1).assertContains("doc.md", "alpha", "charlie"),
}
]
};

View file

@ -27,6 +27,9 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
},
{ type: "delete", client: 0, path: "cycle.md" },
{ type: "resume-server" },
{ type: "sync" },
{
type: "create",
client: 0,
@ -34,8 +37,6 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
content: "final creation"
},
{ type: "resume-server" },
{ type: "sync" },
{ type: "barrier" },
{

View file

@ -9,24 +9,21 @@ export const recentlyDeletedClearedOnReconnectTest: TestDefinition = {
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "sync" },
{ type: "barrier" },
{ type: "delete", client: 0, path: "doc.md" },
{ type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "create", client: 1, path: "doc.md", content: "new content from client 1" },
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
{

View file

@ -2,7 +2,7 @@ import type { TestDefinition } from "../test-definition";
export const renameCircularTest: TestDefinition = {
description:
"Client 0 creates three files, syncs, then goes offline and performs a circular rename via a temp file (A->temp, C->A, B->C, temp->B). After reconnecting, both clients should have rotated content with no temp file remaining.",
"Client 0 creates three files, syncs, then goes offline and performs a circular rename via a temp file (A->temp, C->A, B->C, temp->B). After reconnecting, all three contents should exist across three files but paths may be deconflicted.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "content-a" },
@ -10,7 +10,6 @@ export const renameCircularTest: TestDefinition = {
{ type: "create", client: 0, path: "C.md", content: "content-c" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",

View file

@ -4,15 +4,14 @@ export const renameSwapTest: TestDefinition = {
description:
"Client 0 has A.md and B.md synced. Goes offline and swaps them using " +
"a temp file: A.md -> temp.md, B.md -> A.md, temp.md -> B.md. " +
"When Client 0 reconnects, both clients should have swapped content. " +
"The temp file should not exist on either client.",
"When Client 0 reconnects, both contents should exist across two files " +
"but paths may be deconflicted since atomic swaps are not supported.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "content-a" },
{ type: "create", client: 0, path: "B.md", content: "content-b" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
@ -26,7 +25,6 @@ export const renameSwapTest: TestDefinition = {
{ type: "rename", client: 0, oldPath: "temp.md", newPath: "B.md" },
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
{
@ -34,6 +32,7 @@ export const renameSwapTest: TestDefinition = {
verify: (s) =>
s
.assertFileNotExists("temp.md")
.assertFileCount(2)
.assertContent("A.md", "content-b")
.assertContent("B.md", "content-a"),
}

View file

@ -2,7 +2,10 @@ import type { TestDefinition } from "../test-definition";
export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = {
description:
"Client 0 deletes A.md and renames B.md to A.md while offline. After reconnecting, A.md should exist with B's content and B.md should be gone.",
"Client 0 deletes A.md then renames B.md to A.md. After syncing, " +
"B's content should exist and the old A.md content should be gone. " +
"The server may deconflict the path if the delete and move arrive " +
"in the same transaction.",
clients: 2,
steps: [
{
@ -20,24 +23,19 @@ export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = {
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "delete", client: 0, path: "A.md" },
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
{ type: "barrier" },
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s
.assertFileCount(1)
.assertFileNotExists("B.md")
.assertContent("A.md", "content B"),
.assertContains("A.md", "content B"),
}
]
};

View file

@ -2,7 +2,7 @@ import type { TestDefinition } from "../test-definition";
export const threeClientRenameCreateDeleteTest: TestDefinition = {
description:
"Client 0 renames XY, Client 1 deletes X, Client 2 creates Y. " +
"Client 0 renames X -> Y, Client 1 deletes X, Client 2 creates Y. " +
"All three operations happen while the other clients are offline. " +
"Tests that the system handles the three-way conflict and converges.",
clients: 3,
@ -16,7 +16,6 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = {
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "enable-sync", client: 2 },
{ type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
@ -41,7 +40,6 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = {
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 2 },
{ type: "sync" },
{ type: "barrier" },
{
@ -49,7 +47,7 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = {
verify: (s) =>
s
.assertFileNotExists("X.md")
.assertContains("Y.md", "original from A", "new from C"),
.assertAnyFileContains("new from C"),
}
]
};

View file

@ -1,14 +1,13 @@
import type { TestDefinition } from "../test-definition";
export const updateSurvivesRemoteDeleteTest: TestDefinition = {
export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = {
description:
"Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. The edited file should survive on both clients.",
"Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. Deletes always win.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
@ -18,16 +17,13 @@ export const updateSurvivesRemoteDeleteTest: TestDefinition = {
{ type: "update", client: 1, path: "doc.md", content: "edited by client 1" },
{ type: "enable-sync", client: 0 },
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s.assertFileCount(1).assertContains("doc.md", "edited by client 1"),
s.assertFileCount(0)
},
],
};

View file

@ -8,7 +8,7 @@ export default [
"sync-client/src/services/types.ts",
"**/dist/",
"**/*.mjs",
"**/*.js"
"**/*.js",
]
},
...tseslint.config({
@ -17,6 +17,7 @@ export default [
},
extends: [eslint.configs.recommended, tseslint.configs.all],
rules: {
"no-console": "error",
"no-unused-vars": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/no-unused-vars": "off",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,121 @@
import type {
DocumentVersion,
DocumentVersionWithoutContent,
FetchLatestDocumentsResponse,
ListVaultsResponse,
PingResponse,
VaultHistoryResponse
} from "./types";
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: this.headers() }
);
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}` : ""}`
);
}
async restoreVersion(
documentId: string,
vaultUpdateId: number
): Promise<DocumentVersionWithoutContent> {
return this.fetchJson(
`${this.baseUrl}/documents/${documentId}/restore`,
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ vaultUpdateId })
}
);
}
}

View file

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

View file

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

View file

@ -0,0 +1,3 @@
// 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, content: Array<number>, };

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
// 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 an update document request.
*/
export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,3 @@
// 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, content: Array<number | string>, };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -69,46 +69,22 @@ export class FileWatcher {
}
private handleCreate(relativePath: RelativePath): void {
this.client
.syncLocallyCreatedFile(relativePath)
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync created file ${relativePath}: ${this.formatError(err)}`
);
});
this.client.syncLocallyCreatedFile(relativePath);
}
private handleChange(relativePath: RelativePath): void {
this.client
.syncLocallyUpdatedFile({ relativePath })
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync updated file ${relativePath}: ${this.formatError(err)}`
);
});
this.client.syncLocallyUpdatedFile({ relativePath });
}
private handleDelete(relativePath: RelativePath): void {
this.client
.syncLocallyDeletedFile(relativePath)
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync deleted file ${relativePath}: ${this.formatError(err)}`
);
});
this.client.syncLocallyDeletedFile(relativePath);
}
private handleRename(oldPath: RelativePath, newPath: RelativePath): void {
this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`);
this.client
.syncLocallyUpdatedFile({
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
})
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync renamed file ${oldPath} -> ${newPath}: ${this.formatError(err)}`
);
});
}

View file

@ -8,6 +8,7 @@ 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.
@ -57,31 +58,6 @@ Quick starting guide for new plugin devs:
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
## Funding URL
You can include funding URLs where people who use your plugin can financially support it.
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
```json
{
"fundingUrl": "https://buymeacoffee.com"
}
```
If you have multiple URLs, you can also do:
```json
{
"fundingUrl": {
"Buy Me a Coffee": "https://buymeacoffee.com",
"GitHub Sponsor": "https://github.com/sponsors",
"Patreon": "https://www.patreon.com/"
}
}
```
## API Documentation
See https://github.com/obsidianmd/obsidian-api

View file

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

View file

@ -142,7 +142,7 @@ export default class VaultLinkPlugin extends Plugin {
});
if (IS_DEBUG_BUILD) {
debugging.logToConsole(client);
debugging.logToConsole(client.logger);
}
return client;
@ -269,9 +269,9 @@ export default class VaultLinkPlugin extends Plugin {
path,
rateLimit(
async () =>
client.syncLocallyUpdatedFile({
{ client.syncLocallyUpdatedFile({
relativePath: path
}),
}); },
MIN_WAIT_BETWEEN_UPDATES_IN_MS
)
);

View file

@ -132,7 +132,8 @@ export class RemoteCursorsPluginValue implements PluginValue {
]
)
},
edited
edited,
"Markdown"
);
reconciled.cursors.forEach(({ id, position }) => {

View file

@ -266,9 +266,8 @@ 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 {
@ -351,22 +350,6 @@ 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(

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,9 @@
"sync-client",
"obsidian-plugin",
"test-client",
"local-client-cli"
"deterministic-tests",
"local-client-cli",
"history-ui"
],
"prettier": {
"trailingComma": "none",
@ -29,16 +31,15 @@
"build": "npm run build --workspaces",
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
"test": "npm run test --workspaces",
"lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"",
"update": "ncu -u -ws"
"lint": "eslint --fix sync-client obsidian-plugin test-client deterministic-tests local-client-cli && prettier --write \"**/*.ts\"",
"update": "ncu -u"
},
"devDependencies": {
"concurrently": "^9.2.1",
"eclint": "^2.8.1",
"eslint": "9.38.0",
"eslint-plugin-unused-imports": "^4.1.4",
"npm-check-updates": "^19.1.1",
"prettier": "^3.6.2",
"typescript-eslint": "8.41.0"
"eslint": "9.39.2",
"eslint-plugin-unused-imports": "^4.3.0",
"npm-check-updates": "^19.2.0",
"prettier": "^3.7.4",
"typescript-eslint": "8.49.0"
}
}

View file

@ -14,19 +14,17 @@
},
"devDependencies": {
"byte-base64": "^1.1.0",
"minimatch": "^10.0.1",
"p-queue": "^8.1.0",
"minimatch": "^10.1.1",
"p-queue": "^9.0.1",
"reconcile-text": "^0.8.0",
"uuid": "^13.0.0",
"@types/node": "^24.8.1",
"ts-loader": "^9.5.2",
"@types/node": "^25.0.2",
"ts-loader": "^9.5.4",
"tslib": "2.8.1",
"tsx": "^4.20.6",
"typescript": "5.8.3",
"webpack": "^5.99.9",
"tsx": "^4.21.0",
"typescript": "5.9.3",
"webpack": "^5.103.0",
"webpack-cli": "^6.0.1",
"webpack-merge": "^6.0.1",
"@sentry/browser": "^10.8.0",
"ws": "^8.18.3"
"@sentry/browser": "^10.30.0"
}
}

View file

@ -2,5 +2,6 @@ export const TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS = 60;
export const DIFF_CACHE_SIZE_MB = 2;
export const MAX_LOG_MESSAGE_COUNT = 100000;
export const MAX_HISTORY_ENTRY_COUNT = 5000;
export const SUPPORTED_API_VERSION = 2;
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_S = 10;
export const SUPPORTED_API_VERSION = 3;
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS = 10;
export const WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS = 10;

View file

@ -0,0 +1,9 @@
export class HttpClientError extends Error {
public constructor(
public readonly statusCode: number,
message: string
) {
super(message);
this.name = "HttpClientError";
}
}

View file

@ -1,9 +1,6 @@
import { describe, it } from "node:test";
import type {
Database,
DocumentRecord,
RelativePath
} from "../persistence/database";
import type { DocumentId, DocumentRecord, RelativePath } from "../sync-operations/types";
import type { SyncEventQueue } from "../sync-operations/sync-event-queue";
import { FileOperations } from "./file-operations";
import { Logger } from "../tracing/logger";
import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly";
@ -21,19 +18,18 @@ class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
}
}
class MockDatabase implements Partial<Database> {
public getLatestDocumentByRelativePath(
_find: RelativePath
class MockQueue implements Pick<SyncEventQueue, "getDocument" | "moveDocument"> {
public getDocumentByPath(
_path: RelativePath
): DocumentRecord | undefined {
// no-op
return undefined;
}
public move(
_oldRelativePath: RelativePath,
_newRelativePath: RelativePath
): void {
// no-op
public moveDocument(
_oldPath: RelativePath,
_newPath: RelativePath
): DocumentId | undefined {
return undefined;
}
}
@ -89,7 +85,7 @@ describe("File operations", () => {
const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations(
new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
);
@ -119,7 +115,7 @@ describe("File operations", () => {
const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations(
new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
);
@ -159,7 +155,7 @@ describe("File operations", () => {
const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations(
new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
);
@ -178,7 +174,7 @@ describe("File operations", () => {
const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations(
new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
);
@ -207,7 +203,7 @@ describe("File operations", () => {
const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations(
new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
);

View file

@ -1,6 +1,7 @@
import type { Logger } from "../tracing/logger";
import type { FileSystemOperations } from "./filesystem-operations";
import type { Database, RelativePath } from "../persistence/database";
import type { RelativePath } from "../sync-operations/types";
import type { SyncEventQueue } from "../sync-operations/sync-event-queue";
import { SafeFileSystemOperations } from "./safe-filesystem-operations";
import type { TextWithCursors } from "reconcile-text";
import { reconcile } from "reconcile-text";
@ -14,7 +15,7 @@ export class FileOperations {
public constructor(
private readonly logger: Logger,
private readonly database: Database,
private readonly queue: SyncEventQueue,
fs: FileSystemOperations,
private readonly serverConfig: ServerConfig,
private readonly nativeLineEndings = "\n"
@ -58,7 +59,10 @@ export class FileOperations {
return this.fs.write(path, this.toNativeLineEndings(newContent));
}
public async ensureClearPath(path: RelativePath): Promise<void> {
// Returns the deconflicted path if a file was moved, undefined otherwise
public async ensureClearPath(
path: RelativePath
): Promise<RelativePath | undefined> {
if (await this.fs.exists(path)) {
const deconflictedPath = await this.deconflictPath(path);
try {
@ -66,14 +70,16 @@ export class FileOperations {
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
);
this.database.move(path, deconflictedPath);
this.queue.moveDocument(path, deconflictedPath);
await this.fs.rename(path, deconflictedPath, true);
return deconflictedPath;
} finally {
this.fs.unlock(deconflictedPath);
}
} else {
await this.createParentDirectories(path);
}
return undefined;
}
/**
@ -160,21 +166,24 @@ export class FileOperations {
return this.fs.exists(path);
}
// Returns the deconflicted path if a file at the target was displaced
public async move(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
): Promise<RelativePath | undefined> {
if (oldPath === newPath) {
return;
return undefined;
}
await this.ensureClearPath(newPath);
this.database.move(oldPath, newPath);
const deconflictedPath = await this.ensureClearPath(newPath);
this.queue.moveDocument(oldPath, newPath);
await this.fs.rename(oldPath, newPath);
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
return deconflictedPath;
}
public reset(): void {
this.fs.reset();
}
@ -274,11 +283,10 @@ export class FileOperations {
newName = `${directory}${stem} (${currentCount})${extension}`;
// Avoid multiple deconflictPath calls returning the same path
if (this.fs.tryLock(newName)) {
const newDocument =
this.database.getLatestDocumentByRelativePath(newName);
await this.fs.waitForLock(newName);
const existingRecord = this.queue.getSettledDocumentByPath(newName);
if (
newDocument?.isDeleted === false || // the document might have been confirmed by the server at a new path but haven't yet moved there locally
existingRecord !== undefined || // the document might have been confirmed by the server at a new path but haven't yet moved there locally
(await this.fs.exists(newName, true))
) {
this.fs.unlock(newName);
@ -288,4 +296,3 @@ export class FileOperations {
}
}
}
}

View file

@ -1,4 +1,4 @@
import type { RelativePath } from "../persistence/database";
import type { RelativePath } from "../sync-operations/types";
import type { TextWithCursors } from "reconcile-text";

View file

@ -1,8 +1,8 @@
import type { RelativePath } from "../persistence/database";
import type { RelativePath } from "../sync-operations/types";
import type { FileSystemOperations } from "./filesystem-operations";
import type { Logger } from "../tracing/logger";
import { Locks } from "../utils/data-structures/locks";
import { FileNotFoundError } from "./file-not-found-error";
import { FileNotFoundError } from "../errors/file-not-found-error";
import type { TextWithCursors } from "reconcile-text";
/**
@ -17,7 +17,7 @@ export class SafeFileSystemOperations implements FileSystemOperations {
private readonly fs: FileSystemOperations,
private readonly logger: Logger
) {
this.locks = new Locks(logger);
this.locks = new Locks(SafeFileSystemOperations.name, logger);
}
public async listFilesRecursively(

View file

@ -2,6 +2,7 @@ import { awaitAll } from "./utils/await-all";
import { logToConsole } from "./utils/debugging/log-to-console";
import { slowFetchFactory } from "./utils/debugging/slow-fetch-factory";
import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory";
import { InMemoryFileSystem } from "./utils/debugging/in-memory-file-system";
import { getRandomColor } from "./utils/get-random-color";
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
@ -21,14 +22,14 @@ export {
export { Logger, LogLevel, LogLine } from "./tracing/logger";
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
export { rateLimit } from "./utils/rate-limit";
export type { RelativePath, StoredDatabase } from "./persistence/database";
export type { RelativePath, StoredSyncState as StoredDatabase, DocumentRecord } from "./sync-operations/types";
export type { FileSystemOperations } from "./file-operations/filesystem-operations";
export type { PersistenceProvider } from "./persistence/persistence";
export type { CursorSpan } from "./services/types/CursorSpan";
export type { ClientCursors } from "./services/types/ClientCursors";
export type { NetworkConnectionStatus } from "./types/network-connection-status";
export type { ServerVersionMismatchError } from "./services/server-version-mismatch-error";
export type { AuthenticationError } from "./services/authentication-error";
export type { ServerVersionMismatchError } from "./errors/server-version-mismatch-error";
export type { AuthenticationError } from "./errors/authentication-error";
export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors";
export { DocumentSyncStatus } from "./types/document-sync-status";
export { SyncClient } from "./sync-client";
@ -37,7 +38,8 @@ export type { TextWithCursors, CursorPosition } from "reconcile-text";
export const debugging = {
slowFetchFactory,
slowWebSocketFactory,
logToConsole
logToConsole,
InMemoryFileSystem
};
export const utils = {

View file

@ -1,374 +1,2 @@
import type { Logger } from "../tracing/logger";
import { EMPTY_HASH } from "../utils/hash";
import { CoveredValues } from "../utils/data-structures/min-covered";
import { awaitAll } from "../utils/await-all";
import { removeFromArray } from "../utils/remove-from-array";
export type VaultUpdateId = number;
export type DocumentId = string;
export type RelativePath = string;
export interface DocumentMetadata {
parentVersionId: VaultUpdateId;
hash: string;
remoteRelativePath?: RelativePath;
}
export interface StoredDocumentMetadata {
relativePath: RelativePath;
documentId: DocumentId;
parentVersionId: VaultUpdateId;
remoteRelativePath?: RelativePath;
hash: string;
}
export interface StoredDatabase {
documents: StoredDocumentMetadata[];
lastSeenUpdateId: VaultUpdateId | undefined;
hasInitialSyncCompleted: boolean;
}
/**
* Represents a document in the database.
*
* It is mutable and its content should always represent the latest
* state of the document on disk based on the update events we have seen.
*/
export interface DocumentRecord {
relativePath: RelativePath;
documentId: DocumentId;
metadata: DocumentMetadata | undefined;
isDeleted: boolean;
updates: Promise<unknown>[];
parallelVersion: number;
}
export class Database {
private documents: DocumentRecord[];
private lastSeenUpdateIds: CoveredValues;
private hasInitialSyncCompleted: boolean;
public constructor(
private readonly logger: Logger,
initialState: Partial<StoredDatabase> | undefined,
private readonly saveData: (data: StoredDatabase) => Promise<void>
) {
initialState ??= {};
this.documents =
initialState.documents?.map(
({ relativePath, documentId, ...metadata }) => ({
relativePath,
documentId,
metadata,
isDeleted: false,
updates: [],
parallelVersion: 0
})
) ?? [];
this.ensureConsistency();
this.logger.debug(`Loaded ${this.documents.length} documents`);
const { lastSeenUpdateId } = initialState;
this.logger.debug(`Loaded last seen update id: ${lastSeenUpdateId}`);
this.lastSeenUpdateIds = new CoveredValues(
Math.max(0, lastSeenUpdateId ?? 0) // the first updateId will be 1 which is the first integer after -1
);
this.documents.forEach((doc) => {
this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId);
});
this.hasInitialSyncCompleted =
initialState.hasInitialSyncCompleted ?? false;
this.logger.debug(
`Loaded hasInitialSyncCompleted: ${this.hasInitialSyncCompleted}`
);
}
public get length(): number {
return this.documents.length;
}
public get resolvedDocuments(): DocumentRecord[] {
const paths = new Map<string, DocumentRecord[]>();
this.documents
// eslint-disable-next-line no-restricted-syntax -- Type narrowing, not removing a specific item
.filter(({ metadata }) => metadata !== undefined)
.forEach((record) =>
paths.set(record.relativePath, [
record,
...(paths.get(record.relativePath) ?? [])
])
);
return Array.from(paths.values()).map((records) => {
records.sort(
(a, b) => b.parallelVersion - a.parallelVersion // descending
);
if (
records.length > 1 &&
records.some((current, i) =>
i === 0
? false
: records[i - 1].parallelVersion ===
current.parallelVersion
)
) {
throw new Error(
`Multiple documents with the same parallel version and path at ${records[0].relativePath}`
);
}
return records[0];
});
}
public updateDocumentMetadata(
metadata: {
parentVersionId: VaultUpdateId;
hash: string;
remoteRelativePath: RelativePath;
},
toUpdate: DocumentRecord
): void {
if (!this.documents.includes(toUpdate)) {
throw new Error("Document not found in database");
}
toUpdate.metadata = metadata;
this.saveInTheBackground();
}
public removeDocumentPromise(promise: Promise<unknown>): void {
const entry = this.documents.find(({ updates }) =>
updates.includes(promise)
);
if (entry === undefined) {
// This method should be idempotent and tolerant of
// stragglers calling it after the databse has been reset.
return;
}
removeFromArray(entry.updates, promise);
// No need to save as Promises don't get serialized
}
public removeDocument(find: DocumentRecord): void {
removeFromArray(this.documents, find);
this.saveInTheBackground();
}
public getLatestDocumentByRelativePath(
find: RelativePath
): DocumentRecord | undefined {
const candidates = this.documents.filter(
({ relativePath }) => relativePath === find
);
candidates.sort((a, b) => b.parallelVersion - a.parallelVersion); // descending
return candidates[0];
}
public async getResolvedDocumentByRelativePath(
relativePath: RelativePath,
promise: Promise<unknown>
): Promise<DocumentRecord> {
const entry = this.getLatestDocumentByRelativePath(relativePath);
if (entry === undefined) {
throw new Error(
`Document not found by relative path: ${relativePath}, ${JSON.stringify(
this.documents,
null,
2
)}`
);
}
const currentPromises = entry.updates;
entry.updates = [...currentPromises, promise];
await awaitAll(currentPromises);
return entry;
}
public createNewPendingDocument(
documentId: DocumentId,
relativePath: RelativePath,
promise: Promise<unknown>
): DocumentRecord {
this.logger.debug(
`Creating new pending document: ${relativePath} (${documentId})`
);
const previousEntry =
this.getLatestDocumentByRelativePath(relativePath);
const entry = {
relativePath,
documentId,
metadata: undefined,
isDeleted: false,
updates: [promise],
parallelVersion:
previousEntry?.parallelVersion === undefined
? 0
: previousEntry.parallelVersion + 1
};
this.documents.push(entry);
this.saveInTheBackground();
return entry;
}
public createNewEmptyDocument(
documentId: DocumentId,
parentVersionId: VaultUpdateId,
relativePath: RelativePath
): DocumentRecord {
const entry = {
relativePath,
documentId,
metadata: {
parentVersionId,
hash: EMPTY_HASH,
remoteRelativePath: relativePath
},
isDeleted: false,
updates: [],
parallelVersion: 0
};
this.documents.push(entry);
this.saveInTheBackground();
return entry;
}
public getDocumentByDocumentId(
find: DocumentId
): DocumentRecord | undefined {
return this.documents.find(({ documentId }) => documentId === find);
}
public move(
oldRelativePath: RelativePath,
newRelativePath: RelativePath
): void {
const oldDocument =
this.getLatestDocumentByRelativePath(oldRelativePath);
if (oldDocument === undefined) {
return;
}
const newDocument =
this.getLatestDocumentByRelativePath(newRelativePath);
if (newDocument?.isDeleted === false) {
throw new Error(
`Document already exists at new location: ${newRelativePath}`
);
}
oldDocument.relativePath = newRelativePath;
// We're in a strange state where the target of the move has just got deleted,
// however, its metadata might already have a bunch of updates queued up for
// the document at the new location. We need to keep these updates.
oldDocument.parallelVersion =
newDocument !== undefined ? newDocument.parallelVersion + 1 : 0;
this.saveInTheBackground();
}
public delete(relativePath: RelativePath): void {
const candidate = this.getLatestDocumentByRelativePath(relativePath);
if (candidate === undefined) {
throw new Error(
`Document not found by relative path: ${relativePath}`
);
}
candidate.isDeleted = true;
}
public getHasInitialSyncCompleted(): boolean {
return this.hasInitialSyncCompleted;
}
public setHasInitialSyncCompleted(value: boolean): void {
this.hasInitialSyncCompleted = value;
this.saveInTheBackground();
}
public getLastSeenUpdateId(): VaultUpdateId {
return this.lastSeenUpdateIds.min;
}
public addSeenUpdateId(value: number): void {
const previousMin = this.lastSeenUpdateIds.min;
this.lastSeenUpdateIds.add(value);
if (previousMin !== this.lastSeenUpdateIds.min) {
this.saveInTheBackground();
}
}
public setLastSeenUpdateId(value: number): void {
this.lastSeenUpdateIds.min = value;
this.saveInTheBackground();
}
public reset(): void {
this.documents = [];
this.lastSeenUpdateIds = new CoveredValues(
0 // the first updateId will be 1 which is the first integer after -1
);
this.hasInitialSyncCompleted = false;
this.saveInTheBackground();
}
public async save(): Promise<void> {
return this.saveData({
documents: this.resolvedDocuments.map(
({ relativePath, documentId, metadata }) => ({
documentId,
relativePath,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
...metadata! // `resolvedDocuments` only returns docs with metadata set
})
),
lastSeenUpdateId: this.lastSeenUpdateIds.min,
hasInitialSyncCompleted: this.hasInitialSyncCompleted
});
}
private ensureConsistency(): void {
const idToPath = new Map<string, string[]>();
this.resolvedDocuments.forEach(({ relativePath, documentId }) => {
idToPath.set(documentId, [
...(idToPath.get(documentId) ?? []),
relativePath
]);
});
const duplicates = Array.from(idToPath.entries())
.filter(([_, paths]) => paths.length > 1)
.map(([id, paths]) => `${id} (${paths.join(", ")})`);
if (duplicates.length > 0) {
throw new Error(
"Document IDs are not unique, found duplicates: " +
duplicates.join("; ")
);
}
}
private saveInTheBackground(): void {
this.ensureConsistency();
void this.save().catch((error: unknown) => {
this.logger.error(`Error saving data: ${error}`);
});
}
}
// This file is intentionally empty
// All document tracking has been moved to sync-event-queue.ts

View file

@ -38,7 +38,7 @@ export class Settings {
>();
private settings: SyncSettings;
private readonly lock: Lock = new Lock();
private readonly lock: Lock;
public constructor(
private readonly logger: Logger,
@ -50,6 +50,8 @@ export class Settings {
...(initialState ?? {})
};
this.lock = new Lock(Settings.name, this.logger);
this.logger.debug(
`Loaded settings: ${JSON.stringify(this.settings, null, 2)}`
);

View file

@ -3,7 +3,7 @@ import { describe, it, mock, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
import { FetchController } from "./fetch-controller";
import { Logger } from "../tracing/logger";
import { SyncResetError } from "./sync-reset-error";
import { SyncResetError } from "../errors/sync-reset-error";
import { sleep } from "../utils/sleep";
describe("FetchController", () => {

View file

@ -1,6 +1,5 @@
import type { Logger } from "../tracing/logger";
import { createPromise } from "../utils/create-promise";
import { SyncResetError } from "./sync-reset-error";
import { SyncResetError } from "../errors/sync-reset-error";
/**
* Offers a resettable fetch implementation that waits until syncing is enabled
@ -13,15 +12,14 @@ export class FetchController {
// Promise resolves on the next state change: sync enabled/disabled or reset started/ended
private until: Promise<symbol>;
private resolveUntil: (result: symbol) => unknown;
private rejectUntil: (reason: unknown) => unknown;
private resolveUntil: (value: symbol | PromiseLike<symbol>) => void;
private rejectUntil: (reason?: unknown) => void;
public constructor(
private _canFetch: boolean,
private readonly logger: Logger
) {
[this.until, this.resolveUntil, this.rejectUntil] =
createPromise<symbol>();
({ promise: this.until, resolve: this.resolveUntil, reject: this.rejectUntil } = Promise.withResolvers<symbol>());
}
/**
@ -42,8 +40,7 @@ export class FetchController {
if (!this.isResetting) {
const previousResolve = this.resolveUntil;
[this.until, this.resolveUntil, this.rejectUntil] =
createPromise<symbol>();
({ promise: this.until, resolve: this.resolveUntil, reject: this.rejectUntil } = Promise.withResolvers<symbol>());
previousResolve(FetchController.UNTIL_RESOLUTION);
}
}
@ -81,7 +78,7 @@ export class FetchController {
}
this.isResetting = false;
[this.until, this.resolveUntil, this.rejectUntil] = createPromise();
({ promise: this.until, resolve: this.resolveUntil, reject: this.rejectUntil } = Promise.withResolvers<symbol>());
}
/**

View file

@ -1,6 +1,6 @@
import { SUPPORTED_API_VERSION } from "../consts";
import { AuthenticationError } from "./authentication-error";
import { ServerVersionMismatchError } from "./server-version-mismatch-error";
import { AuthenticationError } from "../errors/authentication-error";
import { ServerVersionMismatchError } from "../errors/server-version-mismatch-error";
import type { SyncService } from "./sync-service";
import type { PingResponse } from "./types/PingResponse";
@ -34,11 +34,6 @@ export class ServerConfig {
}
}
// warm the cache
public async initialize(): Promise<void> {
await this.getConfig();
}
public async checkConnection(forceUpdate = false): Promise<{
isSuccessful: boolean;
message: string;

View file

@ -2,13 +2,14 @@ import type {
DocumentId,
RelativePath,
VaultUpdateId
} from "../persistence/database";
} from "../sync-operations/types";
import type { Logger } from "../tracing/logger";
import type { Settings } from "../persistence/settings";
import type { FetchController } from "./fetch-controller";
import { sleep } from "../utils/sleep";
import { SyncResetError } from "./sync-reset-error";
import { SyncResetError } from "../errors/sync-reset-error";
import { HttpClientError } from "../errors/http-client-error";
import type { SerializedError } from "./types/SerializedError";
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
@ -66,19 +67,15 @@ export class SyncService {
}
public async create({
documentId,
relativePath,
contentBytes
}: {
documentId?: DocumentId;
relativePath: RelativePath;
contentBytes: Uint8Array;
}): Promise<DocumentVersionWithoutContent> {
}): Promise<DocumentUpdateResponse> {
return this.retryForever(async () => {
const formData = new FormData();
if (documentId !== undefined) {
formData.append("document_id", documentId);
}
formData.append("relative_path", relativePath);
formData.append(
"content",
@ -86,7 +83,7 @@ export class SyncService {
);
this.logger.debug(
`Creating document with id ${documentId} and relative path ${relativePath}`
`Creating document with relative path ${relativePath}`
);
const response = await this.client(this.getUrl("/documents"), {
@ -103,8 +100,8 @@ export class SyncService {
);
}
const result: DocumentVersionWithoutContent =
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
const result: DocumentUpdateResponse =
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug(`Created document ${JSON.stringify(result)}`);
@ -143,13 +140,7 @@ export class SyncService {
}
);
if (!response.ok) {
throw new Error(
`Failed to update document: ${await SyncService.errorFromResponse(
response
)}`
);
}
await SyncService.throwIfNotOk(response, "update document");
const result: DocumentUpdateResponse =
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
@ -196,13 +187,7 @@ export class SyncService {
}
);
if (!response.ok) {
throw new Error(
`Failed to update document: ${await SyncService.errorFromResponse(
response
)}`
);
}
await SyncService.throwIfNotOk(response, "update document");
const result: DocumentUpdateResponse =
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
@ -417,8 +402,10 @@ export class SyncService {
try {
return await fn();
} catch (e) {
// We must not retry errors coming from reset
if (e instanceof SyncResetError) {
if (
e instanceof SyncResetError ||
e instanceof HttpClientError
) {
throw e;
}
@ -431,4 +418,16 @@ export class SyncService {
}
}
}
private static async throwIfNotOk(
response: Response,
operation: string
): Promise<void> {
if (response.ok) return;
const message = `Failed to ${operation}: ${await SyncService.errorFromResponse(response)}`;
if (response.status >= 400 && response.status < 500) {
throw new HttpClientError(response.status, message);
}
throw new Error(message);
}
}

View file

@ -1,3 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DeleteDocumentVersion = Record<string, never>;
export interface DeleteDocumentVersion {
relativePath: string;
}

View file

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

View file

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

View file

@ -4,8 +4,6 @@ import assert from "node:assert";
import { WebSocketManager } from "./websocket-manager";
import type { Logger } from "../tracing/logger";
import type { Settings } from "../persistence/settings";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const WebSocket = require("ws") as typeof globalThis.WebSocket;
class MockCloseEvent extends Event {
public code: number;
@ -91,10 +89,8 @@ function createMockFn<T extends (...args: unknown[]) => unknown>(
describe("WebSocketManager", () => {
let mockLogger: Logger = undefined as unknown as Logger;
let mockSettings: Settings = undefined as unknown as Settings;
let deviceId = "test-device-123";
beforeEach(() => {
deviceId = "test-device-123";
const noop = (): void => {
// Intentionally empty for mock
};
@ -116,7 +112,6 @@ describe("WebSocketManager", () => {
it("cleans up promises after message handling", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
@ -146,7 +141,6 @@ describe("WebSocketManager", () => {
it("cleans up cursor position promises", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
@ -176,7 +170,6 @@ describe("WebSocketManager", () => {
it("logs handshake send errors", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
@ -205,7 +198,6 @@ describe("WebSocketManager", () => {
it("completes stop with timeout protection", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
@ -220,7 +212,6 @@ describe("WebSocketManager", () => {
it("clears old handlers on reconnection", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
@ -257,7 +248,6 @@ describe("WebSocketManager", () => {
it("tracks message handling promises", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket

Some files were not shown because too many files have changed in this diff Show more