Compare commits
26 commits
f36a84b275
...
3ba0b7a88b
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ba0b7a88b | |||
| 53bfbfaa4a | |||
| 5a4723cd00 | |||
| d5958fcbaa | |||
| 1a4e39d57a | |||
| d034ad5cb3 | |||
| 0e3e5a99cd | |||
| 64ca5a82ef | |||
| 3784418567 | |||
| 0897f7a545 | |||
| 22dfdc069b | |||
| 7c203bc5c9 | |||
| 19e4c39f44 | |||
| 03b5c223d6 | |||
| 1bb1ca99dd | |||
| 4aeec1b021 | |||
| adad2d5703 | |||
| 44947dc3a5 | |||
| 9ae1a5e09e | |||
| f3d985cc57 | |||
| 1c6cd80b64 | |||
| 65d75dec40 | |||
| 48234de10d | |||
| 4493365076 | |||
| e8c57b3a37 | |||
| 904a2737d4 |
154 changed files with 9976 additions and 6034 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -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
110
CLAUDE.md
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
170
frontend/deterministic-tests/src/managed-websocket.ts
Normal file
170
frontend/deterministic-tests/src/managed-websocket.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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") },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -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"
|
||||
),
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 ");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -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"),
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { TestDefinition } from "../test-definition";
|
|||
|
||||
export const threeClientRenameCreateDeleteTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 renames X→Y, 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"),
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
13
frontend/history-ui/index.html
Normal file
13
frontend/history-ui/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VaultLink2</title>
|
||||
<link rel="icon" href="data:," />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
frontend/history-ui/package.json
Normal file
16
frontend/history-ui/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "history-ui",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"test": "echo 'no tests yet'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
78
frontend/history-ui/src/App.svelte
Normal file
78
frontend/history-ui/src/App.svelte
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<script lang="ts">
|
||||
import { auth, nav, toasts } from "./lib/stores.svelte";
|
||||
import { listVaults } from "./lib/api";
|
||||
import Login from "./components/Login.svelte";
|
||||
import VaultPicker from "./components/VaultPicker.svelte";
|
||||
import Dashboard from "./components/Dashboard.svelte";
|
||||
import ToastContainer from "./components/ToastContainer.svelte";
|
||||
|
||||
let restoring = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
const saved = auth.tryRestore();
|
||||
if (!saved) {
|
||||
restoring = false;
|
||||
return;
|
||||
}
|
||||
listVaults(saved.token)
|
||||
.then((response) => {
|
||||
auth.authenticate(
|
||||
saved.token,
|
||||
response.userName,
|
||||
response.vaults
|
||||
);
|
||||
if (
|
||||
saved.vaultId &&
|
||||
response.vaults.some(
|
||||
(v) => v.name === saved.vaultId
|
||||
)
|
||||
) {
|
||||
auth.selectVault(saved.vaultId);
|
||||
}
|
||||
restoring = false;
|
||||
})
|
||||
.catch(() => {
|
||||
restoring = false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if restoring}
|
||||
<div class="loading-screen">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{:else if !auth.token}
|
||||
<Login />
|
||||
{:else if !auth.isAuthenticated}
|
||||
<VaultPicker />
|
||||
{:else}
|
||||
<Dashboard
|
||||
selectedDocumentId={nav.current.kind === "document" ? nav.current.documentId : undefined}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ToastContainer />
|
||||
|
||||
<style>
|
||||
.loading-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--bg-tertiary);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
101
frontend/history-ui/src/app.css
Normal file
101
frontend/history-ui/src/app.css
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
:root {
|
||||
--bg: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--bg-hover: #30363d;
|
||||
--border: #30363d;
|
||||
--border-light: #21262d;
|
||||
--text: #e6edf3;
|
||||
--text-muted: #8b949e;
|
||||
--text-subtle: #6e7681;
|
||||
--accent: #58a6ff;
|
||||
--accent-hover: #79c0ff;
|
||||
--green: #3fb950;
|
||||
--green-bg: rgba(63, 185, 80, 0.15);
|
||||
--red: #f85149;
|
||||
--red-bg: rgba(248, 81, 73, 0.15);
|
||||
--orange: #d29922;
|
||||
--orange-bg: rgba(210, 153, 34, 0.15);
|
||||
--purple: #bc8cff;
|
||||
--purple-bg: rgba(188, 140, 255, 0.15);
|
||||
--blue: #58a6ff;
|
||||
--blue-bg: rgba(88, 166, 255, 0.15);
|
||||
--mono: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Noto Sans, Helvetica, Arial, sans-serif;
|
||||
--radius: 6px;
|
||||
--radius-sm: 4px;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
346
frontend/history-ui/src/components/ActivityFeed.svelte
Normal file
346
frontend/history-ui/src/components/ActivityFeed.svelte
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
<script lang="ts">
|
||||
import type { VersionEvent } from "../lib/types";
|
||||
import {
|
||||
absoluteTime,
|
||||
formatBytes
|
||||
} from "../lib/stores.svelte";
|
||||
|
||||
interface Props {
|
||||
versions: VersionEvent[];
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
onSelectDocument: (documentId: string) => void;
|
||||
onTimeTravel: (vaultUpdateId: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
versions,
|
||||
loading,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
onSelectDocument,
|
||||
onTimeTravel
|
||||
}: Props = $props();
|
||||
|
||||
function timeOfDay(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
// Group by day
|
||||
let grouped = $derived.by(() => {
|
||||
const groups: { date: string; items: VersionEvent[] }[] = [];
|
||||
const sortedDesc = [...versions].sort(
|
||||
(a, b) => b.vaultUpdateId - a.vaultUpdateId
|
||||
);
|
||||
|
||||
for (const v of sortedDesc) {
|
||||
const date = new Date(v.updatedDate).toLocaleDateString(
|
||||
"en-US",
|
||||
{ month: "long", day: "numeric", year: "numeric" }
|
||||
);
|
||||
const last = groups.at(-1);
|
||||
if (last && last.date === date) {
|
||||
last.items.push(v);
|
||||
} else {
|
||||
groups.push({ date, items: [v] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
const actionColors: Record<string, string> = {
|
||||
created: "var(--green)",
|
||||
updated: "var(--blue)",
|
||||
renamed: "var(--orange)",
|
||||
deleted: "var(--red)",
|
||||
restored: "var(--purple)"
|
||||
};
|
||||
|
||||
const actionBgColors: Record<string, string> = {
|
||||
created: "var(--green-bg)",
|
||||
updated: "var(--blue-bg)",
|
||||
renamed: "var(--orange-bg)",
|
||||
deleted: "var(--red-bg)",
|
||||
restored: "var(--purple-bg)"
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="feed">
|
||||
{#if loading && versions.length === 0}
|
||||
<div class="feed-loading">Loading activity...</div>
|
||||
{:else if versions.length === 0}
|
||||
<div class="feed-empty">
|
||||
No activity yet. Documents will appear here as sync clients
|
||||
make changes.
|
||||
</div>
|
||||
{:else}
|
||||
{#each grouped as group}
|
||||
<div class="day-group">
|
||||
<div class="day-header">{group.date}</div>
|
||||
<div class="items-list">
|
||||
{#each group.items as event}
|
||||
<div class="feed-item">
|
||||
<button
|
||||
class="feed-item-main"
|
||||
onclick={() =>
|
||||
onSelectDocument(event.documentId)}
|
||||
>
|
||||
<div class="feed-timeline">
|
||||
<div
|
||||
class="timeline-dot"
|
||||
style="background: {actionColors[
|
||||
event.action
|
||||
]}"
|
||||
></div>
|
||||
</div>
|
||||
<div class="feed-content">
|
||||
<div class="feed-header">
|
||||
<span
|
||||
class="action-pill"
|
||||
style="color: {actionColors[
|
||||
event.action
|
||||
]}; background: {actionBgColors[
|
||||
event.action
|
||||
]}"
|
||||
>
|
||||
{event.action}
|
||||
</span>
|
||||
<span class="feed-path">
|
||||
{#if event.action === "renamed" && event.previousPath}
|
||||
<span class="prev-path"
|
||||
>{event.previousPath}</span
|
||||
>
|
||||
<span class="arrow"
|
||||
>→</span
|
||||
>
|
||||
{/if}
|
||||
<span
|
||||
class:deleted={event.action ===
|
||||
"deleted"}
|
||||
>
|
||||
{event.relativePath}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="feed-meta">
|
||||
<span class="feed-user"
|
||||
>{event.userId}</span
|
||||
>
|
||||
<span class="feed-dot"
|
||||
>·</span
|
||||
>
|
||||
<span class="feed-size"
|
||||
>{formatBytes(
|
||||
event.contentSize
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="feed-time-btn"
|
||||
title="Time travel to {absoluteTime(event.updatedDate)}"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTimeTravel(event.vaultUpdateId);
|
||||
}}
|
||||
>
|
||||
{timeOfDay(event.updatedDate)}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if hasMore}
|
||||
<div class="load-more">
|
||||
<button class="load-more-btn" onclick={onLoadMore}>
|
||||
Load older activity
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.feed {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
.feed-loading,
|
||||
.feed-empty {
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.day-group {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.day-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.feed-item {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.feed-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.feed-item-main {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 0 10px 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.items-list {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.items-list::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 21px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.feed-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 12px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feed-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feed-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-pill {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feed-path {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prev-path {
|
||||
color: var(--text-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--text-subtle);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.deleted {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.feed-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.feed-dot {
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.feed-time-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
font-size: 12px;
|
||||
font-family: var(--mono);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.feed-time-btn:hover {
|
||||
color: var(--accent);
|
||||
border-left-color: var(--border-light);
|
||||
}
|
||||
|
||||
.load-more {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
padding: 8px 20px;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.load-more-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
</style>
|
||||
167
frontend/history-ui/src/components/ConfirmDialog.svelte
Normal file
167
frontend/history-ui/src/components/ConfirmDialog.svelte
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
destructive?: boolean;
|
||||
loading?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
destructive = false,
|
||||
loading = false,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: Props = $props();
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="backdrop" onclick={onCancel} role="presentation">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||
<div
|
||||
class="dialog"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
>
|
||||
<h3 class="dialog-title">{title}</h3>
|
||||
<p class="dialog-message">{message}</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn-cancel" onclick={onCancel} disabled={loading}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn-confirm"
|
||||
class:destructive
|
||||
onclick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="btn-spinner"></span>
|
||||
{/if}
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fade-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 480px;
|
||||
width: calc(100% - 32px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
animation: scale-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dialog-message {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-muted);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-cancel:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
padding: 8px 16px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-confirm:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-confirm.destructive {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.btn-confirm.destructive:hover:not(:disabled) {
|
||||
background: #f97583;
|
||||
}
|
||||
|
||||
.btn-confirm:disabled,
|
||||
.btn-cancel:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from { transform: scale(0.95); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
511
frontend/history-ui/src/components/Dashboard.svelte
Normal file
511
frontend/history-ui/src/components/Dashboard.svelte
Normal 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)}
|
||||
·
|
||||
{doc.userId}
|
||||
·
|
||||
{relativeTime(doc.updatedDate)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.sidebar-search {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.sidebar-search input {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.sidebar-controls {
|
||||
padding: 4px 16px 8px;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-label input[type="checkbox"] {
|
||||
width: auto;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-tree {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.loading-placeholder {
|
||||
padding: 16px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.time-slider-container {
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.file-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.file-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.file-row.deleted {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.file-row.deleted .file-path {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
288
frontend/history-ui/src/components/DiffView.svelte
Normal file
288
frontend/history-ui/src/components/DiffView.svelte
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
oldContent: string;
|
||||
newContent: string;
|
||||
oldLabel: string;
|
||||
newLabel: string;
|
||||
}
|
||||
|
||||
let { oldContent, newContent, oldLabel, newLabel }: Props = $props();
|
||||
|
||||
interface DiffLine {
|
||||
type: "add" | "remove" | "context";
|
||||
content: string;
|
||||
oldLineNo: number | null;
|
||||
newLineNo: number | null;
|
||||
}
|
||||
|
||||
let diffLines = $derived.by((): DiffLine[] => {
|
||||
const oldLines = oldContent.split("\n");
|
||||
const newLines = newContent.split("\n");
|
||||
|
||||
// Simple line-by-line diff using LCS
|
||||
const lines: DiffLine[] = [];
|
||||
const lcs = computeLCS(oldLines, newLines);
|
||||
|
||||
let oi = 0;
|
||||
let ni = 0;
|
||||
let oldLineNo = 1;
|
||||
let newLineNo = 1;
|
||||
|
||||
for (const match of lcs) {
|
||||
// Remove lines before match
|
||||
while (oi < match.oldIndex) {
|
||||
lines.push({
|
||||
type: "remove",
|
||||
content: oldLines[oi],
|
||||
oldLineNo: oldLineNo++,
|
||||
newLineNo: null
|
||||
});
|
||||
oi++;
|
||||
}
|
||||
// Add lines before match
|
||||
while (ni < match.newIndex) {
|
||||
lines.push({
|
||||
type: "add",
|
||||
content: newLines[ni],
|
||||
oldLineNo: null,
|
||||
newLineNo: newLineNo++
|
||||
});
|
||||
ni++;
|
||||
}
|
||||
// Context line
|
||||
lines.push({
|
||||
type: "context",
|
||||
content: oldLines[oi],
|
||||
oldLineNo: oldLineNo++,
|
||||
newLineNo: newLineNo++
|
||||
});
|
||||
oi++;
|
||||
ni++;
|
||||
}
|
||||
|
||||
// Remaining removes
|
||||
while (oi < oldLines.length) {
|
||||
lines.push({
|
||||
type: "remove",
|
||||
content: oldLines[oi],
|
||||
oldLineNo: oldLineNo++,
|
||||
newLineNo: null
|
||||
});
|
||||
oi++;
|
||||
}
|
||||
// Remaining adds
|
||||
while (ni < newLines.length) {
|
||||
lines.push({
|
||||
type: "add",
|
||||
content: newLines[ni],
|
||||
oldLineNo: null,
|
||||
newLineNo: newLineNo++
|
||||
});
|
||||
ni++;
|
||||
}
|
||||
|
||||
return lines;
|
||||
});
|
||||
|
||||
let stats = $derived({
|
||||
added: diffLines.filter((l) => l.type === "add").length,
|
||||
removed: diffLines.filter((l) => l.type === "remove").length
|
||||
});
|
||||
|
||||
interface LCSMatch {
|
||||
oldIndex: number;
|
||||
newIndex: number;
|
||||
}
|
||||
|
||||
function computeLCS(a: string[], b: string[]): LCSMatch[] {
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
|
||||
// For large files, use a simpler approach
|
||||
if (m * n > 1_000_000) {
|
||||
return simpleDiff(a, b);
|
||||
}
|
||||
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||
new Array(n + 1).fill(0)
|
||||
);
|
||||
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
if (a[i - 1] === b[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backtrack
|
||||
const matches: LCSMatch[] = [];
|
||||
let i = m;
|
||||
let j = n;
|
||||
while (i > 0 && j > 0) {
|
||||
if (a[i - 1] === b[j - 1]) {
|
||||
matches.unshift({ oldIndex: i - 1, newIndex: j - 1 });
|
||||
i--;
|
||||
j--;
|
||||
} else if (dp[i - 1][j] > dp[i][j - 1]) {
|
||||
i--;
|
||||
} else {
|
||||
j--;
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function simpleDiff(a: string[], b: string[]): LCSMatch[] {
|
||||
// Hash-based matching for large files
|
||||
const bMap = new Map<string, number[]>();
|
||||
for (let j = 0; j < b.length; j++) {
|
||||
const arr = bMap.get(b[j]);
|
||||
if (arr) arr.push(j);
|
||||
else bMap.set(b[j], [j]);
|
||||
}
|
||||
|
||||
const matches: LCSMatch[] = [];
|
||||
let lastJ = -1;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const candidates = bMap.get(a[i]);
|
||||
if (!candidates) continue;
|
||||
for (const j of candidates) {
|
||||
if (j > lastJ) {
|
||||
matches.push({ oldIndex: i, newIndex: j });
|
||||
lastJ = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="diff-view">
|
||||
<div class="diff-header">
|
||||
<span class="diff-label">{oldLabel}</span>
|
||||
<span class="diff-arrow">→</span>
|
||||
<span class="diff-label">{newLabel}</span>
|
||||
<span class="diff-stats">
|
||||
<span class="diff-added">+{stats.added}</span>
|
||||
<span class="diff-removed">-{stats.removed}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="diff-content">
|
||||
{#each diffLines as line}
|
||||
<div class="diff-line {line.type}">
|
||||
<span class="line-no old-no">
|
||||
{line.oldLineNo ?? ""}
|
||||
</span>
|
||||
<span class="line-no new-no">
|
||||
{line.newLineNo ?? ""}
|
||||
</span>
|
||||
<span class="line-marker">
|
||||
{#if line.type === "add"}+{:else if line.type === "remove"}-{:else} {/if}
|
||||
</span>
|
||||
<span class="line-content">{line.content}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.diff-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.diff-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.diff-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.diff-arrow {
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.diff-stats {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.diff-added {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.diff-removed {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.diff-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
display: flex;
|
||||
white-space: pre;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.diff-line.add {
|
||||
background: var(--green-bg);
|
||||
}
|
||||
|
||||
.diff-line.remove {
|
||||
background: var(--red-bg);
|
||||
}
|
||||
|
||||
.line-no {
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
text-align: right;
|
||||
padding-right: 8px;
|
||||
color: var(--text-subtle);
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.line-marker {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.diff-line.add .line-marker {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.diff-line.remove .line-marker {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.line-content {
|
||||
flex: 1;
|
||||
padding-right: 16px;
|
||||
}
|
||||
</style>
|
||||
712
frontend/history-ui/src/components/DocumentDetail.svelte
Normal file
712
frontend/history-ui/src/components/DocumentDetail.svelte
Normal 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>·</span>
|
||||
<span>{versions.length} version{versions.length !== 1 ? "s" : ""}</span>
|
||||
<span>·</span>
|
||||
<span>Last by {latest.userId}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="detail-loading">Loading versions...</div>
|
||||
{:else}
|
||||
<!-- Content area -->
|
||||
<div class="detail-body">
|
||||
<div class="content-panel">
|
||||
{#if selectedVersion}
|
||||
<div class="content-tabs">
|
||||
<button
|
||||
class="content-tab"
|
||||
class:active={activeTab === "preview"}
|
||||
onclick={() => (activeTab = "preview")}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
class="content-tab"
|
||||
class:active={activeTab === "diff"}
|
||||
onclick={() => {
|
||||
if (selectedVersion) {
|
||||
const idx = versions.indexOf(selectedVersion);
|
||||
if (idx > 0) showDiff(selectedVersion, idx);
|
||||
}
|
||||
}}
|
||||
disabled={versions.indexOf(selectedVersion) === 0}
|
||||
>
|
||||
Diff
|
||||
</button>
|
||||
<div class="content-tab-spacer"></div>
|
||||
<span class="viewing-label">
|
||||
Viewing v#{selectedVersion.vaultUpdateId}
|
||||
·
|
||||
{relativeTime(selectedVersion.updatedDate)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="content-view">
|
||||
{#if loadingContent}
|
||||
<div class="content-loading">Loading content...</div>
|
||||
{:else if activeTab === "diff" && diffOldContent !== null && diffNewContent !== null}
|
||||
<DiffView
|
||||
oldContent={diffOldContent}
|
||||
newContent={diffNewContent}
|
||||
oldLabel={diffOldLabel}
|
||||
newLabel={diffNewLabel}
|
||||
/>
|
||||
{:else if activeTab === "preview"}
|
||||
{#if isTextFile(selectedVersion.relativePath) || fileExtension(selectedVersion.relativePath) === ""}
|
||||
<pre class="text-content">{loadedContent ?? ""}</pre>
|
||||
{:else if isImageFile(selectedVersion.relativePath) && loadedContentBytes}
|
||||
<div class="image-preview">
|
||||
<img
|
||||
src={getImageUrl(loadedContentBytes, selectedVersion.relativePath)}
|
||||
alt={selectedVersion.relativePath}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="binary-placeholder">
|
||||
<div class="binary-icon">📦</div>
|
||||
<div class="binary-label">Binary file</div>
|
||||
<div class="binary-size">
|
||||
{formatBytes(selectedVersion.contentSize)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Version timeline -->
|
||||
<div class="version-panel">
|
||||
<div class="version-panel-header">Version History</div>
|
||||
<div class="version-list">
|
||||
{#each [...versionEvents].reverse() as event, i}
|
||||
{@const v = event.version}
|
||||
{@const isSelected = selectedVersion?.vaultUpdateId === v.vaultUpdateId}
|
||||
<div class="version-item" class:selected={isSelected}>
|
||||
<button
|
||||
class="version-main"
|
||||
onclick={() => selectVersion(v)}
|
||||
>
|
||||
<div class="version-left">
|
||||
<span class="version-id">#{v.vaultUpdateId}</span>
|
||||
<span
|
||||
class="version-action"
|
||||
style="color: {actionColors[event.action]}; background: {actionBgColors[event.action]}"
|
||||
>
|
||||
{event.action}
|
||||
</span>
|
||||
</div>
|
||||
<div class="version-right">
|
||||
<span class="version-user">{v.userId}</span>
|
||||
<span
|
||||
class="version-time"
|
||||
title={absoluteTime(v.updatedDate)}
|
||||
>
|
||||
{relativeTime(v.updatedDate)}
|
||||
</span>
|
||||
<span class="version-size">{formatBytes(v.contentSize)}</span>
|
||||
</div>
|
||||
</button>
|
||||
{#if event.previousPath}
|
||||
<div class="version-rename">
|
||||
{event.previousPath} → {v.relativePath}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="version-actions">
|
||||
{#if i < versionEvents.length - 1}
|
||||
<button
|
||||
class="version-btn"
|
||||
onclick={() => {
|
||||
const realIdx = versions.indexOf(v);
|
||||
showDiff(v, realIdx);
|
||||
}}
|
||||
>
|
||||
Diff
|
||||
</button>
|
||||
{/if}
|
||||
{#if v !== latest}
|
||||
<button
|
||||
class="version-btn restore-btn"
|
||||
onclick={() => confirmRestore(v)}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showRestoreDialog && restoreTarget}
|
||||
<ConfirmDialog
|
||||
title="Restore Version"
|
||||
message={`Restore "${currentPath}" to version #${restoreTarget.vaultUpdateId} from ${absoluteTime(restoreTarget.updatedDate)}? This creates a new version with the old content. Current content is preserved in history.`}
|
||||
confirmLabel="Restore"
|
||||
destructive={false}
|
||||
loading={restoring}
|
||||
onConfirm={executeRestore}
|
||||
onCancel={() => {
|
||||
showRestoreDialog = false;
|
||||
restoreTarget = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-path {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.path-text {
|
||||
font-family: var(--mono);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deleted-path {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.active-badge {
|
||||
color: var(--green);
|
||||
background: var(--green-bg);
|
||||
}
|
||||
|
||||
.deleted-badge {
|
||||
color: var(--red);
|
||||
background: var(--red-bg);
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.doc-id {
|
||||
font-family: var(--mono);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.detail-loading {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content-tab {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.content-tab:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.content-tab.active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.content-tab:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.content-tab-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.viewing-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-subtle);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.content-view {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.content-loading {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.text-content {
|
||||
padding: 16px;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 60vh;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.binary-placeholder {
|
||||
padding: 64px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.binary-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.binary-label {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.binary-size {
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Version panel */
|
||||
.version-panel {
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.version-panel-header {
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
padding: 8px 12px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.version-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.version-item.selected {
|
||||
background: var(--blue-bg);
|
||||
}
|
||||
|
||||
.version-main {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.version-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.version-id {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.version-action {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.version-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.version-rename {
|
||||
font-size: 11px;
|
||||
color: var(--orange);
|
||||
font-family: var(--mono);
|
||||
margin: 4px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.version-btn {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.version-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.restore-btn {
|
||||
color: var(--orange);
|
||||
}
|
||||
</style>
|
||||
124
frontend/history-ui/src/components/FileTree.svelte
Normal file
124
frontend/history-ui/src/components/FileTree.svelte
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<script lang="ts">
|
||||
import type { TreeNode } from "../lib/types";
|
||||
import FileTree from "./FileTree.svelte";
|
||||
|
||||
interface Props {
|
||||
node: TreeNode;
|
||||
selectedId: string | null;
|
||||
onSelect: (documentId: string) => void;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
let { node, selectedId, onSelect, depth = 0 }: Props = $props();
|
||||
|
||||
let expanded = $state<Record<string, boolean>>({});
|
||||
|
||||
function toggle(path: string) {
|
||||
expanded[path] = !expanded[path];
|
||||
}
|
||||
|
||||
function isExpanded(path: string): boolean {
|
||||
return expanded[path] ?? true;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if node.isFolder && depth === 0}
|
||||
{#each node.children as child}
|
||||
<FileTree
|
||||
node={child}
|
||||
{selectedId}
|
||||
{onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
{/each}
|
||||
{:else if node.isFolder}
|
||||
<div class="tree-folder">
|
||||
<button
|
||||
class="tree-item folder"
|
||||
style="padding-left: {depth * 16}px"
|
||||
onclick={() => toggle(node.path)}
|
||||
>
|
||||
<span class="expand-icon"
|
||||
>{isExpanded(node.path) ? "▾" : "▸"}</span
|
||||
>
|
||||
<span class="folder-icon">📁</span>
|
||||
<span class="node-name">{node.name}</span>
|
||||
</button>
|
||||
{#if isExpanded(node.path)}
|
||||
{#each node.children as child}
|
||||
<FileTree
|
||||
node={child}
|
||||
{selectedId}
|
||||
{onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="tree-item file"
|
||||
class:selected={node.document?.documentId === selectedId}
|
||||
class:deleted={node.isDeleted}
|
||||
style="padding-left: {depth * 16 + 8}px"
|
||||
onclick={() =>
|
||||
node.document && onSelect(node.document.documentId)}
|
||||
>
|
||||
<span class="file-icon">{node.isDeleted ? "○" : "●"}</span>
|
||||
<span class="node-name">{node.name}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 3px 12px;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tree-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tree-item.selected {
|
||||
background: var(--blue-bg);
|
||||
}
|
||||
|
||||
.tree-item.deleted {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.tree-item.deleted .node-name {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 10px;
|
||||
width: 12px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 8px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.node-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
144
frontend/history-ui/src/components/Header.svelte
Normal file
144
frontend/history-ui/src/components/Header.svelte
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<script lang="ts">
|
||||
import { auth } from "../lib/stores.svelte";
|
||||
|
||||
interface Props {
|
||||
vaultId: string;
|
||||
serverVersion: string;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
let { vaultId, serverVersion, onRefresh }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
<span class="header-title">VaultLink</span>
|
||||
<span class="header-sep">/</span>
|
||||
<span class="header-vault">{vaultId}</span>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<span class="server-version">v{serverVersion}</span>
|
||||
<button class="header-btn" onclick={onRefresh} title="Refresh">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if auth.availableVaults.length > 1}
|
||||
<button
|
||||
class="header-btn"
|
||||
onclick={() => auth.deselectVault()}
|
||||
title="Switch vault"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="header-btn"
|
||||
onclick={() => auth.logout()}
|
||||
title="Sign out"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.header-sep {
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.header-vault {
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.server-version {
|
||||
font-size: 12px;
|
||||
color: var(--text-subtle);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
</style>
|
||||
176
frontend/history-ui/src/components/Login.svelte
Normal file
176
frontend/history-ui/src/components/Login.svelte
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<script lang="ts">
|
||||
import { auth } from "../lib/stores.svelte";
|
||||
import { listVaults } from "../lib/api";
|
||||
|
||||
let token = $state("");
|
||||
let error = $state("");
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!token.trim()) {
|
||||
error = "Token is required.";
|
||||
return;
|
||||
}
|
||||
error = "";
|
||||
loading = true;
|
||||
try {
|
||||
const response = await listVaults(token.trim());
|
||||
auth.authenticate(
|
||||
token.trim(),
|
||||
response.userName,
|
||||
response.vaults
|
||||
);
|
||||
} catch {
|
||||
error = "Authentication failed. Check your token.";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="logo">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<h1>VaultLink</h1>
|
||||
</div>
|
||||
<p class="subtitle">Vault History Browser</p>
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<label>
|
||||
<span>Token</span>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={token}
|
||||
placeholder="Enter your access token"
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="btn-primary" disabled={loading}>
|
||||
{#if loading}
|
||||
<span class="btn-spinner"></span>
|
||||
Connecting...
|
||||
{:else}
|
||||
Connect
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 40px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
label span {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--red);
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
background: var(--red-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
191
frontend/history-ui/src/components/TimeSlider.svelte
Normal file
191
frontend/history-ui/src/components/TimeSlider.svelte
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
<script lang="ts">
|
||||
import type { DocumentVersionWithoutContent } from "../lib/types";
|
||||
import { relativeTime, absoluteTime } from "../lib/stores.svelte";
|
||||
|
||||
interface Props {
|
||||
min: number;
|
||||
max: number;
|
||||
value: number | null;
|
||||
versions: DocumentVersionWithoutContent[];
|
||||
onchange: (value: number | null) => void;
|
||||
}
|
||||
|
||||
let { min, max, value, versions, onchange }: Props = $props();
|
||||
|
||||
let isNow = $derived(value === null || value >= max);
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const v = parseInt(target.value, 10);
|
||||
if (v >= max) {
|
||||
onchange(null);
|
||||
} else {
|
||||
onchange(v);
|
||||
}
|
||||
}
|
||||
|
||||
function snapToNow() {
|
||||
onchange(null);
|
||||
}
|
||||
|
||||
let currentVersion = $derived(
|
||||
value !== null
|
||||
? versions.find((v) => v.vaultUpdateId === value) ??
|
||||
versions.reduce(
|
||||
(closest, v) =>
|
||||
Math.abs(v.vaultUpdateId - (value ?? max)) <
|
||||
Math.abs(
|
||||
closest.vaultUpdateId - (value ?? max)
|
||||
)
|
||||
? v
|
||||
: closest,
|
||||
versions[0]
|
||||
)
|
||||
: null
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="time-slider">
|
||||
<div class="slider-label">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span class="label-text">Time Travel</span>
|
||||
</div>
|
||||
|
||||
<div class="slider-track">
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value ?? max}
|
||||
oninput={handleInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="slider-info">
|
||||
{#if isNow}
|
||||
<span class="now-badge">Now</span>
|
||||
{:else if currentVersion}
|
||||
<span
|
||||
class="time-info"
|
||||
title={absoluteTime(currentVersion.updatedDate)}
|
||||
>
|
||||
#{value}
|
||||
·
|
||||
{relativeTime(currentVersion.updatedDate)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="time-info">#{value}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isNow}
|
||||
<button class="snap-btn" onclick={snapToNow} title="Back to now">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.time-slider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.slider-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.slider-track input[type="range"] {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
appearance: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.slider-track input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.slider-track input[type="range"]::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.slider-info {
|
||||
flex-shrink: 0;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.now-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--green);
|
||||
background: var(--green-bg);
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.time-info {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.snap-btn {
|
||||
padding: 4px;
|
||||
color: var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.snap-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
</style>
|
||||
80
frontend/history-ui/src/components/ToastContainer.svelte
Normal file
80
frontend/history-ui/src/components/ToastContainer.svelte
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<script lang="ts">
|
||||
import { toasts } from "../lib/stores.svelte";
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
success: "var(--green)",
|
||||
error: "var(--red)",
|
||||
info: "var(--accent)"
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if toasts.items.length > 0}
|
||||
<div class="toast-container">
|
||||
{#each toasts.items as toast (toast.id)}
|
||||
<div
|
||||
class="toast"
|
||||
style="border-left-color: {typeColors[toast.type]}"
|
||||
>
|
||||
<span class="toast-message">{toast.message}</span>
|
||||
<button
|
||||
class="toast-dismiss"
|
||||
onclick={() => toasts.dismiss(toast.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-left-width: 3px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
animation: slide-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toast-dismiss {
|
||||
font-size: 18px;
|
||||
color: var(--text-muted);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.toast-dismiss:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
198
frontend/history-ui/src/components/VaultPicker.svelte
Normal file
198
frontend/history-ui/src/components/VaultPicker.svelte
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<script lang="ts">
|
||||
import { auth } from "../lib/stores.svelte";
|
||||
import { relativeTime } from "../lib/stores.svelte";
|
||||
import type { VaultInfo } from "../lib/types";
|
||||
|
||||
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>
|
||||
121
frontend/history-ui/src/lib/api.ts
Normal file
121
frontend/history-ui/src/lib/api.ts
Normal 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 })
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
305
frontend/history-ui/src/lib/stores.svelte.ts
Normal file
305
frontend/history-ui/src/lib/stores.svelte.ts
Normal 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));
|
||||
}
|
||||
4
frontend/history-ui/src/lib/types/ClientCursors.ts
Normal file
4
frontend/history-ui/src/lib/types/ClientCursors.ts
Normal 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>, };
|
||||
|
|
@ -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>, };
|
||||
|
|
@ -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>, };
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ClientCursors } from "./ClientCursors";
|
||||
|
||||
export type CursorPositionFromServer = { clients: Array<ClientCursors>, };
|
||||
3
frontend/history-ui/src/lib/types/CursorSpan.ts
Normal file
3
frontend/history-ui/src/lib/types/CursorSpan.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type CursorSpan = { start: number, end: number, };
|
||||
|
|
@ -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;
|
||||
3
frontend/history-ui/src/lib/types/DocumentVersion.ts
Normal file
3
frontend/history-ui/src/lib/types/DocumentVersion.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DocumentVersion = { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, };
|
||||
|
|
@ -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, };
|
||||
4
frontend/history-ui/src/lib/types/DocumentWithCursors.ts
Normal file
4
frontend/history-ui/src/lib/types/DocumentWithCursors.ts
Normal 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>, };
|
||||
|
|
@ -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, };
|
||||
7
frontend/history-ui/src/lib/types/ListVaultsResponse.ts
Normal file
7
frontend/history-ui/src/lib/types/ListVaultsResponse.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { VaultInfo } from "./VaultInfo";
|
||||
|
||||
/**
|
||||
* Response to listing vaults accessible to the authenticated user.
|
||||
*/
|
||||
export type ListVaultsResponse = { vaults: Array<VaultInfo>, hasMore: boolean, userName: string, };
|
||||
24
frontend/history-ui/src/lib/types/PingResponse.ts
Normal file
24
frontend/history-ui/src/lib/types/PingResponse.ts
Normal 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, };
|
||||
3
frontend/history-ui/src/lib/types/SerializedError.ts
Normal file
3
frontend/history-ui/src/lib/types/SerializedError.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type SerializedError = { errorType: string, message: string, causes: Array<string>, };
|
||||
|
|
@ -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>, };
|
||||
|
|
@ -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, };
|
||||
6
frontend/history-ui/src/lib/types/VaultInfo.ts
Normal file
6
frontend/history-ui/src/lib/types/VaultInfo.ts
Normal 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, };
|
||||
|
|
@ -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;
|
||||
3
frontend/history-ui/src/lib/types/WebSocketHandshake.ts
Normal file
3
frontend/history-ui/src/lib/types/WebSocketHandshake.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type WebSocketHandshake = { token: string, deviceId: string, lastSeenVaultUpdateId: number | null, };
|
||||
|
|
@ -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;
|
||||
|
|
@ -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, };
|
||||
28
frontend/history-ui/src/lib/types/index.ts
Normal file
28
frontend/history-ui/src/lib/types/index.ts
Normal 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;
|
||||
}
|
||||
7
frontend/history-ui/src/main.ts
Normal file
7
frontend/history-ui/src/main.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { mount } from "svelte";
|
||||
import App from "./App.svelte";
|
||||
import "./app.css";
|
||||
|
||||
const app = mount(App, { target: document.getElementById("app")! });
|
||||
|
||||
export default app;
|
||||
5
frontend/history-ui/svelte.config.js
Normal file
5
frontend/history-ui/svelte.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess()
|
||||
};
|
||||
16
frontend/history-ui/tsconfig.json
Normal file
16
frontend/history-ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["svelte"]
|
||||
},
|
||||
"include": ["src/**/*", "src/**/*.svelte"]
|
||||
}
|
||||
15
frontend/history-ui/vite.config.ts
Normal file
15
frontend/history-ui/vite.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/vaults": "http://localhost:3010"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -132,7 +132,8 @@ export class RemoteCursorsPluginValue implements PluginValue {
|
|||
]
|
||||
)
|
||||
},
|
||||
edited
|
||||
edited,
|
||||
"Markdown"
|
||||
);
|
||||
|
||||
reconciled.cursors.forEach(({ id, position }) => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
5092
frontend/package-lock.json
generated
5092
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
9
frontend/sync-client/src/errors/http-client-error.ts
Normal file
9
frontend/sync-client/src/errors/http-client-error.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export class HttpClientError extends Error {
|
||||
public constructor(
|
||||
public readonly statusCode: number,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HttpClientError";
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { RelativePath } from "../persistence/database";
|
||||
import type { RelativePath } from "../sync-operations/types";
|
||||
|
||||
import type { TextWithCursors } from "reconcile-text";
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, }
|
||||
6
frontend/sync-client/src/services/types/VaultInfo.ts
Normal file
6
frontend/sync-client/src/services/types/VaultInfo.ts
Normal 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, }
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue