WIP: Quality of life features #180

Draft
schmelczer wants to merge 19 commits from asch/qol into main
174 changed files with 21319 additions and 17689 deletions

View file

@ -11,5 +11,6 @@ indent_style = space
indent_size = 4 indent_size = 4
tab_width = 4 tab_width = 4
[*.{yml,yaml}] [*.{yml,yaml,md}]
indent_size = 2 indent_size = 2
tab_width = 2

View file

@ -42,14 +42,10 @@ jobs:
cd docs cd docs
npm ci npm ci
- name: Check formatting - name: Check formatting & spelling
run: | run: |
cd docs cd docs
npm run format:check npm run format:check
- name: Check spelling
run: |
cd docs
npm run spell:check npm run spell:check
- name: Build documentation - name: Build documentation

View file

@ -6,7 +6,7 @@ on:
pull_request: pull_request:
branches: ["main"] branches: ["main"]
schedule: schedule:
- cron: '*/30 * * * *' - cron: '0 * * * *'
concurrency: concurrency:
group: e2e-tests group: e2e-tests

1
docs/.gitignore vendored
View file

@ -1,2 +1,3 @@
.vitepress/dist/ .vitepress/dist/
.vitepress/cache/ .vitepress/cache/
.vitepress/.temp/

View file

@ -1,7 +1,7 @@
{ {
"printWidth": 120, "printWidth": 120,
"tabWidth": 4, "tabWidth": 4,
"useTabs": true, "useTabs": false,
"semi": false, "semi": false,
"singleQuote": false, "singleQuote": false,
"trailingComma": "none", "trailingComma": "none",

View file

@ -1,60 +1,60 @@
import { defineConfig } from "vitepress" import { defineConfig } from "vitepress"
export default defineConfig({ export default defineConfig({
title: "VaultLink", title: "VaultLink",
description: "Self-hosted real-time synchronisation for Obsidian", description: "Self-hosted real-time synchronisation for Obsidian",
base: "/vault-link/", base: "/",
themeConfig: { themeConfig: {
logo: "/logo.svg", logo: "/logo.svg",
nav: [ nav: [
{ text: "Home", link: "/" }, { text: "Home", link: "/" },
{ text: "Guide", link: "/guide/getting-started" }, { text: "Guide", link: "/guide/getting-started" },
{ text: "Architecture", link: "/architecture/" }, { text: "Architecture", link: "/architecture/" },
{ text: "GitHub", link: "https://github.com/schmelczer/vault-link" } { text: "GitHub", link: "https://github.com/schmelczer/vault-link" }
], ],
sidebar: [ sidebar: [
{ {
text: "Introduction", text: "Introduction",
items: [ items: [
{ text: "What is VaultLink?", link: "/guide/what-is-vaultlink" }, { text: "What is VaultLink?", link: "/guide/what-is-vaultlink" },
{ text: "Getting Started", link: "/guide/getting-started" }, { text: "Getting Started", link: "/guide/getting-started" },
{ text: "Limitations", link: "/guide/limitations" }, { text: "Limitations", link: "/guide/limitations" },
{ text: "Comparison with Alternatives", link: "/guide/alternatives" } { text: "Comparison with Alternatives", link: "/guide/alternatives" }
] ]
}, },
{ {
text: "Setup", text: "Setup",
items: [ items: [
{ text: "Server Setup", link: "/guide/server-setup" }, { text: "Server Setup", link: "/guide/server-setup" },
{ text: "Obsidian Plugin", link: "/guide/obsidian-plugin" }, { text: "Obsidian Plugin", link: "/guide/obsidian-plugin" },
{ text: "CLI Client", link: "/guide/cli-client" } { text: "CLI Client", link: "/guide/cli-client" }
] ]
}, },
{ {
text: "Configuration", text: "Configuration",
items: [ items: [
{ text: "Server Configuration", link: "/config/server" }, { text: "Server Configuration", link: "/config/server" },
{ text: "Authentication", link: "/config/authentication" }, { text: "Authentication", link: "/config/authentication" },
{ text: "Advanced Options", link: "/config/advanced" } { text: "Advanced Options", link: "/config/advanced" }
] ]
}, },
{ {
text: "Architecture", text: "Architecture",
items: [ items: [
{ text: "Overview", link: "/architecture/" }, { text: "Overview", link: "/architecture/" },
{ text: "Sync Algorithm", link: "/architecture/sync-algorithm" }, { text: "Sync Algorithm", link: "/architecture/sync-algorithm" },
{ text: "Data Flow", link: "/architecture/data-flow" } { text: "Data Flow", link: "/architecture/data-flow" }
] ]
} }
], ],
socialLinks: [{ icon: "github", link: "https://github.com/schmelczer/vault-link" }], socialLinks: [{ icon: "github", link: "https://github.com/schmelczer/vault-link" }],
footer: { footer: {
message: "Released under the MIT License.", message: "Released under the MIT License.",
copyright: "Copyright © 2024-present Andras Schmelczer" copyright: "Copyright © 2024-present Andras Schmelczer"
}, },
search: { search: {
provider: "local" provider: "local"
} }
}, },
head: [["link", { rel: "icon", type: "image/svg+xml", href: "/vault-link/logo.svg" }]] head: [["link", { rel: "icon", type: "image/svg+xml", href: "/logo.svg" }]]
}) })

View file

@ -125,37 +125,37 @@ sequenceDiagram
``` ```
┌─────────┐ ┌─────────┐
│ Client │ │ Client │
└───┬────┘ └───┬─-───┘
│ 1. Detect file change │ 1. Detect file change
├─► 2. Read file content ├─► 2. Read file content
├─► 3. Create upload message ├─► 3. Create upload message
│ { │ {
│ type: "upload_file", │ type: "upload_file",
│ path: "notes/daily.md", │ path: "notes/daily.md",
│ content: "...", │ content: "...",
│ version: 42, │ version: 42,
│ timestamp: "2024-01-01T12:00:00Z" │ timestamp: "2024-01-01T12:00:00Z"
│ } │ }
┌─────────┐ ┌─────────┐
│ Server │ │ Server │
└───┬────┘ └───┬────-
│ 4. Validate message │ 4. Validate message
├─► 5. Check permissions ├─► 5. Check permissions
├─► 6. Apply OT (if conflicts) ├─► 6. Apply OT (if conflicts)
├─► 7. Store in database ├─► 7. Store in database
├─► 8. Update version ├─► 8. Update version
├─► 9. Broadcast to clients ├─► 9. Broadcast to clients
└─► 10. Send ACK to uploader └─► 10. Send ACK to uploader
``` ```
### Download ### Download
@ -163,36 +163,36 @@ sequenceDiagram
``` ```
┌─────────┐ ┌─────────┐
│ Server │ │ Server │
└───┬────┘ └───┬─-───┘
│ 1. File updated by another client │ 1. File updated by another client
├─► 2. Broadcast notification ├─► 2. Broadcast notification
│ { │ {
│ type: "file_updated", │ type: "file_updated",
│ path: "notes/daily.md", │ path: "notes/daily.md",
│ version: 43 │ version: 43
│ } │ }
┌─────────┐ ┌─────────┐
│ Client │ │ Client │
└───┬────┘ └───┬─-───┘
│ 3. Receive notification │ 3. Receive notification
├─► 4. Request file download ├─► 4. Request file download
│ { │ {
│ type: "download_file", │ type: "download_file",
│ path: "notes/daily.md", │ path: "notes/daily.md",
│ version: 43 │ version: 43
│ } │ }
┌─────────┐ ┌─────────┐
│ Server │ │ Server │
└───┬────┘ └───┬─=───┘
│ 5. Retrieve from database │ 5. Retrieve from database
└─► 6. Send file content └─► 6. Send file content
{ {
type: "file_content", type: "file_content",
path: "notes/daily.md", path: "notes/daily.md",
@ -201,9 +201,9 @@ sequenceDiagram
} }
┌─────────┐ ┌─────────┐
│ Client │ │ Client │
└────┬─── └───-─┬───┘
│ 7. Write to filesystem │ 7. Write to filesystem
└─► 8. Update local metadata └─► 8. Update local metadata
@ -215,30 +215,30 @@ sequenceDiagram
┌─────────┐ ┌─────────┐
│ Client │ │ Client │
└────┬────┘ └────┬────┘
│ 1. File deleted locally │ 1. File deleted locally
├─► 2. Send delete message ├─► 2. Send delete message
│ { │ {
│ type: "delete_file", │ type: "delete_file",
│ path: "notes/old.md" │ path: "notes/old.md"
│ } │ }
┌─────────┐ ┌─────────┐
│ Server │ │ Server │
└────┬────┘ └────┬────┘
│ 3. Mark as deleted in DB │ 3. Mark as deleted in DB
│ (soft delete for history) │ (soft delete for history)
├─► 4. Broadcast deletion ├─► 4. Broadcast deletion
└─► 5. ACK to sender └─► 5. ACK to sender
┌─────────┐ ┌─────────┐
│ Other │ │ Other │
│ Clients │ │ Clients │
└────┬────┘ └────┬────┘
│ 6. Delete local file │ 6. Delete local file
└─► 7. Update metadata └─► 7. Update metadata
@ -252,32 +252,32 @@ sequenceDiagram
Time → Time →
Client A Server Client B Client A Server Client B
│ │ │ │ │ │
│ Edit file v10 │ │ │ Edit file v10 │ │
│ "Add line A" │ │ Edit file v10 │ "Add line A" │ │ Edit file v10
│ │ │ "Add line B" │ │ │ "Add line B"
│ │ │ │ │ │
├─── Upload @ t1 ─────────►│ │ ├─── Upload @ t1 ─────────►│ │
│ │◄────── Upload @ t2 ────────┤ │ │◄────── Upload @ t2 ────────┤
│ │ │ │ │ │
│ │ 1. Receive both edits │ │ │ 1. Receive both edits │
│ │ (based on v10) │ │ │ (based on v10) │
│ │ │ │ │ │
│ │ 2. Apply first edit │ │ │ 2. Apply first edit │
│ │ → v11 (line A added) │ │ │ → v11 (line A added) │
│ │ │ │ │ │
│ │ 3. Transform second edit │ │ │ 3. Transform second edit │
│ │ against first │ │ │ against first │
│ │ │ │ │ │
│ │ 4. Apply transformed edit │ │ │ 4. Apply transformed edit │
│ │ → v12 (both lines) │ │ │ → v12 (both lines) │
│ │ │ │ │ │
│◄──── v12 content ────────┤ │ │◄──── v12 content ────────┤ │
│ ├───── v12 content ─────────►│ │ ├───── v12 content ─────────►│
│ │ │ │ │ │
│ Apply v12 │ │ Apply v12 │ Apply v12 │ │ Apply v12
│ (has both lines) │ │ (has both lines) │ (has both lines) │ │ (has both lines)
│ │ │ │ │ │
``` ```
### Conflict Resolution Steps ### Conflict Resolution Steps
@ -361,11 +361,11 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "upload_file", "type": "upload_file",
"path": "notes/example.md", "path": "notes/example.md",
"content": "File content here...", "content": "File content here...",
"base_version": 10, "base_version": 10,
"timestamp": "2024-01-01T12:00:00Z" "timestamp": "2024-01-01T12:00:00Z"
} }
``` ```
@ -373,8 +373,8 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "download_file", "type": "download_file",
"path": "notes/example.md" "path": "notes/example.md"
} }
``` ```
@ -382,8 +382,8 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "delete_file", "type": "delete_file",
"path": "notes/old.md" "path": "notes/old.md"
} }
``` ```
@ -391,8 +391,8 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "list_files", "type": "list_files",
"since_version": 0 "since_version": 0
} }
``` ```
@ -402,11 +402,11 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "file_updated", "type": "file_updated",
"path": "notes/example.md", "path": "notes/example.md",
"version": 11, "version": 11,
"size": 1024, "size": 1024,
"hash": "abc123..." "hash": "abc123..."
} }
``` ```
@ -414,10 +414,10 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "file_content", "type": "file_content",
"path": "notes/example.md", "path": "notes/example.md",
"content": "Updated content...", "content": "Updated content...",
"version": 11 "version": 11
} }
``` ```
@ -425,9 +425,9 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "file_deleted", "type": "file_deleted",
"path": "notes/old.md", "path": "notes/old.md",
"version": 12 "version": 12
} }
``` ```
@ -435,9 +435,9 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "sync_complete", "type": "sync_complete",
"total_files": 150, "total_files": 150,
"current_version": 200 "current_version": 200
} }
``` ```
@ -445,9 +445,9 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "error", "type": "error",
"message": "File too large", "message": "File too large",
"code": "FILE_TOO_LARGE" "code": "FILE_TOO_LARGE"
} }
``` ```

View file

@ -11,10 +11,10 @@ Central sync server with multiple clients. High-level architecture and design de
│ Obsidian Plugin │ Obsidian Plugin │ CLI Client │ │ Obsidian Plugin │ Obsidian Plugin │ CLI Client │
│ (User A - Device1) │ (User A - Device2│ (Server/Backup) │ │ (User A - Device1) │ (User A - Device2│ (Server/Backup) │
└──────────┬──────────┴─────────┬─────────┴──────────┬────────┘ └──────────┬──────────┴─────────┬─────────┴──────────┬────────┘
│ │ │ │ │ │
│ WebSocket │ WebSocket │ WebSocket │ WebSocket │ WebSocket │ WebSocket
│ │ │ │ │ │
└────────────────────┼────────────────────┘ └────────────────────┼────────────────────┘
┌───────────▼───────────┐ ┌───────────▼───────────┐
│ Sync Server │ │ Sync Server │

View file

@ -243,9 +243,9 @@ users:
2. Client sends authentication message: 2. Client sends authentication message:
```json ```json
{ {
"type": "auth", "type": "auth",
"token": "user-token", "token": "user-token",
"vault": "vault-name" "vault": "vault-name"
} }
``` ```
3. Server validates: 3. Server validates:

5964
docs/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"> <svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs> <defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" /> <stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" /> <stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
</linearGradient> </linearGradient>
</defs> </defs>
@ -25,23 +25,23 @@
<!-- Link chain --> <!-- Link chain -->
<g opacity="0.9"> <g opacity="0.9">
<!-- Left link --> <!-- Left link -->
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/> <ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Right link --> <!-- Right link -->
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/> <ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Center link connecting them --> <!-- Center link connecting them -->
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/> <ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
</g> </g>
<!-- Sync arrows (subtle) --> <!-- Sync arrows (subtle) -->
<g opacity="0.5"> <g opacity="0.5">
<!-- Clockwise arrow top-right --> <!-- Clockwise arrow top-right -->
<path d="M 35 -35 Q 50 -35 50 -20 L 50 -15" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/> <path d="M 35 -35 Q 50 -35 50 -20 L 50 -15" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/> <polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
<!-- Counter-clockwise arrow bottom-left --> <!-- Counter-clockwise arrow bottom-left -->
<path d="M -35 25 Q -50 25 -50 10 L -50 5" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/> <path d="M -35 25 Q -50 25 -50 10 L -50 5" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/> <polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

View file

@ -3,73 +3,92 @@ import tseslint from "typescript-eslint";
import unusedImports from "eslint-plugin-unused-imports"; import unusedImports from "eslint-plugin-unused-imports";
export default [ export default [
{ {
ignores: [ ignores: [
"sync-client/src/services/types.ts", "sync-client/src/services/types.ts",
"**/dist/", "**/dist/",
"**/*.mjs", "**/*.mjs",
"**/*.js" "**/*.js"
] ]
}, },
...tseslint.config({ ...tseslint.config({
plugins: { plugins: {
"unused-imports": unusedImports "unused-imports": unusedImports
}, },
extends: [eslint.configs.recommended, tseslint.configs.all], extends: [eslint.configs.recommended, tseslint.configs.all],
rules: { rules: {
"no-unused-vars": "off", "no-unused-vars": "off",
"@typescript-eslint/restrict-template-expressions": "off", "@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-floating-promises": [ "@typescript-eslint/no-floating-promises": [
"error", "error",
{
allowForKnownSafeCalls: [
{ from: "package", name: ["suite", "test"], package: "node:test" },
],
},
],
"@typescript-eslint/parameter-properties": "off",
"@typescript-eslint/require-await": "off",
"@typescript-eslint/class-methods-use-this": "off",
"@typescript-eslint/consistent-return": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/max-params": "off",
"@typescript-eslint/no-magic-numbers": "off",
"@typescript-eslint/prefer-readonly-parameter-types": "off",
"@typescript-eslint/naming-convention": "off",
"no-restricted-properties": [
"error",
{
object: "Promise",
property: "all",
message: "Use `awaitAll` instead of Promise.all to always await all promises."
},
{ {
object: "Promise", allowForKnownSafeCalls: [
property: "allSettled", { from: "package", name: ["suite", "test"], package: "node:test" },
message: "Use `awaitAll` instead of Promise.allSettled to always await all promises and throw on errors." ],
}, },
{ ],
object: "String", "@typescript-eslint/parameter-properties": "off",
property: "replace", "@typescript-eslint/require-await": "off",
message: "Use replaceAll instead of replace to replace all occurrences of a substring." "@typescript-eslint/class-methods-use-this": "off",
} "@typescript-eslint/consistent-return": "off",
], "@typescript-eslint/no-unsafe-argument": "off",
"unused-imports/no-unused-vars": [ "@typescript-eslint/max-params": "off",
"warn", "@typescript-eslint/no-magic-numbers": "off",
{ "@typescript-eslint/prefer-readonly-parameter-types": "off",
vars: "all", "@typescript-eslint/naming-convention": "off",
varsIgnorePattern: "^_", "no-restricted-properties": [
args: "after-used", "error",
argsIgnorePattern: "^_" {
} object: "Promise",
] property: "all",
}, message: "Use `awaitAll` instead of Promise.all to always await all promises."
languageOptions: { },
parserOptions: { {
projectService: true, object: "Promise",
tsconfigRootDir: import.meta.dirname property: "allSettled",
} message: "Use `awaitAll` instead of Promise.allSettled to always await all promises and throw on errors."
} },
}) {
object: "String",
property: "replace",
message: "Use replaceAll instead of replace to replace all occurrences of a substring."
}
],
"no-restricted-syntax": [
"error",
{
selector: "CallExpression[callee.property.name='splice'][arguments.length=2][arguments.1.type='Literal'][arguments.1.value=1]",
message: "Use `removeFromArray(array, item)` instead of manually using indexOf + splice(index, 1). Import from 'sync-client/src/utils/remove-from-array'."
},
{
selector: "CallExpression[callee.property.name='filter'] > ArrowFunctionExpression[body.type='BinaryExpression'][body.operator='!==']",
message: "Use `removeFromArray(array, item)` instead of filter(x => x !== item) for better performance. Import from 'sync-client/src/utils/remove-from-array'."
},
{
selector: "CallExpression[callee.property.name='filter'] > ArrowFunctionExpression > BlockStatement > ReturnStatement > BinaryExpression[operator='!==']",
message: "Use `removeFromArray(array, item)` instead of filter(x => { return x !== item }) for better performance. Import from 'sync-client/src/utils/remove-from-array'."
},
{
selector: "CallExpression[callee.property.name='filter'] > FunctionExpression[body.type='BlockStatement'] > BlockStatement > ReturnStatement > BinaryExpression[operator='!==']",
message: "Use `removeFromArray(array, item)` instead of filter(function(x) { return x !== item }) for better performance. Import from 'sync-client/src/utils/remove-from-array'."
}
],
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_"
}
]
},
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname
}
}
})
]; ];

View file

@ -1,27 +1,28 @@
{ {
"name": "local-client-cli", "name": "local-client-cli",
"version": "0.12.0", "version": "0.12.0",
"description": "Standalone CLI for VaultLink sync client", "description": "Standalone CLI for VaultLink sync client",
"private": false, "private": false,
"bin": { "bin": {
"vaultlink": "./dist/cli.js" "vaultlink": "./dist/cli.js"
}, },
"scripts": { "scripts": {
"dev": "webpack watch --mode development", "dev": "webpack watch --mode development",
"build": "webpack --mode production", "build": "webpack --mode production",
"test": "tsx --test src/args.test.ts src/node-filesystem.test.ts" "test": "tsx --test 'src/**/*.test.ts'"
}, },
"dependencies": { "dependencies": {
"commander": "^14.0.2" "commander": "^14.0.2",
}, "watcher": "^2.3.1"
"devDependencies": { },
"@types/node": "^24.8.1", "devDependencies": {
"sync-client": "file:../sync-client", "@types/node": "^24.8.1",
"ts-loader": "^9.5.2", "sync-client": "file:../sync-client",
"tslib": "2.8.1", "ts-loader": "^9.5.2",
"tsx": "^4.20.6", "tslib": "2.8.1",
"typescript": "5.8.3", "tsx": "^4.20.6",
"webpack": "^5.99.9", "typescript": "5.8.3",
"webpack-cli": "^6.0.1" "webpack": "^5.99.9",
} "webpack-cli": "^6.0.1"
}
} }

View file

@ -4,227 +4,227 @@ import { parseArgs } from "./args";
import { LogLevel } from "sync-client"; import { LogLevel } from "sync-client";
test("parseArgs - parse basic arguments", () => { test("parseArgs - parse basic arguments", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default" "default"
]); ]);
assert.equal(args.localPath, "/path/to/vault"); assert.equal(args.localPath, "/path/to/vault");
assert.equal(args.remoteUri, "https://sync.example.com"); assert.equal(args.remoteUri, "https://sync.example.com");
assert.equal(args.token, "mytoken"); assert.equal(args.token, "mytoken");
assert.equal(args.vaultName, "default"); assert.equal(args.vaultName, "default");
}); });
test("parseArgs - parse long form arguments", () => { test("parseArgs - parse long form arguments", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
"cli.js", "cli.js",
"--local-path", "--local-path",
"/path/to/vault", "/path/to/vault",
"--remote-uri", "--remote-uri",
"https://sync.example.com", "https://sync.example.com",
"--token", "--token",
"mytoken", "mytoken",
"--vault-name", "--vault-name",
"default" "default"
]); ]);
assert.equal(args.localPath, "/path/to/vault"); assert.equal(args.localPath, "/path/to/vault");
assert.equal(args.remoteUri, "https://sync.example.com"); assert.equal(args.remoteUri, "https://sync.example.com");
assert.equal(args.token, "mytoken"); assert.equal(args.token, "mytoken");
assert.equal(args.vaultName, "default"); assert.equal(args.vaultName, "default");
}); });
test("parseArgs - parse with optional arguments", () => { test("parseArgs - parse with optional arguments", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default", "default",
"--sync-concurrency", "--sync-concurrency",
"5", "5",
"--max-file-size-mb", "--max-file-size-mb",
"20" "20"
]); ]);
assert.equal(args.syncConcurrency, 5); assert.equal(args.syncConcurrency, 5);
assert.equal(args.maxFileSizeMB, 20); assert.equal(args.maxFileSizeMB, 20);
}); });
test("parseArgs - parse with multiple ignore patterns", () => { test("parseArgs - parse with multiple ignore patterns", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default", "default",
"--ignore-pattern", "--ignore-pattern",
".git/**", ".git/**",
"*.tmp" "*.tmp"
]); ]);
assert.deepEqual(args.ignorePatterns, [".git/**", "*.tmp"]); assert.deepEqual(args.ignorePatterns, [".git/**", "*.tmp"]);
}); });
test("parseArgs - throws on missing required arguments", () => { test("parseArgs - throws on missing required arguments", () => {
assert.throws(() => { assert.throws(() => {
parseArgs(["node", "cli.js", "-r", "https://sync.example.com"]); parseArgs(["node", "cli.js", "-r", "https://sync.example.com"]);
}, /required option/); }, /required option/);
}); });
test("parseArgs - throws on missing remote uri", () => { test("parseArgs - throws on missing remote uri", () => {
assert.throws(() => { assert.throws(() => {
parseArgs([ parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default" "default"
]); ]);
}, /--remote-uri/); }, /--remote-uri/);
}); });
test("parseArgs - throws on missing token", () => { test("parseArgs - throws on missing token", () => {
assert.throws(() => { assert.throws(() => {
parseArgs([ parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-v", "-v",
"default" "default"
]); ]);
}, /--token/); }, /--token/);
}); });
test("parseArgs - throws on missing vault name", () => { test("parseArgs - throws on missing vault name", () => {
assert.throws(() => { assert.throws(() => {
parseArgs([ parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken" "mytoken"
]); ]);
}, /--vault-name/); }, /--vault-name/);
}); });
test("parseArgs - default log level is INFO", () => { test("parseArgs - default log level is INFO", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default" "default"
]); ]);
assert.equal(args.logLevel, LogLevel.INFO); assert.equal(args.logLevel, LogLevel.INFO);
}); });
test("parseArgs - parse DEBUG log level", () => { test("parseArgs - parse DEBUG log level", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default", "default",
"--log-level", "--log-level",
"DEBUG" "DEBUG"
]); ]);
assert.equal(args.logLevel, LogLevel.DEBUG); assert.equal(args.logLevel, LogLevel.DEBUG);
}); });
test("parseArgs - parse ERROR log level", () => { test("parseArgs - parse ERROR log level", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default", "default",
"--log-level", "--log-level",
"ERROR" "ERROR"
]); ]);
assert.equal(args.logLevel, LogLevel.ERROR); assert.equal(args.logLevel, LogLevel.ERROR);
}); });
test("parseArgs - log level is case insensitive", () => { test("parseArgs - log level is case insensitive", () => {
const args = parseArgs([ const args = parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default", "default",
"--log-level", "--log-level",
"debug" "debug"
]); ]);
assert.equal(args.logLevel, LogLevel.DEBUG); assert.equal(args.logLevel, LogLevel.DEBUG);
}); });
test("parseArgs - throws on invalid log level", () => { test("parseArgs - throws on invalid log level", () => {
assert.throws(() => { assert.throws(() => {
parseArgs([ parseArgs([
"node", "node",
"cli.js", "cli.js",
"-l", "-l",
"/path/to/vault", "/path/to/vault",
"-r", "-r",
"https://sync.example.com", "https://sync.example.com",
"-t", "-t",
"mytoken", "mytoken",
"-v", "-v",
"default", "default",
"--log-level", "--log-level",
"INVALID" "INVALID"
]); ]);
}, /Invalid log level/); }, /Invalid log level/);
}); });

View file

@ -3,134 +3,134 @@ import packageJson from "../package.json";
import { LogLevel } from "sync-client"; import { LogLevel } from "sync-client";
export interface CliArgs { export interface CliArgs {
remoteUri: string; remoteUri: string;
token: string; token: string;
vaultName: string; vaultName: string;
localPath: string; localPath: string;
syncConcurrency?: number; syncConcurrency?: number;
maxFileSizeMB?: number; maxFileSizeMB?: number;
ignorePatterns?: string[]; ignorePatterns?: string[];
webSocketRetryIntervalMs?: number; webSocketRetryIntervalMs?: number;
logLevel: LogLevel; logLevel: LogLevel;
health?: string; health?: string;
enableTelemetry?: boolean; enableTelemetry?: boolean;
} }
export function parseArgs(argv: string[]): CliArgs { export function parseArgs(argv: string[]): CliArgs {
const program = new Command(); const program = new Command();
program program
.name("vaultlink") .name("vaultlink")
.description( .description(
"VaultLink Local CLI - Sync your vault to the local filesystem" "VaultLink Local CLI - Sync your vault to the local filesystem"
) )
.version(packageJson.version) .version(packageJson.version)
.option("-l, --local-path <path>", "Local directory path to sync") .option("-l, --local-path <path>", "Local directory path to sync")
.option("-r, --remote-uri <uri>", "Remote server URI") .option("-r, --remote-uri <uri>", "Remote server URI")
.option("-t, --token <token>", "Authentication token") .option("-t, --token <token>", "Authentication token")
.option("-v, --vault-name <name>", "Vault name") .option("-v, --vault-name <name>", "Vault name")
.option( .option(
"--sync-concurrency <number>", "--sync-concurrency <number>",
"[OPTIONAL] Number of concurrent sync operations", "[OPTIONAL] Number of concurrent sync operations",
parseInt parseInt
) )
.option( .option(
"--max-file-size-mb <number>", "--max-file-size-mb <number>",
"[OPTIONAL] Maximum file size in MB", "[OPTIONAL] Maximum file size in MB",
parseInt parseInt
) )
.option( .option(
"--ignore-pattern <pattern...>", "--ignore-pattern <pattern...>",
"[OPTIONAL] Patterns to ignore (can be specified multiple times)" "[OPTIONAL] Patterns to ignore (can be specified multiple times)"
) )
.option( .option(
"--websocket-retry-interval-ms <number>", "--websocket-retry-interval-ms <number>",
"[OPTIONAL] WebSocket retry interval in milliseconds", "[OPTIONAL] WebSocket retry interval in milliseconds",
parseInt parseInt
) )
.option( .option(
"--log-level <level>", "--log-level <level>",
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)", "[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
"INFO" "INFO"
) )
.option( .option(
"--health <path>", "--health <path>",
"[OPTIONAL] Path to health status file for Docker healthcheck" "[OPTIONAL] Path to health status file for Docker healthcheck"
) )
.option( .option(
"--enable-telemetry", "--enable-telemetry",
"[OPTIONAL] Enable telemetry (disabled by default)" "[OPTIONAL] Enable telemetry (disabled by default)"
) )
.addHelpText( .addHelpText(
"after", "after",
` `
Examples: Examples:
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\ $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
--ignore-pattern ".git/**" --ignore-pattern "*.tmp" --ignore-pattern ".git/**" --ignore-pattern "*.tmp"
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\ $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
--log-level DEBUG --log-level DEBUG
` `
); );
program.parse(argv); program.parse(argv);
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
const opts = program.opts(); const opts = program.opts();
const localPath = opts.localPath as string | undefined; const localPath = opts.localPath as string | undefined;
const remoteUri = opts.remoteUri as string | undefined; const remoteUri = opts.remoteUri as string | undefined;
const token = opts.token as string | undefined; const token = opts.token as string | undefined;
const vaultName = opts.vaultName as string | undefined; const vaultName = opts.vaultName as string | undefined;
const syncConcurrency = opts.syncConcurrency as number | undefined; const syncConcurrency = opts.syncConcurrency as number | undefined;
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined; const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
const ignorePattern = opts.ignorePattern as string[] | undefined; const ignorePattern = opts.ignorePattern as string[] | undefined;
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
| number | number
| undefined; | undefined;
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO"; const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
const health = opts.health as string | undefined; const health = opts.health as string | undefined;
const enableTelemetry = opts.enableTelemetry as boolean | undefined; const enableTelemetry = opts.enableTelemetry as boolean | undefined;
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
if (localPath === undefined) { if (localPath === undefined) {
throw new Error( throw new Error(
"required option '-l, --local-path <path>' not specified" "required option '-l, --local-path <path>' not specified"
); );
} }
if (remoteUri === undefined) { if (remoteUri === undefined) {
throw new Error("required option '--remote-uri <uri>' not specified"); throw new Error("required option '--remote-uri <uri>' not specified");
} }
if (token === undefined) { if (token === undefined) {
throw new Error("required option '--token <token>' not specified"); throw new Error("required option '--token <token>' not specified");
} }
if (vaultName === undefined) { if (vaultName === undefined) {
throw new Error("required option '--vault-name <name>' not specified"); throw new Error("required option '--vault-name <name>' not specified");
} }
// Validate and parse log level // Validate and parse log level
const logLevelUpper = logLevelStr.toUpperCase(); const logLevelUpper = logLevelStr.toUpperCase();
const validLogLevels = Object.values(LogLevel); const validLogLevels = Object.values(LogLevel);
const isLogLevel = (value: string): value is LogLevel => { const isLogLevel = (value: string): value is LogLevel => {
return (validLogLevels as readonly string[]).includes(value); return (validLogLevels as readonly string[]).includes(value);
}; };
if (!isLogLevel(logLevelUpper)) { if (!isLogLevel(logLevelUpper)) {
throw new Error( throw new Error(
`Invalid log level '${logLevelStr}'. Valid values are: ${validLogLevels.join(", ")}` `Invalid log level '${logLevelStr}'. Valid values are: ${validLogLevels.join(", ")}`
); );
} }
const logLevel = logLevelUpper; const logLevel = logLevelUpper;
return { return {
localPath, localPath,
remoteUri, remoteUri,
token, token,
vaultName, vaultName,
syncConcurrency, syncConcurrency,
maxFileSizeMB: maxFileSizeMb, maxFileSizeMB: maxFileSizeMb,
ignorePatterns: ignorePattern, ignorePatterns: ignorePattern,
webSocketRetryIntervalMs: websocketRetryIntervalMs, webSocketRetryIntervalMs: websocketRetryIntervalMs,
logLevel, logLevel,
health, health,
enableTelemetry enableTelemetry
}; };
} }

View file

@ -3,11 +3,11 @@ import * as fs from "fs/promises";
import * as fsSync from "fs"; import * as fsSync from "fs";
import type { NetworkConnectionStatus } from "sync-client"; import type { NetworkConnectionStatus } from "sync-client";
import { import {
SyncClient, SyncClient,
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
LogLevel, LogLevel,
type SyncSettings, type SyncSettings,
type StoredDatabase type StoredDatabase
} from "sync-client"; } from "sync-client";
import { parseArgs } from "./args"; import { parseArgs } from "./args";
import { NodeFileSystemOperations } from "./node-filesystem"; import { NodeFileSystemOperations } from "./node-filesystem";
@ -16,229 +16,233 @@ import { formatLogLine, colorize, styleText } from "./logger-formatter";
import packageJson from "../package.json"; import packageJson from "../package.json";
function writeHealthStatus( function writeHealthStatus(
filePath: string, filePath: string,
connectionStatus: NetworkConnectionStatus connectionStatus: NetworkConnectionStatus
): void { ): void {
try { try {
fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus)); fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus));
} catch (error) { } catch (error) {
console.error( console.error(
`Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}` `Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
const LOG_LEVEL_ORDER = { const LOG_LEVEL_ORDER = {
[LogLevel.DEBUG]: 0, [LogLevel.DEBUG]: 0,
[LogLevel.INFO]: 1, [LogLevel.INFO]: 1,
[LogLevel.WARNING]: 2, [LogLevel.WARNING]: 2,
[LogLevel.ERROR]: 3 [LogLevel.ERROR]: 3
}; };
async function main(): Promise<void> { async function main(): Promise<void> {
const args = parseArgs(process.argv); const args = parseArgs(process.argv);
const absolutePath = path.resolve(args.localPath); const absolutePath = path.resolve(args.localPath);
try { if (!fsSync.existsSync(absolutePath)) {
const stats = await fs.stat(absolutePath); fsSync.mkdirSync(absolutePath, { recursive: true });
if (!stats.isDirectory()) { }
console.error(
colorize(`Error: ${absolutePath} is not a directory`, "red")
);
process.exit(1);
}
} catch (error) {
console.error(
colorize(
`Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`,
"red"
)
);
process.exit(1);
}
console.log( try {
styleText("VaultLink Local CLI", "bold", "cyan") + const stats = await fs.stat(absolutePath);
colorize(` v${packageJson.version}`, "dim") if (!stats.isDirectory()) {
); console.error(
console.log(colorize("=".repeat(50), "dim")); colorize(`Error: ${absolutePath} is not a directory`, "red")
console.log( );
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}` process.exit(1);
); }
console.log( } catch (error) {
`${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}` console.error(
); colorize(
console.log( `Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`,
`${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}` "red"
); )
console.log(""); );
process.exit(1);
}
const dataDir = path.join(absolutePath, ".vaultlink"); console.log(
const dataFile = path.join(dataDir, "sync-data.json"); styleText("VaultLink Local CLI", "bold", "cyan") +
colorize(` v${packageJson.version}`, "dim")
);
console.log(colorize("=".repeat(50), "dim"));
console.log(
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}`
);
console.log(
`${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}`
);
console.log(
`${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}`
);
console.log("");
await fs.mkdir(dataDir, { recursive: true }); const dataDir = path.join(absolutePath, ".vaultlink");
const dataFile = path.join(dataDir, "sync-data.json");
const fileSystem = new NodeFileSystemOperations(absolutePath); await fs.mkdir(dataDir, { recursive: true });
const ignorePatterns = [ const fileSystem = new NodeFileSystemOperations(absolutePath);
...(args.ignorePatterns ?? []),
".vaultlink/**",
".git/**"
];
const settings: SyncSettings = { const ignorePatterns = [
...DEFAULT_SETTINGS, ...(args.ignorePatterns ?? []),
remoteUri: args.remoteUri, ".vaultlink/**",
token: args.token, ".git/**"
vaultName: args.vaultName, ];
syncConcurrency:
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
ignorePatterns,
webSocketRetryIntervalMs:
args.webSocketRetryIntervalMs ??
DEFAULT_SETTINGS.webSocketRetryIntervalMs,
isSyncEnabled: true,
enableTelemetry:
args.enableTelemetry ?? DEFAULT_SETTINGS.enableTelemetry
};
const client = await SyncClient.create({ const settings: SyncSettings = {
fs: fileSystem, ...DEFAULT_SETTINGS,
persistence: { remoteUri: args.remoteUri,
load: async () => { token: args.token,
let database: Partial<StoredDatabase> | undefined = undefined; vaultName: args.vaultName,
try { syncConcurrency:
const content = await fs.readFile(dataFile, "utf-8"); args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
database = JSON.parse(content) as Partial<StoredDatabase>; ignorePatterns,
} catch { webSocketRetryIntervalMs:
console.error( args.webSocketRetryIntervalMs ??
colorize( DEFAULT_SETTINGS.webSocketRetryIntervalMs,
`Cannot read data file at ${dataFile}`, isSyncEnabled: true,
"yellow" enableTelemetry:
) args.enableTelemetry ?? DEFAULT_SETTINGS.enableTelemetry
); };
}
return { const client = await SyncClient.create({
settings, fs: fileSystem,
database persistence: {
}; load: async () => {
}, let database: Partial<StoredDatabase> | undefined = undefined;
save: async ({ database: persistedDatabase }) => { try {
// settings can't be updated when running with this CLI const content = await fs.readFile(dataFile, "utf-8");
await fs.writeFile( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
dataFile, database = JSON.parse(content) as Partial<StoredDatabase>;
JSON.stringify(persistedDatabase, null, 2) } catch {
); console.error(
} colorize(
}, `Cannot read data file at ${dataFile}`,
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n" "yellow"
}); )
);
}
if (args.health !== undefined) { return {
const healthFile = args.health; settings,
const healthInterval = setInterval(() => { database
void client.checkConnection().then((status) => { };
writeHealthStatus(healthFile, status); },
}); save: async ({ database: persistedDatabase }) => {
}, 30 * 1000); // every 30 seconds // settings can't be updated when running with this CLI
const clearHealthInterval = (): void => { await fs.writeFile(
clearInterval(healthInterval); dataFile,
}; JSON.stringify(persistedDatabase, null, 2)
process.on("SIGINT", clearHealthInterval); );
process.on("SIGTERM", clearHealthInterval); }
process.on("exit", clearHealthInterval); },
} nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
});
// Add colored log formatter with level filtering if (args.health !== undefined) {
client.logger.addOnMessageListener((logLine) => { const healthFile = args.health;
// Only show messages at or above the configured log level const healthInterval = setInterval(() => {
if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[args.logLevel]) { void client.checkConnection().then((status) => {
console.log(formatLogLine(logLine)); writeHealthStatus(healthFile, status);
} });
}); }, 30 * 1000); // every 30 seconds
const clearHealthInterval = (): void => {
clearInterval(healthInterval);
};
process.on("SIGINT", clearHealthInterval);
process.on("SIGTERM", clearHealthInterval);
process.on("exit", clearHealthInterval);
}
client.logger.info("Starting sync client"); // Add colored log formatter with level filtering
client.logger.onLogEmitted.add((logLine) => {
// Only show messages at or above the configured log level
if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[args.logLevel]) {
console.log(formatLogLine(logLine));
}
});
const fileWatcher = new FileWatcher(absolutePath, client); client.logger.info("Starting sync client");
client.addWebSocketStatusChangeListener(() => { const fileWatcher = new FileWatcher(absolutePath, client);
const isConnected = client.isWebSocketConnected;
client.logger.info(
`WebSocket status changed: ${isConnected ? "connected" : "disconnected"}`
);
});
client.addRemainingSyncOperationsListener((remaining) => { client.onWebSocketStatusChanged.add(() => {
if (remaining === 0) { const isConnected = client.isWebSocketConnected;
client.logger.info("All sync operations completed"); client.logger.info(
} else { `WebSocket status changed: ${isConnected ? "connected" : "disconnected"}`
client.logger.info(`${remaining} sync operations remaining`); );
} });
});
const gracefulShutdown = async (signal: string): Promise<void> => { client.onRemainingOperationsCountChanged.add((remaining) => {
console.log( if (remaining === 0) {
colorize( client.logger.info("All sync operations completed");
`\n${signal} received. Shutting down gracefully...`, } else {
"yellow" client.logger.info(`${remaining} sync operations remaining`);
) }
); });
fileWatcher.stop(); const gracefulShutdown = async (signal: string): Promise<void> => {
await client.waitUntilFinished(); console.log(
await client.destroy(); colorize(
console.log(colorize("Shutdown complete", "green")); `\n${signal} received. Shutting down gracefully...`,
process.exit(0); "yellow"
}; )
);
process.on("SIGINT", () => { fileWatcher.stop();
void gracefulShutdown("SIGINT"); await client.waitUntilFinished();
}); await client.destroy();
process.on("SIGTERM", () => { console.log(colorize("Shutdown complete", "green"));
void gracefulShutdown("SIGTERM"); process.exit(0);
}); };
try { process.on("SIGINT", () => {
const connectionStatus = await client.checkConnection(); void gracefulShutdown("SIGINT");
if (!connectionStatus.isSuccessful) { });
console.error( process.on("SIGTERM", () => {
colorize( void gracefulShutdown("SIGTERM");
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`, });
"red"
)
);
process.exit(1);
}
console.log(`${colorize("✓", "green")} Server connection successful`); try {
console.log(colorize("Press Ctrl+C to stop", "dim")); const connectionStatus = await client.checkConnection();
console.log(""); if (!connectionStatus.isSuccessful) {
console.error(
colorize(
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`,
"red"
)
);
process.exit(1);
}
await client.start(); console.log(`${colorize("✓", "green")} Server connection successful`);
fileWatcher.start(); console.log(colorize("Press Ctrl+C to stop", "dim"));
} catch (error) { console.log("");
console.error(
colorize(
`Fatal error: ${error instanceof Error ? error.message : String(error)}`,
"red"
)
);
fileWatcher.stop(); await client.start();
await client.destroy(); fileWatcher.start();
process.exit(1); } catch (error) {
} console.error(
colorize(
`Fatal error: ${error instanceof Error ? error.message : String(error)}`,
"red"
)
);
fileWatcher.stop();
await client.destroy();
process.exit(1);
}
} }
main().catch((error: unknown) => { main().catch((error: unknown) => {
console.error( console.error(
colorize( colorize(
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`, `Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
"red" "red"
) )
); );
process.exit(1); process.exit(1);
}); });

View file

@ -1,102 +1,121 @@
import * as fs from "fs"; import Watcher from "watcher";
import * as path from "path"; import * as path from "path";
import type { SyncClient, RelativePath } from "sync-client"; import type { SyncClient, RelativePath } from "sync-client";
export class FileWatcher { export class FileWatcher {
private watcher: fs.FSWatcher | undefined; private watcher: Watcher | undefined;
private isRunning = false; private isRunning = false;
public constructor( public constructor(
private readonly basePath: string, private readonly basePath: string,
private readonly client: SyncClient private readonly client: SyncClient
) {} ) {}
public start(): void { public start(): void {
if (this.isRunning) { if (this.isRunning) {
return; return;
} }
this.isRunning = true; this.isRunning = true;
this.watcher = fs.watch( this.watcher = new Watcher(this.basePath, {
this.basePath, recursive: true,
{ recursive: true }, renameDetection: true,
(eventType, filename) => { renameTimeout: 125,
if (filename === null || filename.length === 0) { ignoreInitial: true
return; });
}
// Convert to forward slashes for consistency this.watcher.on("add", (filePath: string) => {
const relativePath = this.toUnixPath(filename); this.handleCreate(this.toRelativePath(filePath));
});
if (eventType === "rename") { this.watcher.on("change", (filePath: string) => {
this.handleRenameOrDelete(relativePath); this.handleChange(this.toRelativePath(filePath));
} else { });
// Must be "change" event
this.handleChange(relativePath);
}
}
);
this.client.logger.info("File watcher started"); this.watcher.on("unlink", (filePath: string) => {
} this.handleDelete(this.toRelativePath(filePath));
});
public stop(): void { this.watcher.on("rename", (oldPath: string, newPath: string) => {
if (this.watcher !== undefined) { this.handleRename(
this.watcher.close(); this.toRelativePath(oldPath),
this.watcher = undefined; this.toRelativePath(newPath)
} );
this.isRunning = false; });
this.client.logger.info("File watcher stopped");
}
private handleChange(relativePath: RelativePath): void { this.client.logger.info("File watcher started");
this.client }
.syncLocallyUpdatedFile({ relativePath })
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync updated file ${relativePath}: ${err instanceof Error ? err.message : String(err)}`
);
});
}
private handleRenameOrDelete(relativePath: RelativePath): void { public stop(): void {
const fullPath = path.join(this.basePath, relativePath); if (this.watcher !== undefined) {
this.watcher.close();
this.watcher = undefined;
}
this.isRunning = false;
this.client.logger.info("File watcher stopped");
}
fs.access(fullPath, fs.constants.F_OK, (accessError) => { private handleCreate(relativePath: RelativePath): void {
if (accessError) { this.client
this.client .syncLocallyCreatedFile(relativePath)
.syncLocallyDeletedFile(relativePath) .catch((err: unknown) => {
.catch((deleteErr: unknown) => { this.client.logger.error(
this.client.logger.error( `Failed to sync created file ${relativePath}: ${this.formatError(err)}`
`Failed to sync deleted file ${relativePath}: ${deleteErr instanceof Error ? deleteErr.message : String(deleteErr)}` );
); });
}); }
} else {
fs.stat(fullPath, (statErr, stats) => {
if (statErr !== null || !stats.isFile()) {
return;
}
this.client private handleChange(relativePath: RelativePath): void {
.syncLocallyCreatedFile(relativePath) this.client
.catch((createErr: unknown) => { .syncLocallyUpdatedFile({ relativePath })
this.client.logger.error( .catch((err: unknown) => {
`Failed to sync created file ${relativePath}: ${createErr instanceof Error ? createErr.message : String(createErr)}` this.client.logger.error(
); `Failed to sync updated file ${relativePath}: ${this.formatError(err)}`
}); );
}); });
} }
});
}
/** private handleDelete(relativePath: RelativePath): void {
* Convert a native platform path to forward slashes this.client
*/ .syncLocallyDeletedFile(relativePath)
private toUnixPath(nativePath: string): string { .catch((err: unknown) => {
if (path.sep === "\\") { this.client.logger.error(
return nativePath.replace(/\\/g, "/"); `Failed to sync deleted file ${relativePath}: ${this.formatError(err)}`
} );
return nativePath; });
} }
private handleRename(oldPath: RelativePath, newPath: RelativePath): void {
this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`);
this.client
.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
})
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync renamed file ${oldPath} -> ${newPath}: ${this.formatError(err)}`
);
});
}
private toRelativePath(absolutePath: string): RelativePath {
const relative = path.relative(this.basePath, absolutePath);
return this.toUnixPath(relative);
}
/**
* Convert a native platform path to forward slashes
*/
private toUnixPath(nativePath: string): string {
if (path.sep === "\\") {
return nativePath.replace(/\\/g, "/");
}
return nativePath;
}
private formatError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
} }

View file

@ -9,58 +9,58 @@ import * as fs from "fs";
import type { NetworkConnectionStatus } from "sync-client"; import type { NetworkConnectionStatus } from "sync-client";
function isHealthStatus(value: unknown): value is NetworkConnectionStatus { function isHealthStatus(value: unknown): value is NetworkConnectionStatus {
if (typeof value !== "object" || value === null) { if (typeof value !== "object" || value === null) {
return false; return false;
} }
return ( return (
"isSuccessful" in value && "isSuccessful" in value &&
typeof value.isSuccessful === "boolean" && typeof value.isSuccessful === "boolean" &&
"isWebSocketConnected" in value && "isWebSocketConnected" in value &&
typeof value.isWebSocketConnected === "boolean" && typeof value.isWebSocketConnected === "boolean" &&
"serverMessage" in value && "serverMessage" in value &&
typeof value.serverMessage === "string" typeof value.serverMessage === "string"
); );
} }
function main(): void { function main(): void {
if (process.argv.length < 3) { if (process.argv.length < 3) {
console.error("Usage: healthcheck <path-to-health-file>"); console.error("Usage: healthcheck <path-to-health-file>");
process.exit(1); process.exit(1);
} }
const [, , healthFile] = process.argv; const [, , healthFile] = process.argv;
try { try {
// Check if health file exists // Check if health file exists
if (!fs.existsSync(healthFile)) { if (!fs.existsSync(healthFile)) {
console.error(`Health file does not exist: ${healthFile}`); console.error(`Health file does not exist: ${healthFile}`);
process.exit(1); process.exit(1);
} }
// Read and parse health status // Read and parse health status
const content = fs.readFileSync(healthFile, "utf-8"); const content = fs.readFileSync(healthFile, "utf-8");
const parsed: unknown = JSON.parse(content); const parsed: unknown = JSON.parse(content);
// Validate the parsed object using type guard // Validate the parsed object using type guard
if (!isHealthStatus(parsed)) { if (!isHealthStatus(parsed)) {
throw new Error("Invalid health status format"); throw new Error("Invalid health status format");
} }
const status = parsed; const status = parsed;
if (!status.isSuccessful || !status.isWebSocketConnected) { if (!status.isSuccessful || !status.isWebSocketConnected) {
console.error("Not connected to server: " + status.serverMessage); console.error("Not connected to server: " + status.serverMessage);
process.exit(1); process.exit(1);
} }
console.log("Healthy: Connected to server"); console.log("Healthy: Connected to server");
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
console.error( console.error(
`Health check failed: ${error instanceof Error ? error.message : String(error)}` `Health check failed: ${error instanceof Error ? error.message : String(error)}`
); );
process.exit(1); process.exit(1);
} }
} }
main(); main();

View file

@ -2,85 +2,85 @@ import { LogLevel, type LogLine } from "sync-client";
// ANSI color codes // ANSI color codes
export const colors = { export const colors = {
reset: "\x1b[0m", reset: "\x1b[0m",
bold: "\x1b[1m", bold: "\x1b[1m",
dim: "\x1b[2m", dim: "\x1b[2m",
// Foreground colors // Foreground colors
red: "\x1b[31m", red: "\x1b[31m",
green: "\x1b[32m", green: "\x1b[32m",
yellow: "\x1b[33m", yellow: "\x1b[33m",
blue: "\x1b[34m", blue: "\x1b[34m",
magenta: "\x1b[35m", magenta: "\x1b[35m",
cyan: "\x1b[36m", cyan: "\x1b[36m",
gray: "\x1b[90m" gray: "\x1b[90m"
} as const; } as const;
export function colorize(text: string, color: keyof typeof colors): string { export function colorize(text: string, color: keyof typeof colors): string {
return `${colors[color]}${text}${colors.reset}`; return `${colors[color]}${text}${colors.reset}`;
} }
/** /**
* Helper function to apply multiple color modifiers to text * Helper function to apply multiple color modifiers to text
*/ */
export function styleText( export function styleText(
text: string, text: string,
...modifiers: (keyof typeof colors)[] ...modifiers: (keyof typeof colors)[]
): string { ): string {
const prefix = modifiers.map((m) => colors[m]).join(""); const prefix = modifiers.map((m) => colors[m]).join("");
return `${prefix}${text}${colors.reset}`; return `${prefix}${text}${colors.reset}`;
} }
function formatTimestamp(date: Date): string { function formatTimestamp(date: Date): string {
const [time] = date.toTimeString().split(" "); const [time] = date.toTimeString().split(" ");
const ms = date.getMilliseconds().toString().padStart(3, "0"); const ms = date.getMilliseconds().toString().padStart(3, "0");
return colorize(`${time}.${ms}`, "gray"); return colorize(`${time}.${ms}`, "gray");
} }
function formatLevel(level: LogLevel): string { function formatLevel(level: LogLevel): string {
const levelStr = level.padEnd(7); const levelStr = level.padEnd(7);
switch (level) { switch (level) {
case LogLevel.DEBUG: case LogLevel.DEBUG:
return colorize(levelStr, "cyan"); return colorize(levelStr, "cyan");
case LogLevel.INFO: case LogLevel.INFO:
return colorize(levelStr, "green"); return colorize(levelStr, "green");
case LogLevel.WARNING: case LogLevel.WARNING:
return colorize(levelStr, "yellow"); return colorize(levelStr, "yellow");
case LogLevel.ERROR: case LogLevel.ERROR:
return colorize(levelStr, "red"); return colorize(levelStr, "red");
} }
} }
function formatMessage(message: string, level: LogLevel): string { function formatMessage(message: string, level: LogLevel): string {
// Highlight important parts of the message // Highlight important parts of the message
let formatted = message; let formatted = message;
// Highlight file paths // Highlight file paths
formatted = formatted.replace( formatted = formatted.replace(
/(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g, /(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g,
(_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2 (_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2
); );
// Highlight numbers // Highlight numbers
formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan")); formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan"));
// Highlight patterns like /regex/ // Highlight patterns like /regex/
formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) => formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) =>
colorize(pattern, "yellow") colorize(pattern, "yellow")
); );
// Make error messages bold // Make error messages bold
if (level === LogLevel.ERROR) { if (level === LogLevel.ERROR) {
formatted = colorize(formatted, "bold"); formatted = colorize(formatted, "bold");
} }
return formatted; return formatted;
} }
export function formatLogLine(logLine: LogLine): string { export function formatLogLine(logLine: LogLine): string {
const timestamp = formatTimestamp(logLine.timestamp); const timestamp = formatTimestamp(logLine.timestamp);
const level = formatLevel(logLine.level); const level = formatLevel(logLine.level);
const message = formatMessage(logLine.message, logLine.level); const message = formatMessage(logLine.message, logLine.level);
return `${timestamp} ${level} ${message}`; return `${timestamp} ${level} ${message}`;
} }

View file

@ -6,157 +6,157 @@ import * as os from "os";
import { NodeFileSystemOperations } from "./node-filesystem"; import { NodeFileSystemOperations } from "./node-filesystem";
test("NodeFileSystemOperations - read and write files", async () => { test("NodeFileSystemOperations - read and write files", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
const content = new TextEncoder().encode("Hello, world!"); const content = new TextEncoder().encode("Hello, world!");
await fsOps.write("test.txt", content); await fsOps.write("test.txt", content);
const readContent = await fsOps.read("test.txt"); const readContent = await fsOps.read("test.txt");
assert.equal(new TextDecoder().decode(readContent), "Hello, world!"); assert.equal(new TextDecoder().decode(readContent), "Hello, world!");
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
test("NodeFileSystemOperations - create nested directories with forward slashes", async () => { test("NodeFileSystemOperations - create nested directories with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
const content = new TextEncoder().encode("Nested file"); const content = new TextEncoder().encode("Nested file");
// Always use forward slashes in API // Always use forward slashes in API
await fsOps.write("dir1/dir2/test.txt", content); await fsOps.write("dir1/dir2/test.txt", content);
const readContent = await fsOps.read("dir1/dir2/test.txt"); const readContent = await fsOps.read("dir1/dir2/test.txt");
assert.equal(new TextDecoder().decode(readContent), "Nested file"); assert.equal(new TextDecoder().decode(readContent), "Nested file");
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
test("NodeFileSystemOperations - exists with forward slashes", async () => { test("NodeFileSystemOperations - exists with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
assert.equal(await fsOps.exists("test.txt"), false); assert.equal(await fsOps.exists("test.txt"), false);
await fsOps.write("test.txt", new TextEncoder().encode("test")); await fsOps.write("test.txt", new TextEncoder().encode("test"));
assert.equal(await fsOps.exists("test.txt"), true); assert.equal(await fsOps.exists("test.txt"), true);
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
test("NodeFileSystemOperations - delete with forward slashes", async () => { test("NodeFileSystemOperations - delete with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
await fsOps.write("test.txt", new TextEncoder().encode("test")); await fsOps.write("test.txt", new TextEncoder().encode("test"));
assert.equal(await fsOps.exists("test.txt"), true); assert.equal(await fsOps.exists("test.txt"), true);
await fsOps.delete("test.txt"); await fsOps.delete("test.txt");
assert.equal(await fsOps.exists("test.txt"), false); assert.equal(await fsOps.exists("test.txt"), false);
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
test("NodeFileSystemOperations - rename with forward slashes", async () => { test("NodeFileSystemOperations - rename with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
const content = new TextEncoder().encode("test content"); const content = new TextEncoder().encode("test content");
await fsOps.write("old.txt", content); await fsOps.write("old.txt", content);
await fsOps.rename("old.txt", "new.txt"); await fsOps.rename("old.txt", "new.txt");
assert.equal(await fsOps.exists("old.txt"), false); assert.equal(await fsOps.exists("old.txt"), false);
assert.equal(await fsOps.exists("new.txt"), true); assert.equal(await fsOps.exists("new.txt"), true);
const readContent = await fsOps.read("new.txt"); const readContent = await fsOps.read("new.txt");
assert.equal(new TextDecoder().decode(readContent), "test content"); assert.equal(new TextDecoder().decode(readContent), "test content");
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
test("NodeFileSystemOperations - rename to nested path with forward slashes", async () => { test("NodeFileSystemOperations - rename to nested path with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
const content = new TextEncoder().encode("test content"); const content = new TextEncoder().encode("test content");
await fsOps.write("old.txt", content); await fsOps.write("old.txt", content);
await fsOps.rename("old.txt", "dir1/dir2/new.txt"); await fsOps.rename("old.txt", "dir1/dir2/new.txt");
assert.equal(await fsOps.exists("old.txt"), false); assert.equal(await fsOps.exists("old.txt"), false);
assert.equal(await fsOps.exists("dir1/dir2/new.txt"), true); assert.equal(await fsOps.exists("dir1/dir2/new.txt"), true);
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
test("NodeFileSystemOperations - getFileSize", async () => { test("NodeFileSystemOperations - getFileSize", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
const content = new TextEncoder().encode("Hello!"); const content = new TextEncoder().encode("Hello!");
await fsOps.write("test.txt", content); await fsOps.write("test.txt", content);
const size = await fsOps.getFileSize("test.txt"); const size = await fsOps.getFileSize("test.txt");
assert.equal(size, content.length); assert.equal(size, content.length);
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
test("NodeFileSystemOperations - atomicUpdateText", async () => { test("NodeFileSystemOperations - atomicUpdateText", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
await fsOps.write("test.txt", new TextEncoder().encode("Hello")); await fsOps.write("test.txt", new TextEncoder().encode("Hello"));
const result = await fsOps.atomicUpdateText("test.txt", (current) => ({ const result = await fsOps.atomicUpdateText("test.txt", (current) => ({
text: current.text + " World", text: current.text + " World",
cursors: [] cursors: []
})); }));
assert.equal(result, "Hello World"); assert.equal(result, "Hello World");
const content = await fsOps.read("test.txt"); const content = await fsOps.read("test.txt");
assert.equal(new TextDecoder().decode(content), "Hello World"); assert.equal(new TextDecoder().decode(content), "Hello World");
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });
test("NodeFileSystemOperations - handles paths with forward slashes on all platforms", async () => { test("NodeFileSystemOperations - handles paths with forward slashes on all platforms", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir); const fsOps = new NodeFileSystemOperations(tempDir);
try { try {
// API should always accept forward slashes // API should always accept forward slashes
const testPath = "deep/nested/directory/file.txt"; const testPath = "deep/nested/directory/file.txt";
const content = new TextEncoder().encode("test"); const content = new TextEncoder().encode("test");
await fsOps.write(testPath, content); await fsOps.write(testPath, content);
assert.equal(await fsOps.exists(testPath), true); assert.equal(await fsOps.exists(testPath), true);
const readContent = await fsOps.read(testPath); const readContent = await fsOps.read(testPath);
assert.equal(new TextDecoder().decode(readContent), "test"); assert.equal(new TextDecoder().decode(readContent), "test");
await fsOps.delete(testPath); await fsOps.delete(testPath);
assert.equal(await fsOps.exists(testPath), false); assert.equal(await fsOps.exists(testPath), false);
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });

View file

@ -2,205 +2,205 @@ import * as fs from "fs/promises";
import type { Dirent } from "fs"; import type { Dirent } from "fs";
import * as path from "path"; import * as path from "path";
import type { import type {
FileSystemOperations, FileSystemOperations,
RelativePath, RelativePath,
TextWithCursors TextWithCursors
} from "sync-client"; } from "sync-client";
export class NodeFileSystemOperations implements FileSystemOperations { export class NodeFileSystemOperations implements FileSystemOperations {
public constructor(private readonly basePath: string) {} public constructor(private readonly basePath: string) {}
public async listFilesRecursively( public async listFilesRecursively(
directory: RelativePath | undefined directory: RelativePath | undefined
): Promise<RelativePath[]> { ): Promise<RelativePath[]> {
const files: RelativePath[] = []; const files: RelativePath[] = [];
await this.walkDirectory( await this.walkDirectory(
directory !== undefined ? this.toNativePath(directory) : "", directory !== undefined ? this.toNativePath(directory) : "",
files files
); );
return files; return files;
} }
public async read(relativePath: RelativePath): Promise<Uint8Array> { public async read(relativePath: RelativePath): Promise<Uint8Array> {
const fullPath = path.join( const fullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(relativePath) this.toNativePath(relativePath)
); );
try { try {
return await fs.readFile(fullPath); return await fs.readFile(fullPath);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
public async write( public async write(
relativePath: RelativePath, relativePath: RelativePath,
content: Uint8Array content: Uint8Array
): Promise<void> { ): Promise<void> {
const fullPath = path.join( const fullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(relativePath) this.toNativePath(relativePath)
); );
const dir = path.dirname(fullPath); const dir = path.dirname(fullPath);
try { try {
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
await fs.writeFile(fullPath, content); await fs.writeFile(fullPath, content);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
public async atomicUpdateText( public async atomicUpdateText(
relativePath: RelativePath, relativePath: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> { ): Promise<string> {
const fullPath = path.join( const fullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(relativePath) this.toNativePath(relativePath)
); );
try { try {
const currentContent = await fs.readFile(fullPath, "utf-8"); const currentContent = await fs.readFile(fullPath, "utf-8");
const result = updater({ text: currentContent, cursors: [] }); const result = updater({ text: currentContent, cursors: [] });
await fs.writeFile(fullPath, result.text, "utf-8"); await fs.writeFile(fullPath, result.text, "utf-8");
return result.text; return result.text;
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
public async getFileSize(relativePath: RelativePath): Promise<number> { public async getFileSize(relativePath: RelativePath): Promise<number> {
const fullPath = path.join( const fullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(relativePath) this.toNativePath(relativePath)
); );
try { try {
const stats = await fs.stat(fullPath); const stats = await fs.stat(fullPath);
return stats.size; return stats.size;
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
public async exists(relativePath: RelativePath): Promise<boolean> { public async exists(relativePath: RelativePath): Promise<boolean> {
const fullPath = path.join( const fullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(relativePath) this.toNativePath(relativePath)
); );
try { try {
await fs.access(fullPath); await fs.access(fullPath);
return true; return true;
} catch { } catch {
return false; return false;
} }
} }
public async createDirectory(relativePath: RelativePath): Promise<void> { public async createDirectory(relativePath: RelativePath): Promise<void> {
const fullPath = path.join( const fullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(relativePath) this.toNativePath(relativePath)
); );
try { try {
await fs.mkdir(fullPath, { recursive: false }); await fs.mkdir(fullPath, { recursive: false });
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
public async delete(relativePath: RelativePath): Promise<void> { public async delete(relativePath: RelativePath): Promise<void> {
const fullPath = path.join( const fullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(relativePath) this.toNativePath(relativePath)
); );
try { try {
await fs.unlink(fullPath); await fs.unlink(fullPath);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
public async rename( public async rename(
oldPath: RelativePath, oldPath: RelativePath,
newPath: RelativePath newPath: RelativePath
): Promise<void> { ): Promise<void> {
const oldFullPath = path.join( const oldFullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(oldPath) this.toNativePath(oldPath)
); );
const newFullPath = path.join( const newFullPath = path.join(
this.basePath, this.basePath,
this.toNativePath(newPath) this.toNativePath(newPath)
); );
const newDir = path.dirname(newFullPath); const newDir = path.dirname(newFullPath);
try { try {
await fs.mkdir(newDir, { recursive: true }); await fs.mkdir(newDir, { recursive: true });
await fs.rename(oldFullPath, newFullPath); await fs.rename(oldFullPath, newFullPath);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
} }
private async walkDirectory( private async walkDirectory(
relativePath: string, relativePath: string,
files: RelativePath[] files: RelativePath[]
): Promise<void> { ): Promise<void> {
const fullPath = path.join(this.basePath, relativePath); const fullPath = path.join(this.basePath, relativePath);
let entries: Dirent[] = []; let entries: Dirent[] = [];
try { try {
entries = await fs.readdir(fullPath, { withFileTypes: true }); entries = await fs.readdir(fullPath, { withFileTypes: true });
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}` `Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
); );
} }
for (const entry of entries) { for (const entry of entries) {
const entryName = entry.name; const entryName = entry.name;
const entryRelativePath = path.join(relativePath, entryName); const entryRelativePath = path.join(relativePath, entryName);
if (entry.isDirectory()) { if (entry.isDirectory()) {
await this.walkDirectory(entryRelativePath, files); await this.walkDirectory(entryRelativePath, files);
} else if (entry.isFile()) { } else if (entry.isFile()) {
// Always return forward slashes // Always return forward slashes
files.push(this.toUnixPath(entryRelativePath)); files.push(this.toUnixPath(entryRelativePath));
} }
} }
} }
/** /**
* Convert a forward-slash path to native platform path separators * Convert a forward-slash path to native platform path separators
*/ */
private toNativePath(relativePath: string): string { private toNativePath(relativePath: string): string {
if (path.sep === "\\") { if (path.sep === "\\") {
return relativePath.replace(/\//g, "\\"); return relativePath.replace(/\//g, "\\");
} }
return relativePath; return relativePath;
} }
/** /**
* Convert a native platform path to forward slashes * Convert a native platform path to forward slashes
*/ */
private toUnixPath(nativePath: string): string { private toUnixPath(nativePath: string): string {
if (path.sep === "\\") { if (path.sep === "\\") {
return nativePath.replace(/\\/g, "/"); return nativePath.replace(/\\/g, "/");
} }
return nativePath; return nativePath;
} }
} }

View file

@ -4,7 +4,7 @@
"module": "ESNext", "module": "ESNext",
"lib": [ "lib": [
"DOM", // to get `fetch` & `WebSocket` "DOM", // to get `fetch` & `WebSocket`
"ES2024" "ES2024"
], ],
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
@ -18,5 +18,7 @@
"declarationMap": true, "declarationMap": true,
"sourceMap": true "sourceMap": true
}, },
"exclude": ["dist"] "exclude": [
"dist"
]
} }

View file

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

View file

@ -0,0 +1 @@

View file

@ -85,8 +85,3 @@ If you have multiple URLs, you can also do:
## API Documentation ## API Documentation
See https://github.com/obsidianmd/obsidian-api See https://github.com/obsidianmd/obsidian-api

View file

@ -1,10 +1,10 @@
{ {
"id": "vault-link", "id": "vault-link",
"name": "VaultLink", "name": "VaultLink",
"version": "0.12.0", "version": "0.12.0",
"minAppVersion": "0.0.0", "minAppVersion": "0.0.0",
"description": "Self-hosted synchronization and collaboration for your Vault.", "description": "Self-hosted synchronization and collaboration for your Vault.",
"author": "Andras Schmelczer", "author": "Andras Schmelczer",
"authorUrl": "https://schmelczer.dev", "authorUrl": "https://schmelczer.dev",
"isDesktopOnly": false "isDesktopOnly": false
} }

View file

@ -2,175 +2,175 @@ import type { Stat, Vault, Workspace } from "obsidian";
import { MarkdownView, normalizePath } from "obsidian"; import { MarkdownView, normalizePath } from "obsidian";
import type { CursorPosition, TextWithCursors } from "sync-client"; import type { CursorPosition, TextWithCursors } from "sync-client";
import { import {
utils, utils,
type FileSystemOperations, type FileSystemOperations,
type RelativePath type RelativePath
} from "sync-client"; } from "sync-client";
import { getSelectionsFromEditor } from "./views/cursors/get-selections-from-editor"; import { getSelectionsFromEditor } from "./views/cursors/get-selections-from-editor";
export class ObsidianFileSystemOperations implements FileSystemOperations { export class ObsidianFileSystemOperations implements FileSystemOperations {
public constructor( public constructor(
private readonly vault: Vault, private readonly vault: Vault,
private readonly workspace: Workspace private readonly workspace: Workspace
) {} ) {}
public async listFilesRecursively( public async listFilesRecursively(
root: RelativePath | undefined root: RelativePath | undefined
): Promise<RelativePath[]> { ): Promise<RelativePath[]> {
// Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files. // Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files.
const allFiles = []; const allFiles = [];
const remainingFolders = [root ?? this.vault.getRoot().path]; const remainingFolders = [root ?? this.vault.getRoot().path];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) { while (true) {
const folder = remainingFolders.pop(); const folder = remainingFolders.pop();
if (folder == undefined) { if (folder == undefined) {
break; break;
} }
// This would be a very bad idea to sync as it would mess with // This would be a very bad idea to sync as it would mess with
// the integrity of the sync database. // the integrity of the sync database.
if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) { if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) {
continue; continue;
} }
const files = await this.vault.adapter.list(normalizePath(folder)); const files = await this.vault.adapter.list(normalizePath(folder));
allFiles.push(...files.files); allFiles.push(...files.files);
remainingFolders.push(...files.folders); remainingFolders.push(...files.folders);
} }
return allFiles; return allFiles;
} }
public async read(path: RelativePath): Promise<Uint8Array> { public async read(path: RelativePath): Promise<Uint8Array> {
path = normalizePath(path); path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView); const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) { if (view?.file?.path === path) {
return new TextEncoder().encode(view.editor.getValue()); return new TextEncoder().encode(view.editor.getValue());
} }
return new Uint8Array(await this.vault.adapter.readBinary(path)); return new Uint8Array(await this.vault.adapter.readBinary(path));
} }
public async write(path: RelativePath, content: Uint8Array): Promise<void> { public async write(path: RelativePath, content: Uint8Array): Promise<void> {
path = normalizePath(path); path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView); const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) { if (view?.file?.path === path) {
const position = view.editor.getCursor(); const position = view.editor.getCursor();
view.editor.setValue(new TextDecoder().decode(content)); view.editor.setValue(new TextDecoder().decode(content));
view.editor.setCursor(position); view.editor.setCursor(position);
return; return;
} }
return this.vault.adapter.writeBinary( return this.vault.adapter.writeBinary(
path, path,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
content.buffer as ArrayBuffer content.buffer as ArrayBuffer
); );
} }
public async atomicUpdateText( public async atomicUpdateText(
path: RelativePath, path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> { ): Promise<string> {
path = normalizePath(path); path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView); const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) { if (view?.file?.path === path) {
const text = view.editor.getValue(); const text = view.editor.getValue();
const cursors: CursorPosition[] = getSelectionsFromEditor( const cursors: CursorPosition[] = getSelectionsFromEditor(
view.editor view.editor
).flatMap(({ id, start: anchor, end: head }) => [ ).flatMap(({ id, start: anchor, end: head }) => [
{ {
id: 2 * id, id: 2 * id,
position: anchor position: anchor
}, },
{ {
id: 2 * id + 1, id: 2 * id + 1,
position: head position: head
} }
]); ]);
const result = updater({ const result = updater({
text, text,
cursors cursors
}); });
if (result.text === text) { if (result.text === text) {
return text; return text;
} }
view.editor.setValue(result.text); view.editor.setValue(result.text);
const selections = []; const selections = [];
for (let i = 0; i < result.cursors.length / 2; i++) { for (let i = 0; i < result.cursors.length / 2; i++) {
const from = result.cursors[2 * i]; const from = result.cursors[2 * i];
const to = result.cursors[2 * i + 1]; const to = result.cursors[2 * i + 1];
const { line: fromLine, column: fromColumn } = const { line: fromLine, column: fromColumn } =
utils.positionToLineAndColumn(result.text, from.position); utils.positionToLineAndColumn(result.text, from.position);
const { line: toLine, column: toColumn } = const { line: toLine, column: toColumn } =
utils.positionToLineAndColumn(result.text, to.position); utils.positionToLineAndColumn(result.text, to.position);
selections.push({ selections.push({
anchor: { line: fromLine, ch: fromColumn }, anchor: { line: fromLine, ch: fromColumn },
head: { line: toLine, ch: toColumn } head: { line: toLine, ch: toColumn }
}); });
} }
view.editor.setSelections(selections); view.editor.setSelections(selections);
return result.text; return result.text;
} }
return this.vault.adapter.process( return this.vault.adapter.process(
path, path,
(text) => (text) =>
updater({ updater({
text, text,
cursors: [] cursors: []
}).text }).text
); );
} }
public async getFileSize(path: RelativePath): Promise<number> { public async getFileSize(path: RelativePath): Promise<number> {
return (await this.statFile(path)).size; return (await this.statFile(path)).size;
} }
public async getModificationTime(path: RelativePath): Promise<Date> { public async getModificationTime(path: RelativePath): Promise<Date> {
return new Date((await this.statFile(path)).mtime); return new Date((await this.statFile(path)).mtime);
} }
public async exists(path: RelativePath): Promise<boolean> { public async exists(path: RelativePath): Promise<boolean> {
return this.vault.adapter.exists(normalizePath(path)); return this.vault.adapter.exists(normalizePath(path));
} }
public async createDirectory(path: RelativePath): Promise<void> { public async createDirectory(path: RelativePath): Promise<void> {
return this.vault.adapter.mkdir(normalizePath(path)); return this.vault.adapter.mkdir(normalizePath(path));
} }
public async delete(path: RelativePath): Promise<void> { public async delete(path: RelativePath): Promise<void> {
if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) { if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) {
return this.vault.adapter.remove(normalizePath(path)); return this.vault.adapter.remove(normalizePath(path));
} }
} }
public async rename( public async rename(
oldPath: RelativePath, oldPath: RelativePath,
newPath: RelativePath newPath: RelativePath
): Promise<void> { ): Promise<void> {
return this.vault.adapter.rename(oldPath, newPath); return this.vault.adapter.rename(oldPath, newPath);
} }
private async statFile(path: string): Promise<Stat> { private async statFile(path: string): Promise<Stat> {
const file = await this.vault.adapter.stat(normalizePath(path)); const file = await this.vault.adapter.stat(normalizePath(path));
if (!file) { if (!file) {
throw new Error(`File not found: ${path}`); throw new Error(`File not found: ${path}`);
} }
return file; return file;
} }
} }

View file

@ -1,9 +1,9 @@
import type { import type {
MarkdownView, MarkdownView,
Editor, Editor,
MarkdownFileInfo, MarkdownFileInfo,
TAbstractFile, TAbstractFile,
WorkspaceLeaf WorkspaceLeaf
} from "obsidian"; } from "obsidian";
import { Notice, Platform, Plugin, TFile } from "obsidian"; import { Notice, Platform, Plugin, TFile } from "obsidian";
import "../manifest.json"; import "../manifest.json";
@ -12,19 +12,19 @@ import { StatusBar } from "./views/status-bar/status-bar";
import { LogsView } from "./views/logs/logs-view"; import { LogsView } from "./views/logs/logs-view";
import { StatusDescription } from "./views/status-description/status-description"; import { StatusDescription } from "./views/status-description/status-description";
import { import {
SyncClient, SyncClient,
rateLimit, rateLimit,
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
Logger, Logger,
debugging debugging
} from "sync-client"; } from "sync-client";
import { ObsidianFileSystemOperations } from "./obsidian-file-system"; import { ObsidianFileSystemOperations } from "./obsidian-file-system";
import { SyncSettingsTab } from "./views/settings/settings-tab"; import { SyncSettingsTab } from "./views/settings/settings-tab";
import { EditorStatusDisplayManager } from "./views/editor-status-display-manager/editor-status-display-manager"; import { EditorStatusDisplayManager } from "./views/editor-status-display-manager/editor-status-display-manager";
import { remoteCursorsTheme } from "./views/cursors/remote-cursor-theme"; import { remoteCursorsTheme } from "./views/cursors/remote-cursor-theme";
import { import {
remoteCursorsPlugin, remoteCursorsPlugin,
RemoteCursorsPluginValue RemoteCursorsPluginValue
} from "./views/cursors/remote-cursors-plugin"; } from "./views/cursors/remote-cursors-plugin";
import { LocalCursorUpdateListener } from "./views/cursors/local-cursor-update-listener"; import { LocalCursorUpdateListener } from "./views/cursors/local-cursor-update-listener";
import { renderCursorsInFileExplorer } from "./views/cursors/file-explorer"; import { renderCursorsInFileExplorer } from "./views/cursors/file-explorer";
@ -33,252 +33,249 @@ const MIN_WAIT_BETWEEN_UPDATES_IN_MS = 250;
const IS_DEBUG_BUILD = process.env.NODE_ENV === "development"; const IS_DEBUG_BUILD = process.env.NODE_ENV === "development";
export default class VaultLinkPlugin extends Plugin { export default class VaultLinkPlugin extends Plugin {
private readonly rateLimitedUpdatesPerFile = new Map< private readonly rateLimitedUpdatesPerFile = new Map<
string, string,
() => Promise<unknown> () => Promise<unknown>
>(); >();
private readonly syncClient: SyncClient | undefined; private readonly syncClient: SyncClient | undefined;
private settingsTab: SyncSettingsTab | undefined; private settingsTab: SyncSettingsTab | undefined;
public async onload(): Promise<void> { public async onload(): Promise<void> {
this.app.workspace.onLayoutReady(async () => { this.app.workspace.onLayoutReady(async () => {
// eslint-disable-next-line // eslint-disable-next-line
if ((globalThis as any).VAULT_LINK_RUNNING_INSTANCE) { if ((globalThis as any).VAULT_LINK_RUNNING_INSTANCE) {
new Notice( new Notice(
"Another instance of VaultLink is already running. Please disable the duplicate instance." "Another instance of VaultLink is already running. Please disable the duplicate instance."
); );
throw new Error("VaultLink instance already running"); throw new Error("VaultLink instance already running");
} }
// eslint-disable-next-line // eslint-disable-next-line
(globalThis as any).VAULT_LINK_RUNNING_INSTANCE = this; (globalThis as any).VAULT_LINK_RUNNING_INSTANCE = this;
const client = await this.createSyncClient(); const client = await this.createSyncClient();
this.registerObsidianExtensions(client); this.registerObsidianExtensions(client);
this.registerEditorEvents(client); this.registerEditorEvents(client);
this.register(async () => { this.register(async () => {
await client.waitUntilFinished(); await client.waitUntilFinished();
await client.destroy(); await client.destroy();
}); });
await client.start(); await client.start();
}); });
} }
public onUserEnable(): void { public onUserEnable(): void {
new Notice( new Notice(
"VaultLink has been enabled, check out the docs for tips on getting started!" "VaultLink has been enabled, check out the docs for tips on getting started!"
); );
void this.activateView(HistoryView.TYPE).catch((e: unknown) => { void this.activateView(HistoryView.TYPE).catch((e: unknown) => {
this.syncClient?.logger.error( this.syncClient?.logger.error(
`Failed to open history view on enable: ${e}` `Failed to open history view on enable: ${e}`
); );
}); });
void this.activateView(LogsView.TYPE).catch((e: unknown) => { void this.activateView(LogsView.TYPE).catch((e: unknown) => {
this.syncClient?.logger.error( this.syncClient?.logger.error(
`Failed to open logs view on enable: ${e}` `Failed to open logs view on enable: ${e}`
); );
}); });
this.openSettings(); this.openSettings();
} }
public openSettings(): void { public openSettings(): void {
// eslint-disable-next-line // eslint-disable-next-line
(this.app as any).setting.open(); // this is undocumented (this.app as any).setting.open(); // this is undocumented
// eslint-disable-next-line // eslint-disable-next-line
(this.app as any).setting.openTab(this.settingsTab); // this is undocumented (this.app as any).setting.openTab(this.settingsTab); // this is undocumented
} }
public closeSettings(): void { public closeSettings(): void {
// eslint-disable-next-line // eslint-disable-next-line
(this.app as any).setting.close(); // this is undocumented (this.app as any).setting.close(); // this is undocumented
} }
public async activateView(type: string): Promise<void> { public async activateView(type: string): Promise<void> {
const { workspace } = this.app; const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null; let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(type); const leaves = workspace.getLeavesOfType(type);
if (leaves.length > 0) { if (leaves.length > 0) {
[leaf] = leaves; [leaf] = leaves;
} else { } else {
leaf = workspace.getRightLeaf(false); leaf = workspace.getRightLeaf(false);
await leaf?.setViewState({ type: type, active: true }); await leaf?.setViewState({ type: type, active: true });
} }
if (leaf) { if (leaf) {
await workspace.revealLeaf(leaf); await workspace.revealLeaf(leaf);
} }
} }
private async createSyncClient(): Promise<SyncClient> { private async createSyncClient(): Promise<SyncClient> {
DEFAULT_SETTINGS.ignorePatterns.push( DEFAULT_SETTINGS.ignorePatterns.push(
".obsidian/**", ".obsidian/**",
".git/**", ".git/**",
".trash/**", ".trash/**",
"**/.DS_Store" "**/.DS_Store"
); );
const client = await SyncClient.create({ const client = await SyncClient.create({
fs: new ObsidianFileSystemOperations( fs: new ObsidianFileSystemOperations(
this.app.vault, this.app.vault,
this.app.workspace this.app.workspace
), ),
persistence: { persistence: {
load: this.loadData.bind(this), load: this.loadData.bind(this),
save: this.saveData.bind(this) save: this.saveData.bind(this)
}, },
nativeLineEndings: Platform.isWin ? "\r\n" : "\n", nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
...(IS_DEBUG_BUILD ...(IS_DEBUG_BUILD
? { ? {
fetch: debugging.slowFetchFactory(1), fetch: debugging.slowFetchFactory(1),
webSocket: debugging.slowWebSocketFactory( webSocket: debugging.slowWebSocketFactory(1, new Logger())
1, }
new Logger() : {})
) });
}
: {})
});
if (IS_DEBUG_BUILD) { if (IS_DEBUG_BUILD) {
debugging.logToConsole(client); debugging.logToConsole(client);
} }
return client; return client;
} }
private registerObsidianExtensions(client: SyncClient): void { private registerObsidianExtensions(client: SyncClient): void {
const statusDescription = new StatusDescription(client); const statusDescription = new StatusDescription(client);
this.settingsTab = new SyncSettingsTab({ this.settingsTab = new SyncSettingsTab({
app: this.app, app: this.app,
plugin: this, plugin: this,
syncClient: client, syncClient: client,
statusDescription statusDescription
}); });
this.addSettingTab(this.settingsTab); this.addSettingTab(this.settingsTab);
new StatusBar(this, client); new StatusBar(this, client);
this.registerView(HistoryView.TYPE, (leaf) => { this.registerView(HistoryView.TYPE, (leaf) => {
const view = new HistoryView(client, leaf); const view = new HistoryView(client, leaf);
this.register(async () => view.onClose()); this.register(async () => view.onClose());
return view; return view;
}); });
this.registerView(LogsView.TYPE, (leaf) => new LogsView(client, leaf)); this.registerView(LogsView.TYPE, (leaf) => new LogsView(client, leaf));
this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]); this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]);
client.addRemoteCursorsUpdateListener((cursors) => { client.onRemoteCursorsUpdated.add((cursors) => {
RemoteCursorsPluginValue.setCursors(cursors, this.app); RemoteCursorsPluginValue.setCursors(cursors, this.app);
renderCursorsInFileExplorer(cursors, this.app); renderCursorsInFileExplorer(cursors, this.app);
}); });
const cursorListener = new LocalCursorUpdateListener( const cursorListener = new LocalCursorUpdateListener(
client, client,
this.app.workspace this.app.workspace
); );
this.register(() => { this.register(() => {
cursorListener.dispose(); cursorListener.dispose();
}); });
this.app.workspace.updateOptions(); this.app.workspace.updateOptions();
this.addRibbonIcons(); this.addRibbonIcons();
const editorStatusDisplayManager = new EditorStatusDisplayManager( const editorStatusDisplayManager = new EditorStatusDisplayManager(
this, this,
this.app.workspace, this.app.workspace,
client client
); );
this.register(() => { this.register(() => {
editorStatusDisplayManager.dispose(); editorStatusDisplayManager.dispose();
}); });
this.register(() => { this.register(() => {
// eslint-disable-next-line // eslint-disable-next-line
(globalThis as any).VAULT_LINK_RUNNING_INSTANCE = null; (globalThis as any).VAULT_LINK_RUNNING_INSTANCE = null;
}); });
} }
private addRibbonIcons(): void { private addRibbonIcons(): void {
this.addRibbonIcon( this.addRibbonIcon(
HistoryView.ICON, HistoryView.ICON,
"Open VaultLink events", "Open VaultLink events",
async (_: MouseEvent) => this.activateView(HistoryView.TYPE) async (_: MouseEvent) => this.activateView(HistoryView.TYPE)
); );
this.addRibbonIcon( this.addRibbonIcon(
LogsView.ICON, LogsView.ICON,
"Open VaultLink logs", "Open VaultLink logs",
async (_: MouseEvent) => this.activateView(LogsView.TYPE) async (_: MouseEvent) => this.activateView(LogsView.TYPE)
); );
} }
private registerEditorEvents(client: SyncClient): void { private registerEditorEvents(client: SyncClient): void {
[ [
this.app.workspace.on( this.app.workspace.on(
"editor-change", "editor-change",
async ( async (
_editor: Editor, _editor: Editor,
info: MarkdownView | MarkdownFileInfo info: MarkdownView | MarkdownFileInfo
) => { ) => {
const { file } = info; const { file } = info;
if (file) { if (file) {
await this.rateLimitedUpdate(file.path, client); await this.rateLimitedUpdate(file.path, client);
} }
} }
), ),
this.app.vault.on("create", async (file: TAbstractFile) => { this.app.vault.on("create", async (file: TAbstractFile) => {
if (file instanceof TFile) { if (file instanceof TFile) {
await client.syncLocallyCreatedFile(file.path); await client.syncLocallyCreatedFile(file.path);
} }
}), }),
this.app.vault.on("modify", async (file: TAbstractFile) => { this.app.vault.on("modify", async (file: TAbstractFile) => {
if (file instanceof TFile) { if (file instanceof TFile) {
await this.rateLimitedUpdate(file.path, client); await this.rateLimitedUpdate(file.path, client);
} }
}), }),
this.app.vault.on("delete", async (file: TAbstractFile) => { this.app.vault.on("delete", async (file: TAbstractFile) => {
await client.syncLocallyDeletedFile(file.path); await client.syncLocallyDeletedFile(file.path);
}), }),
this.app.vault.on( this.app.vault.on(
"rename", "rename",
async (file: TAbstractFile, oldPath: string) => { async (file: TAbstractFile, oldPath: string) => {
if (file instanceof TFile) { if (file instanceof TFile) {
await client.syncLocallyUpdatedFile({ await client.syncLocallyUpdatedFile({
oldPath, oldPath,
relativePath: file.path relativePath: file.path
}); });
} }
} }
) )
].forEach((event) => { ].forEach((event) => {
this.registerEvent(event); this.registerEvent(event);
}); });
} }
private async rateLimitedUpdate( private async rateLimitedUpdate(
path: string, path: string,
client: SyncClient client: SyncClient
): Promise<void> { ): Promise<void> {
if (!this.rateLimitedUpdatesPerFile.has(path)) { if (!this.rateLimitedUpdatesPerFile.has(path)) {
this.rateLimitedUpdatesPerFile.set( this.rateLimitedUpdatesPerFile.set(
path, path,
rateLimit( rateLimit(
async () => async () =>
client.syncLocallyUpdatedFile({ client.syncLocallyUpdatedFile({
relativePath: path relativePath: path
}), }),
MIN_WAIT_BETWEEN_UPDATES_IN_MS MIN_WAIT_BETWEEN_UPDATES_IN_MS
) )
); );
} }
await this.rateLimitedUpdatesPerFile.get(path)?.(); await this.rateLimitedUpdatesPerFile.get(path)?.();
} }
} }

View file

@ -2,54 +2,54 @@ import "./file-explorer.scss";
import type { App, View } from "obsidian"; import type { App, View } from "obsidian";
import { import {
utils, utils,
type MaybeOutdatedClientCursors, type MaybeOutdatedClientCursors,
type RelativePath type RelativePath
} from "sync-client"; } from "sync-client";
const REMOTE_USER_CONTAINER_CLASS = "remote-users"; const REMOTE_USER_CONTAINER_CLASS = "remote-users";
export function renderCursorsInFileExplorer( export function renderCursorsInFileExplorer(
cursors: MaybeOutdatedClientCursors[], cursors: MaybeOutdatedClientCursors[],
app: App app: App
): void { ): void {
const fileExplorers = app.workspace.getLeavesOfType("file-explorer"); const fileExplorers = app.workspace.getLeavesOfType("file-explorer");
if (fileExplorers.length == 0) return; if (fileExplorers.length == 0) return;
const [fileExplorer] = fileExplorers; const [fileExplorer] = fileExplorers;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const fileExplorerView: View & { const fileExplorerView: View & {
fileItems: Record<RelativePath, { el: Element }>; // it's an internal API fileItems: Record<RelativePath, { el: Element }>; // it's an internal API
} = fileExplorer.view as any; // eslint-disable-line } = fileExplorer.view as any; // eslint-disable-line
for (const key in fileExplorerView.fileItems) { for (const key in fileExplorerView.fileItems) {
const element = const element =
fileExplorerView.fileItems[key].el.querySelector(".tree-item-self"); fileExplorerView.fileItems[key].el.querySelector(".tree-item-self");
const customElement = createDiv( const customElement = createDiv(
{ {
cls: REMOTE_USER_CONTAINER_CLASS cls: REMOTE_USER_CONTAINER_CLASS
}, },
(parent) => { (parent) => {
cursors.forEach((cursor) => { cursors.forEach((cursor) => {
cursor.documentsWithCursors.forEach((document) => { cursor.documentsWithCursors.forEach((document) => {
if (document.relative_path.startsWith(key)) { if (document.relative_path.startsWith(key)) {
parent.appendChild( parent.appendChild(
createSpan({ createSpan({
text: cursor.userName, text: cursor.userName,
attr: { attr: {
style: `border-color: ${utils.getRandomColor(cursor.userName)}` style: `border-color: ${utils.getRandomColor(cursor.userName)}`
} }
}) })
); );
} }
}); });
}); });
} }
); );
element?.querySelector("." + REMOTE_USER_CONTAINER_CLASS)?.remove(); element?.querySelector("." + REMOTE_USER_CONTAINER_CLASS)?.remove();
element?.appendChild(customElement); element?.appendChild(customElement);
} }
} }

View file

@ -2,16 +2,16 @@ import type { Editor } from "obsidian";
import { utils } from "sync-client"; import { utils } from "sync-client";
export interface Selection { export interface Selection {
id: number; id: number;
start: number; start: number;
end: number; end: number;
} }
export function getSelectionsFromEditor(editor: Editor): Selection[] { export function getSelectionsFromEditor(editor: Editor): Selection[] {
const text = editor.getValue(); const text = editor.getValue();
return editor.listSelections().map(({ anchor, head }, i) => ({ return editor.listSelections().map(({ anchor, head }, i) => ({
id: i, id: i,
start: utils.lineAndColumnToPosition(text, anchor.line, anchor.ch), start: utils.lineAndColumnToPosition(text, anchor.line, anchor.ch),
end: utils.lineAndColumnToPosition(text, head.line, head.ch) end: utils.lineAndColumnToPosition(text, head.line, head.ch)
})); }));
} }

View file

@ -5,46 +5,46 @@ import type { Selection } from "./get-selections-from-editor";
import { getSelectionsFromEditor } from "./get-selections-from-editor"; import { getSelectionsFromEditor } from "./get-selections-from-editor";
export class LocalCursorUpdateListener { export class LocalCursorUpdateListener {
private static readonly UPDATE_INTERVAL_MS = 50; private static readonly UPDATE_INTERVAL_MS = 50;
private readonly eventHandle: NodeJS.Timeout; private readonly eventHandle: NodeJS.Timeout;
public constructor( public constructor(
private readonly client: SyncClient, private readonly client: SyncClient,
private readonly workspace: Workspace private readonly workspace: Workspace
) { ) {
this.eventHandle = setInterval(() => { this.eventHandle = setInterval(() => {
this.updateAllSelections(); this.updateAllSelections();
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS); }, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
} }
public dispose(): void { public dispose(): void {
clearInterval(this.eventHandle); clearInterval(this.eventHandle);
} }
private updateAllSelections(): void { private updateAllSelections(): void {
const currentCursors = this.getAllSelections(); const currentCursors = this.getAllSelections();
this.client this.client
.updateLocalCursors(currentCursors) .updateLocalCursors(currentCursors)
.catch((error: unknown) => { .catch((error: unknown) => {
this.client.logger.error( this.client.logger.error(
`Failed to update local cursors: ${error}` `Failed to update local cursors: ${error}`
); );
}); });
} }
private getAllSelections(): Record<string, Selection[]> { private getAllSelections(): Record<string, Selection[]> {
const cursors: Record<string, Selection[]> = {}; const cursors: Record<string, Selection[]> = {};
this.workspace this.workspace
.getLeavesOfType("markdown") .getLeavesOfType("markdown")
.map((leaf) => leaf.view) .map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView) .filter((view) => view instanceof MarkdownView)
.forEach((view) => { .forEach((view) => {
const { file } = view; const { file } = view;
if (!file) { if (!file) {
return; return;
} }
cursors[file.path] = getSelectionsFromEditor(view.editor); cursors[file.path] = getSelectionsFromEditor(view.editor);
}); });
return cursors; return cursors;
} }
} }

View file

@ -4,60 +4,60 @@ const CARET_WIDTH = 2;
const DOT_RADIUS = 4; const DOT_RADIUS = 4;
export const remoteCursorsTheme = EditorView.baseTheme({ export const remoteCursorsTheme = EditorView.baseTheme({
".selection-caret": { ".selection-caret": {
position: "relative" position: "relative"
}, },
".selection-caret > *": { ".selection-caret > *": {
position: "absolute", position: "absolute",
backgroundColor: "inherit" backgroundColor: "inherit"
}, },
".selection-caret > .stick": { ".selection-caret > .stick": {
left: 0, left: 0,
top: 0, top: 0,
transform: "translateX(-50%)", transform: "translateX(-50%)",
width: `${CARET_WIDTH}px`, width: `${CARET_WIDTH}px`,
height: "100%", height: "100%",
display: "block", display: "block",
borderRadius: `${CARET_WIDTH / 2}px`, borderRadius: `${CARET_WIDTH / 2}px`,
animation: "blink-stick 1s steps(1) infinite" animation: "blink-stick 1s steps(1) infinite"
}, },
"@keyframes blink-stick": { "@keyframes blink-stick": {
"0%, 100%": { opacity: 1 }, "0%, 100%": { opacity: 1 },
"50%": { opacity: 0 } "50%": { opacity: 0 }
}, },
".selection-caret > .dot": { ".selection-caret > .dot": {
borderRadius: "50%", borderRadius: "50%",
width: `${DOT_RADIUS * 2}px`, width: `${DOT_RADIUS * 2}px`,
height: `${DOT_RADIUS * 2}px`, height: `${DOT_RADIUS * 2}px`,
top: `-${DOT_RADIUS}px`, top: `-${DOT_RADIUS}px`,
left: `-${DOT_RADIUS}px`, left: `-${DOT_RADIUS}px`,
transition: "transform .3s ease-in-out", transition: "transform .3s ease-in-out",
transformOrigin: "bottom center", transformOrigin: "bottom center",
boxSizing: "border-box" boxSizing: "border-box"
}, },
".selection-caret:hover > .dot": { ".selection-caret:hover > .dot": {
transform: "scale(0)" transform: "scale(0)"
}, },
".selection-caret > .info": { ".selection-caret > .info": {
top: "-1.3em", top: "-1.3em",
left: `-${CARET_WIDTH / 2}px`, left: `-${CARET_WIDTH / 2}px`,
fontSize: "0.9em", fontSize: "0.9em",
userSelect: "none", userSelect: "none",
color: "white", color: "white",
padding: "0 2px", padding: "0 2px",
transition: "opacity .3s ease-in-out", transition: "opacity .3s ease-in-out",
opacity: 0, opacity: 0,
whiteSpace: "nowrap", whiteSpace: "nowrap",
borderRadius: "3px 3px 3px 0" borderRadius: "3px 3px 3px 0"
}, },
".selection-caret:hover > .info": { ".selection-caret:hover > .info": {
opacity: 1 opacity: 1
} }
}); });

View file

@ -1,46 +1,46 @@
import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state"; import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state";
import { import {
ViewUpdate, ViewUpdate,
ViewPlugin, ViewPlugin,
Decoration, Decoration,
WidgetType WidgetType
} from "@codemirror/view"; } from "@codemirror/view";
import type { PluginValue, DecorationSet, EditorView } from "@codemirror/view"; import type { PluginValue, DecorationSet, EditorView } from "@codemirror/view";
export class RemoteCursorWidget extends WidgetType { export class RemoteCursorWidget extends WidgetType {
public constructor( public constructor(
private readonly color: string, private readonly color: string,
private readonly name: string private readonly name: string
) { ) {
super(); super();
} }
public toDOM(editor: EditorView): HTMLElement { public toDOM(editor: EditorView): HTMLElement {
return editor.contentDOM.createEl( return editor.contentDOM.createEl(
"span", "span",
{ {
cls: "selection-caret", cls: "selection-caret",
attr: { attr: {
style: `background-color: ${this.color}; border-color: ${this.color}` style: `background-color: ${this.color}; border-color: ${this.color}`
} }
}, },
(span) => { (span) => {
span.createEl("div", { span.createEl("div", {
cls: "stick" cls: "stick"
}); });
span.createEl("div", { span.createEl("div", {
cls: "dot" cls: "dot"
}); });
span.createEl("div", { span.createEl("div", {
cls: "info", cls: "info",
text: this.name text: this.name
}); });
} }
); );
} }
public eq(other: RemoteCursorWidget): boolean { public eq(other: RemoteCursorWidget): boolean {
return other.color === this.color && other.name === this.name; return other.color === this.color && other.name === this.name;
} }
} }

View file

@ -3,17 +3,17 @@ import { RangeSet } from "@codemirror/state";
import { ViewPlugin, Decoration } from "@codemirror/view"; import { ViewPlugin, Decoration } from "@codemirror/view";
import type { import type {
PluginValue, PluginValue,
DecorationSet, DecorationSet,
EditorView, EditorView,
ViewUpdate ViewUpdate
} from "@codemirror/view"; } from "@codemirror/view";
import { RemoteCursorWidget } from "./remote-cursor-widget"; import { RemoteCursorWidget } from "./remote-cursor-widget";
import type { RelativePath } from "sync-client"; import type { RelativePath } from "sync-client";
import { import {
utils, utils,
type CursorSpan, type CursorSpan,
type MaybeOutdatedClientCursors type MaybeOutdatedClientCursors
} from "sync-client"; } from "sync-client";
import type { App } from "obsidian"; import type { App } from "obsidian";
import { MarkdownView } from "obsidian"; import { MarkdownView } from "obsidian";
@ -25,241 +25,241 @@ import { reconcileWithHistory } from "reconcile-text";
const forceUpdate = StateEffect.define(); const forceUpdate = StateEffect.define();
export class RemoteCursorsPluginValue implements PluginValue { export class RemoteCursorsPluginValue implements PluginValue {
private static cursors: { private static cursors: {
name: string; name: string;
path: string; path: string;
span: CursorSpan; span: CursorSpan;
deviceId: string; deviceId: string;
isOutdated: boolean; isOutdated: boolean;
}[] = []; }[] = [];
private static app?: App; private static app?: App;
public decorations: DecorationSet = RangeSet.of([]); public decorations: DecorationSet = RangeSet.of([]);
public static setCursors( public static setCursors(
clients: MaybeOutdatedClientCursors[], clients: MaybeOutdatedClientCursors[],
app: App app: App
): void { ): void {
RemoteCursorsPluginValue.app = app; RemoteCursorsPluginValue.app = app;
RemoteCursorsPluginValue.cursors = [ RemoteCursorsPluginValue.cursors = [
...RemoteCursorsPluginValue.cursors.filter(({ deviceId }) => ...RemoteCursorsPluginValue.cursors.filter(({ deviceId }) =>
clients.some( clients.some(
(client) => (client) =>
client.deviceId === deviceId && client.isOutdated client.deviceId === deviceId && client.isOutdated
) )
), ),
...clients ...clients
.filter( .filter(
({ isOutdated, deviceId }) => ({ isOutdated, deviceId }) =>
!isOutdated || !isOutdated ||
RemoteCursorsPluginValue.cursors.every( RemoteCursorsPluginValue.cursors.every(
(c) => deviceId !== c.deviceId (c) => deviceId !== c.deviceId
) )
) )
.flatMap((client) => { .flatMap((client) => {
const clientCursors = client.documentsWithCursors; const clientCursors = client.documentsWithCursors;
return clientCursors.flatMap((cursor) => return clientCursors.flatMap((cursor) =>
cursor.cursors.map((span) => ({ cursor.cursors.map((span) => ({
name: client.userName, name: client.userName,
path: cursor.relative_path, path: cursor.relative_path,
deviceId: client.deviceId, deviceId: client.deviceId,
isOutdated: client.isOutdated, isOutdated: client.isOutdated,
span: { ...span } span: { ...span }
})) }))
); );
}) })
]; ];
app.workspace app.workspace
.getLeavesOfType("markdown") .getLeavesOfType("markdown")
.map((leaf) => leaf.view) .map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView) .filter((view) => view instanceof MarkdownView)
.forEach((view) => { .forEach((view) => {
// @ts-expect-error, not typed // @ts-expect-error, not typed
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const editor = view.editor.cm as EditorView; const editor = view.editor.cm as EditorView;
editor.dispatch({ editor.dispatch({
effects: [forceUpdate.of(null)] effects: [forceUpdate.of(null)]
}); });
}); });
} }
private static findFileForEditor( private static findFileForEditor(
editor: EditorView editor: EditorView
): RelativePath | undefined { ): RelativePath | undefined {
return RemoteCursorsPluginValue.app?.workspace return RemoteCursorsPluginValue.app?.workspace
.getLeavesOfType("markdown") .getLeavesOfType("markdown")
.map((leaf) => leaf.view) .map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView) .filter((view) => view instanceof MarkdownView)
.flatMap((view) => { .flatMap((view) => {
// @ts-expect-error, not typed // @ts-expect-error, not typed
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
if ((view.editor.cm as EditorView) !== editor) { if ((view.editor.cm as EditorView) !== editor) {
return []; return [];
} }
const { file } = view; const { file } = view;
if (!file) { if (!file) {
return; return;
} }
return [file.path]; return [file.path];
}) })
.first(); .first();
} }
private static interpolateRemoteCursorPositions( private static interpolateRemoteCursorPositions(
original: string, original: string,
edited: string edited: string
): void { ): void {
if ( if (
original === edited || original === edited ||
RemoteCursorsPluginValue.cursors.length === 0 RemoteCursorsPluginValue.cursors.length === 0
) { ) {
return; return;
} }
const updatedPositions: number[] = []; const updatedPositions: number[] = [];
const reconciled = reconcileWithHistory( const reconciled = reconcileWithHistory(
original, original,
{ {
text: original, text: original,
cursors: RemoteCursorsPluginValue.cursors.flatMap( cursors: RemoteCursorsPluginValue.cursors.flatMap(
({ span }, i) => [ ({ span }, i) => [
{ id: i * 2, position: span.start }, { id: i * 2, position: span.start },
{ id: i * 2 + 1, position: span.end } { id: i * 2 + 1, position: span.end }
] ]
) )
}, },
edited edited
); );
reconciled.cursors.forEach(({ id, position }) => { reconciled.cursors.forEach(({ id, position }) => {
const whereToJump = RemoteCursorsPluginValue.findWhereToMoveCursor( const whereToJump = RemoteCursorsPluginValue.findWhereToMoveCursor(
position, position,
reconciled.history reconciled.history
); );
if (whereToJump !== null) { if (whereToJump !== null) {
updatedPositions[id] = whereToJump; updatedPositions[id] = whereToJump;
} else { } else {
updatedPositions[id] = position; updatedPositions[id] = position;
} }
}); });
RemoteCursorsPluginValue.cursors.forEach(({ span }, i) => { RemoteCursorsPluginValue.cursors.forEach(({ span }, i) => {
span.start = updatedPositions[i * 2]; span.start = updatedPositions[i * 2];
span.end = updatedPositions[i * 2 + 1]; span.end = updatedPositions[i * 2 + 1];
}); });
} }
private static findWhereToMoveCursor( private static findWhereToMoveCursor(
cursor: number, cursor: number,
spans: SpanWithHistory[] spans: SpanWithHistory[]
): number | null { ): number | null {
let position = 0; let position = 0;
for (const span of spans) { for (const span of spans) {
// left and origin are the same // left and origin are the same
if (position === cursor && span.history === "AddedFromRight") { if (position === cursor && span.history === "AddedFromRight") {
return position + span.text.length; return position + span.text.length;
} }
position += span.text.length; position += span.text.length;
if (position === cursor && span.history === "RemovedFromRight") { if (position === cursor && span.history === "RemovedFromRight") {
return position - span.text.length; return position - span.text.length;
} }
} }
return null; return null;
} }
public update(update: ViewUpdate): void { public update(update: ViewUpdate): void {
const original = update.startState.doc.toString(); const original = update.startState.doc.toString();
const edited = update.state.doc.toString(); const edited = update.state.doc.toString();
RemoteCursorsPluginValue.interpolateRemoteCursorPositions( RemoteCursorsPluginValue.interpolateRemoteCursorPositions(
original, original,
edited edited
); );
const decorations: Range<Decoration>[] = []; const decorations: Range<Decoration>[] = [];
const relative_path = RemoteCursorsPluginValue.findFileForEditor( const relative_path = RemoteCursorsPluginValue.findFileForEditor(
update.view update.view
); );
RemoteCursorsPluginValue.cursors RemoteCursorsPluginValue.cursors
.filter(({ path }) => path == relative_path) .filter(({ path }) => path == relative_path)
.forEach(({ name, span: { start, end } }) => { .forEach(({ name, span: { start, end } }) => {
const color = utils.getRandomColor(name); const color = utils.getRandomColor(name);
const startLine = update.view.state.doc.lineAt(start); const startLine = update.view.state.doc.lineAt(start);
const endLine = update.view.state.doc.lineAt(end); const endLine = update.view.state.doc.lineAt(end);
const attributes = { const attributes = {
style: `background-color: ${color};` style: `background-color: ${color};`
}; };
if (startLine.number === endLine.number) { if (startLine.number === endLine.number) {
// selected content in a single line. // selected content in a single line.
decorations.push({ decorations.push({
from: start, from: start,
to: end, to: end,
value: Decoration.mark({ value: Decoration.mark({
attributes attributes
}) })
}); });
} else { } else {
// selected content in multiple lines // selected content in multiple lines
// first, render text-selection in the first line // first, render text-selection in the first line
decorations.push({ decorations.push({
from: start, from: start,
to: startLine.from + startLine.length, to: startLine.from + startLine.length,
value: Decoration.mark({ value: Decoration.mark({
attributes attributes
}) })
}); });
// render text-selection in the lines between the first and last line // render text-selection in the lines between the first and last line
for ( for (
let i = startLine.number + 1; let i = startLine.number + 1;
i < endLine.number; i < endLine.number;
i++ i++
) { ) {
const currentLine = update.view.state.doc.line(i); const currentLine = update.view.state.doc.line(i);
decorations.push({ decorations.push({
from: currentLine.from, from: currentLine.from,
to: currentLine.to, to: currentLine.to,
value: Decoration.mark({ value: Decoration.mark({
attributes attributes
}) })
}); });
} }
// render text-selection in the last line // render text-selection in the last line
decorations.push({ decorations.push({
from: endLine.from, from: endLine.from,
to: end, to: end,
value: Decoration.mark({ value: Decoration.mark({
attributes attributes
}) })
}); });
} }
decorations.push({ decorations.push({
from: end, from: end,
to: end, to: end,
value: Decoration.widget({ value: Decoration.widget({
side: end - start > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection side: end - start > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection
block: false, block: false,
widget: new RemoteCursorWidget(color, name) widget: new RemoteCursorWidget(color, name)
}) })
}); });
}); });
this.decorations = Decoration.set(decorations, true); this.decorations = Decoration.set(decorations, true);
} }
} }
export const remoteCursorsPlugin = ViewPlugin.fromClass( export const remoteCursorsPlugin = ViewPlugin.fromClass(
RemoteCursorsPluginValue, RemoteCursorsPluginValue,
{ {
decorations: (v) => v.decorations decorations: (v) => v.decorations
} }
); );

View file

@ -1,43 +1,43 @@
.vault-link-sync-status { .vault-link-sync-status {
position: absolute; position: absolute;
right: var(--size-4-4); right: var(--size-4-4);
top: var(--size-4-2); top: var(--size-4-2);
opacity: 0.7; opacity: 0.7;
cursor: pointer; cursor: pointer;
> span { > span {
opacity: 0; opacity: 0;
position: absolute; position: absolute;
min-width: 200px; min-width: 200px;
text-align: right; text-align: right;
padding-right: var(--size-2-2); padding-right: var(--size-2-2);
top: 50%; top: 50%;
left: 0; left: 0;
transform: translateY(-50%) translateX(-100%) translateY(-2px); transform: translateY(-50%) translateX(-100%) translateY(-2px);
transition: opacity 200ms; transition: opacity 200ms;
} }
&:hover { &:hover {
> span { > span {
opacity: 1; opacity: 1;
} }
} }
> .icon { > .icon {
line-height: 0; line-height: 0;
} }
&.loading > .icon { &.loading > .icon {
animation: spin 2s linear infinite; animation: spin 2s linear infinite;
@keyframes spin { @keyframes spin {
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);
} }
100% { 100% {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
} }
} }

View file

@ -7,91 +7,91 @@ import type VaultLinkPlugin from "src/vault-link-plugin";
import { HistoryView } from "../history/history-view"; import { HistoryView } from "../history/history-view";
export class EditorStatusDisplayManager { export class EditorStatusDisplayManager {
private static readonly UPDATE_INTERVAL_IN_MS = 100; private static readonly UPDATE_INTERVAL_IN_MS = 100;
private readonly intervalId: NodeJS.Timeout; private readonly intervalId: NodeJS.Timeout;
private readonly lastStatuses = new Map<string, DocumentSyncStatus>(); private readonly lastStatuses = new Map<string, DocumentSyncStatus>();
public constructor( public constructor(
private readonly plugin: VaultLinkPlugin, private readonly plugin: VaultLinkPlugin,
private readonly workspace: Workspace, private readonly workspace: Workspace,
private readonly client: SyncClient private readonly client: SyncClient
) { ) {
this.intervalId = setInterval(() => { this.intervalId = setInterval(() => {
this.updateEditorStatusDisplay(); this.updateEditorStatusDisplay();
}, EditorStatusDisplayManager.UPDATE_INTERVAL_IN_MS); }, EditorStatusDisplayManager.UPDATE_INTERVAL_IN_MS);
} }
public dispose(): void { public dispose(): void {
clearInterval(this.intervalId); clearInterval(this.intervalId);
} }
private updateEditorStatusDisplay(): void { private updateEditorStatusDisplay(): void {
this.workspace.iterateAllLeaves((leaf) => { this.workspace.iterateAllLeaves((leaf) => {
if (leaf.view instanceof FileView) { if (leaf.view instanceof FileView) {
const filePath = leaf.view.file?.path; const filePath = leaf.view.file?.path;
if (filePath == null) { if (filePath == null) {
return; return;
} }
const element = this.getElementFromLeaf(leaf.view); const element = this.getElementFromLeaf(leaf.view);
if (element == null) { if (element == null) {
return; return;
} }
const previousStatus = this.lastStatuses.get(filePath); const previousStatus = this.lastStatuses.get(filePath);
const currentStatus = const currentStatus =
this.client.getDocumentSyncingStatus(filePath); this.client.getDocumentSyncingStatus(filePath);
if (previousStatus === currentStatus) { if (previousStatus === currentStatus) {
return; return;
} }
this.lastStatuses.set(filePath, currentStatus); this.lastStatuses.set(filePath, currentStatus);
if (currentStatus == DocumentSyncStatus.SYNCING_IS_DISABLED) { if (currentStatus == DocumentSyncStatus.SYNCING_IS_DISABLED) {
element.remove(); element.remove();
return; return;
} }
if (currentStatus == DocumentSyncStatus.SYNCING) { if (currentStatus == DocumentSyncStatus.SYNCING) {
element.classList.add("loading"); element.classList.add("loading");
} else { } else {
element.classList.remove("loading"); element.classList.remove("loading");
} }
const iconContainer = element.querySelector(".icon"); const iconContainer = element.querySelector(".icon");
if (iconContainer != null) { if (iconContainer != null) {
setIcon( setIcon(
iconContainer as HTMLElement, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion iconContainer as HTMLElement, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
currentStatus == DocumentSyncStatus.SYNCING currentStatus == DocumentSyncStatus.SYNCING
? "loader" ? "loader"
: "circle-check" : "circle-check"
); );
} }
} }
}); });
} }
private getElementFromLeaf(fileView: FileView): Element | undefined { private getElementFromLeaf(fileView: FileView): Element | undefined {
const parent = fileView.contentEl.querySelector(".cm-editor"); const parent = fileView.contentEl.querySelector(".cm-editor");
if (parent == null) { if (parent == null) {
return; return;
} }
return ( return (
parent.querySelector(".vault-link-sync-status") ?? parent.querySelector(".vault-link-sync-status") ??
parent.createDiv( parent.createDiv(
{ {
cls: "vault-link-sync-status" cls: "vault-link-sync-status"
}, },
(el) => { (el) => {
el.createSpan({ text: "VaultLink sync state" }); el.createSpan({ text: "VaultLink sync state" });
el.createDiv({ el.createDiv({
cls: "icon" cls: "icon"
}); });
el.onclick = async (): Promise<void> => el.onclick = async (): Promise<void> =>
this.plugin.activateView(HistoryView.TYPE); this.plugin.activateView(HistoryView.TYPE);
} }
) )
); );
} }
} }

View file

@ -1,61 +1,61 @@
.history-card { .history-card {
padding: var(--size-4-4); padding: var(--size-4-4);
margin: var(--size-4-2); margin: var(--size-4-2);
background-color: var(--color-base-00); background-color: var(--color-base-00);
border-radius: var(--radius-l); border-radius: var(--radius-l);
container-type: inline-size; container-type: inline-size;
word-break: break-word; word-break: break-word;
&.clickable { &.clickable {
cursor: pointer; cursor: pointer;
} }
&.success { &.success {
background-color: rgba(var(--color-green-rgb), 0.2); background-color: rgba(var(--color-green-rgb), 0.2);
} }
&.error { &.error {
background-color: rgba(var(--color-red-rgb), 0.2); background-color: rgba(var(--color-red-rgb), 0.2);
} }
&.skipped { &.skipped {
background-color: rgba(var(--color-green-rgb), 0.08); background-color: rgba(var(--color-green-rgb), 0.08);
} }
.history-card-header { .history-card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--size-4-2); margin-bottom: var(--size-4-2);
gap: var(--size-4-2); gap: var(--size-4-2);
@container (max-width: 300px) { @container (max-width: 300px) {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
.history-card-title { .history-card-title {
font: var(--font-monospace); font: var(--font-monospace);
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--size-4-2); gap: var(--size-4-2);
margin: 0; margin: 0;
> span { > span {
margin-bottom: var(--size-4-1); margin-bottom: var(--size-4-1);
} }
} }
.history-card-timestamp { .history-card-timestamp {
font-size: var(--font-ui-small); font-size: var(--font-ui-small);
font-style: italic; font-style: italic;
color: var(--italic-color); color: var(--italic-color);
} }
} }
.history-card-message { .history-card-message {
font-size: var(--font-ui-medium); font-size: var(--font-ui-medium);
color: var(--color-base-70); color: var(--color-base-70);
margin: 0; margin: 0;
} }
} }

View file

@ -7,234 +7,234 @@ import type { HistoryEntry, SyncClient } from "sync-client";
import { SyncType } from "sync-client"; import { SyncType } from "sync-client";
export class HistoryView extends ItemView { export class HistoryView extends ItemView {
public static readonly TYPE = "history-view"; public static readonly TYPE = "history-view";
public static readonly ICON = "square-stack"; public static readonly ICON = "square-stack";
private timer: NodeJS.Timeout | null = null; private timer: NodeJS.Timeout | null = null;
private historyContainer: HTMLElement | undefined; private historyContainer: HTMLElement | undefined;
private readonly historyEntryToElement = new Map< private readonly historyEntryToElement = new Map<
HistoryEntry, HistoryEntry,
HTMLElement HTMLElement
>(); >();
public constructor( public constructor(
private readonly client: SyncClient, private readonly client: SyncClient,
leaf: WorkspaceLeaf leaf: WorkspaceLeaf
) { ) {
super(leaf); super(leaf);
this.icon = HistoryView.ICON; this.icon = HistoryView.ICON;
this.client.addSyncHistoryUpdateListener(async () => this.client.onSyncHistoryUpdated.add(async () =>
this.updateView().catch((error: unknown) => { this.updateView().catch((error: unknown) => {
this.client.logger.error( this.client.logger.error(
`Failed to update history view: ${error}` `Failed to update history view: ${error}`
); );
}) })
); );
} }
private static getSyncTypeIcon(type: SyncType | undefined): IconName { private static getSyncTypeIcon(type: SyncType | undefined): IconName {
switch (type) { switch (type) {
case SyncType.CREATE: case SyncType.CREATE:
return "file-plus"; return "file-plus";
case SyncType.DELETE: case SyncType.DELETE:
return "trash-2"; return "trash-2";
case SyncType.UPDATE: case SyncType.UPDATE:
return "file-pen-line"; return "file-pen-line";
case SyncType.MOVE: case SyncType.MOVE:
return "move-right"; return "move-right";
case SyncType.SKIPPED: case SyncType.SKIPPED:
return "circle-slash"; return "circle-slash";
case undefined: case undefined:
default: default:
return ""; return "";
} }
} }
private static renderSyncItemTitle( private static renderSyncItemTitle(
element: HTMLElement, element: HTMLElement,
entry: HistoryEntry entry: HistoryEntry
): void { ): void {
const syncTypeIcon = HistoryView.getSyncTypeIcon(entry.details.type); const syncTypeIcon = HistoryView.getSyncTypeIcon(entry.details.type);
if (syncTypeIcon) { if (syncTypeIcon) {
setIcon(element.createDiv(), syncTypeIcon); setIcon(element.createDiv(), syncTypeIcon);
} }
let fileName = entry.details.relativePath.split("/").pop() ?? ""; let fileName = entry.details.relativePath.split("/").pop() ?? "";
fileName = fileName.replace(/\.md$/, ""); fileName = fileName.replace(/\.md$/, "");
element.createEl("span", { element.createEl("span", {
text: text:
entry.details.type === SyncType.SKIPPED entry.details.type === SyncType.SKIPPED
? `Skipped: ${fileName}` ? `Skipped: ${fileName}`
: fileName : fileName
}); });
} }
private static updateTimeSince( private static updateTimeSince(
element: HTMLElement, element: HTMLElement,
entry: HistoryEntry entry: HistoryEntry
): void { ): void {
const timestampElement = element.querySelector( const timestampElement = element.querySelector(
".history-card-timestamp" ".history-card-timestamp"
); );
if (timestampElement != null) { if (timestampElement != null) {
timestampElement.textContent = timestampElement.textContent =
HistoryView.getTimestampAndAuthor(entry); HistoryView.getTimestampAndAuthor(entry);
} }
} }
private static getTimestampAndAuthor(entry: HistoryEntry): string { private static getTimestampAndAuthor(entry: HistoryEntry): string {
let content = intlFormatDistance(entry.timestamp, new Date()); let content = intlFormatDistance(entry.timestamp, new Date());
if ("author" in entry && entry.author !== undefined) { if ("author" in entry && entry.author !== undefined) {
content += ` by ${entry.author}`; content += ` by ${entry.author}`;
} }
return content; return content;
} }
public getViewType(): string { public getViewType(): string {
return HistoryView.TYPE; return HistoryView.TYPE;
} }
public getDisplayText(): string { public getDisplayText(): string {
return "VaultLink history"; return "VaultLink history";
} }
public async onOpen(): Promise<void> { public async onOpen(): Promise<void> {
const container = this.containerEl.children[1]; const container = this.containerEl.children[1];
container.createEl("h4", { text: "VaultLink history" }); container.createEl("h4", { text: "VaultLink history" });
this.historyContainer = container.createDiv({ cls: "logs-container" }); this.historyContainer = container.createDiv({ cls: "logs-container" });
await this.updateView(); await this.updateView();
this.clearTimer(); this.clearTimer();
this.timer = setInterval( this.timer = setInterval(
() => () =>
void this.updateView().catch((error: unknown) => { void this.updateView().catch((error: unknown) => {
this.client.logger.error( this.client.logger.error(
`Failed to update history view: ${error}` `Failed to update history view: ${error}`
); );
}), }),
1000 1000
); );
} }
public async onClose(): Promise<void> { public async onClose(): Promise<void> {
this.clearTimer(); this.clearTimer();
} }
private clearTimer(): void { private clearTimer(): void {
if (this.timer) { if (this.timer) {
clearInterval(this.timer); clearInterval(this.timer);
this.timer = null; this.timer = null;
} }
} }
private async updateView(): Promise<void> { private async updateView(): Promise<void> {
const container = this.historyContainer; const container = this.historyContainer;
if (container === undefined) { if (container === undefined) {
return; return;
} }
// entries are newest first, but we prepend new ones // entries are newest first, but we prepend new ones
const entries = this.client.getHistoryEntries().toReversed(); const entries = this.client.getHistoryEntries().toReversed();
if (this.historyEntryToElement.size === 0 && entries.length > 0) { if (this.historyEntryToElement.size === 0 && entries.length > 0) {
// Clear the "No update has happened yet" message // Clear the "No update has happened yet" message
container.empty(); container.empty();
} }
entries.forEach((entry) => { entries.forEach((entry) => {
const element = this.historyEntryToElement.get(entry); const element = this.historyEntryToElement.get(entry);
if (element !== undefined) { if (element !== undefined) {
HistoryView.updateTimeSince(element, entry); HistoryView.updateTimeSince(element, entry);
return; return;
} }
const newElement = this.createHistoryCard(container, entry); const newElement = this.createHistoryCard(container, entry);
container.prepend(newElement); container.prepend(newElement);
this.historyEntryToElement.set(entry, newElement); this.historyEntryToElement.set(entry, newElement);
}); });
const newEntries = new Set(entries); const newEntries = new Set(entries);
for (const [entry, element] of this.historyEntryToElement) { for (const [entry, element] of this.historyEntryToElement) {
if (!newEntries.has(entry)) { if (!newEntries.has(entry)) {
element.remove(); element.remove();
this.historyEntryToElement.delete(entry); this.historyEntryToElement.delete(entry);
} }
} }
if (entries.length === 0) { if (entries.length === 0) {
container.empty(); container.empty();
container.createEl("p", { container.createEl("p", {
text: "No update has happened yet." text: "No update has happened yet."
}); });
} }
} }
private createHistoryCard( private createHistoryCard(
container: HTMLElement, container: HTMLElement,
entry: HistoryEntry entry: HistoryEntry
): HTMLElement { ): HTMLElement {
return container.createDiv( return container.createDiv(
{ {
cls: ["history-card", entry.status.toLocaleLowerCase()] cls: ["history-card", entry.status.toLocaleLowerCase()]
}, },
(card) => { (card) => {
if ( if (
this.app.vault.getFileByPath(entry.details.relativePath) != this.app.vault.getFileByPath(entry.details.relativePath) !=
null null
) { ) {
card.addEventListener("click", () => { card.addEventListener("click", () => {
this.app.workspace this.app.workspace
.openLinkText( .openLinkText(
entry.details.relativePath, entry.details.relativePath,
entry.details.relativePath, entry.details.relativePath,
false false
) )
.catch((error: unknown) => { .catch((error: unknown) => {
this.client.logger.error( this.client.logger.error(
`Failed to open link for ${entry.details.relativePath}: ${error}` `Failed to open link for ${entry.details.relativePath}: ${error}`
); );
}); });
}); });
card.addClass("clickable"); card.addClass("clickable");
} }
card.createDiv( card.createDiv(
{ {
cls: "history-card-header" cls: "history-card-header"
}, },
(header) => { (header) => {
header.createEl( header.createEl(
"h5", "h5",
{ {
cls: "history-card-title" cls: "history-card-title"
}, },
(title) => { (title) => {
HistoryView.renderSyncItemTitle(title, entry); HistoryView.renderSyncItemTitle(title, entry);
} }
); );
header.createSpan({ header.createSpan({
text: HistoryView.getTimestampAndAuthor(entry), text: HistoryView.getTimestampAndAuthor(entry),
cls: "history-card-timestamp" cls: "history-card-timestamp"
}); });
} }
); );
const body = const body =
entry.details.type === SyncType.MOVE entry.details.type === SyncType.MOVE
? `${entry.message}. Moved from '${entry.details.movedFrom}' to '${entry.details.relativePath}'` ? `${entry.message}. Moved from '${entry.details.movedFrom}' to '${entry.details.relativePath}'`
: `${entry.message}.`; : `${entry.message}.`;
card.createEl("p", { card.createEl("p", {
text: body, text: body,
cls: "history-card-message" cls: "history-card-message"
}); });
} }
); );
} }
} }

View file

@ -1,74 +1,74 @@
.logs-view { .logs-view {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.verbosity-selector { .verbosity-selector {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
font-weight: normal; font-weight: normal;
gap: var(--size-4-2); gap: var(--size-4-2);
margin: var(--size-4-4) var(--size-4-2); margin: var(--size-4-4) var(--size-4-2);
h4 { h4 {
margin: 0; margin: 0;
} }
.logs-controls { .logs-controls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--size-4-2); gap: var(--size-4-2);
button { button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--size-2-1); gap: var(--size-2-1);
padding: var(--size-2-2) var(--size-4-2); padding: var(--size-2-2) var(--size-4-2);
cursor: pointer; cursor: pointer;
} }
select { select {
cursor: pointer; cursor: pointer;
} }
} }
} }
.logs-container { .logs-container {
max-width: 100%; max-width: 100%;
overflow-y: auto; overflow-y: auto;
.log-message { .log-message {
font: var(--font-monospace); font: var(--font-monospace);
margin-bottom: var(--size-2-1); margin-bottom: var(--size-2-1);
overflow-wrap: break-word; overflow-wrap: break-word;
white-space: pre-wrap; white-space: pre-wrap;
user-select: all; user-select: all;
.timestamp { .timestamp {
padding: var(--size-2-1) var(--size-4-1); padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s); border-radius: var(--radius-s);
background-color: var(--color-base-30); background-color: var(--color-base-30);
font-size: var(--font-ui-small); font-size: var(--font-ui-small);
font-family: var(--font-monospace); font-family: var(--font-monospace);
font-weight: var(--bold-weight); font-weight: var(--bold-weight);
margin-right: var(--size-4-1); margin-right: var(--size-4-1);
} }
&.DEBUG { &.DEBUG {
color: var(--color-base-50); color: var(--color-base-50);
} }
&.INFO { &.INFO {
color: var(--color-base-100); color: var(--color-base-100);
} }
&.WARNING { &.WARNING {
color: rgb(var(--color-yellow-rgb)); color: rgb(var(--color-yellow-rgb));
} }
&.ERROR { &.ERROR {
color: rgb(var(--color-red-rgb)); color: rgb(var(--color-red-rgb));
} }
} }
} }
} }

View file

@ -6,189 +6,189 @@ import type { LogLine } from "sync-client";
import { LogLevel, type SyncClient } from "sync-client"; import { LogLevel, type SyncClient } from "sync-client";
export class LogsView extends ItemView { export class LogsView extends ItemView {
public static readonly TYPE = "logs-view"; public static readonly TYPE = "logs-view";
public static readonly ICON = "logs"; public static readonly ICON = "logs";
private static readonly MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX = 300; private static readonly MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX = 300;
private logsContainer: HTMLElement | undefined; private logsContainer: HTMLElement | undefined;
private readonly logLineToElement = new Map<LogLine, HTMLElement>(); private readonly logLineToElement = new Map<LogLine, HTMLElement>();
private minLogLevel: LogLevel = LogLevel.INFO; private minLogLevel: LogLevel = LogLevel.INFO;
public constructor( public constructor(
private readonly client: SyncClient, private readonly client: SyncClient,
leaf: WorkspaceLeaf leaf: WorkspaceLeaf
) { ) {
super(leaf); super(leaf);
this.icon = LogsView.ICON; this.icon = LogsView.ICON;
this.client.logger.addOnMessageListener(() => { this.client.logger.onLogEmitted.add(() => {
this.updateView(); this.updateView();
}); });
} }
private static createLogLineElement( private static createLogLineElement(
container: HTMLElement, container: HTMLElement,
logLine: LogLine logLine: LogLine
): HTMLElement { ): HTMLElement {
return container.createDiv( return container.createDiv(
{ {
cls: ["log-message", logLine.level] cls: ["log-message", logLine.level]
}, },
(messageContainer) => { (messageContainer) => {
messageContainer.createEl("span", { messageContainer.createEl("span", {
text: LogsView.formatTimestamp(logLine.timestamp), text: LogsView.formatTimestamp(logLine.timestamp),
cls: "timestamp" cls: "timestamp"
}); });
messageContainer.createEl("span", { messageContainer.createEl("span", {
text: logLine.message text: logLine.message
}); });
} }
); );
} }
private static formatTimestamp(timestamp: Date): string { private static formatTimestamp(timestamp: Date): string {
return timestamp.toTimeString().split(" ")[0]; return timestamp.toTimeString().split(" ")[0];
} }
public getViewType(): string { public getViewType(): string {
return LogsView.TYPE; return LogsView.TYPE;
} }
public getDisplayText(): string { public getDisplayText(): string {
return "VaultLink logs"; return "VaultLink logs";
} }
public async onOpen(): Promise<void> { public async onOpen(): Promise<void> {
const container = this.containerEl.children[1]; const container = this.containerEl.children[1];
container.addClass("logs-view"); container.addClass("logs-view");
const logLevels = [ const logLevels = [
{ label: "Debug", value: LogLevel.DEBUG }, { label: "Debug", value: LogLevel.DEBUG },
{ label: "Info", value: LogLevel.INFO }, { label: "Info", value: LogLevel.INFO },
{ label: "Warn", value: LogLevel.WARNING }, { label: "Warn", value: LogLevel.WARNING },
{ label: "Error", value: LogLevel.ERROR } { label: "Error", value: LogLevel.ERROR }
]; ];
container.createDiv( container.createDiv(
{ {
cls: "verbosity-selector" cls: "verbosity-selector"
}, },
(verbositySection) => { (verbositySection) => {
verbositySection.createEl("h4", { verbositySection.createEl("h4", {
text: "VaultLink logs" text: "VaultLink logs"
}); });
const controls = verbositySection.createDiv({ const controls = verbositySection.createDiv({
cls: "logs-controls" cls: "logs-controls"
}); });
const copyButton = controls.createEl("button", { const copyButton = controls.createEl("button", {
text: "Copy logs", text: "Copy logs",
cls: "clickable-icon" cls: "clickable-icon"
}); });
setIcon(copyButton, "clipboard-copy"); setIcon(copyButton, "clipboard-copy");
copyButton.addEventListener("click", () => { copyButton.addEventListener("click", () => {
this.copyLogsToClipboard(); this.copyLogsToClipboard();
}); });
controls.createEl("select", {}, (dropdown) => { controls.createEl("select", {}, (dropdown) => {
logLevels.forEach(({ label, value }) => logLevels.forEach(({ label, value }) =>
dropdown.createEl("option", { text: label, value }) dropdown.createEl("option", { text: label, value })
); );
dropdown.value = this.minLogLevel; dropdown.value = this.minLogLevel;
dropdown.addEventListener("change", () => { dropdown.addEventListener("change", () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
this.minLogLevel = dropdown.value as LogLevel; this.minLogLevel = dropdown.value as LogLevel;
this.logsContainer?.empty(); this.logsContainer?.empty();
this.logLineToElement.clear(); this.logLineToElement.clear();
this.updateView(); this.updateView();
}); });
}); });
} }
); );
this.logsContainer = container.createDiv({ cls: "logs-container" }); this.logsContainer = container.createDiv({ cls: "logs-container" });
this.updateView(); this.updateView();
} }
private copyLogsToClipboard(): void { private copyLogsToClipboard(): void {
const logs = this.client.logger.getMessages(this.minLogLevel); const logs = this.client.logger.getMessages(this.minLogLevel);
if (logs.length === 0) { if (logs.length === 0) {
new Notice("No logs to copy"); new Notice("No logs to copy");
return; return;
} }
const formattedLogs = logs const formattedLogs = logs
.map((logLine) => { .map((logLine) => {
const timestamp = logLine.timestamp.toLocaleString(); const timestamp = logLine.timestamp.toLocaleString();
const level = logLine.level.toUpperCase(); const level = logLine.level.toUpperCase();
return `[${timestamp}] ${level}: ${logLine.message}`; return `[${timestamp}] ${level}: ${logLine.message}`;
}) })
.join("\n"); .join("\n");
navigator.clipboard navigator.clipboard
.writeText(formattedLogs) .writeText(formattedLogs)
.then(() => { .then(() => {
new Notice(`Copied ${logs.length} log entries to clipboard`); new Notice(`Copied ${logs.length} log entries to clipboard`);
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
this.client.logger.error( this.client.logger.error(
`Failed to copy logs to clipboard: ${error}` `Failed to copy logs to clipboard: ${error}`
); );
new Notice("Failed to copy logs to clipboard"); new Notice("Failed to copy logs to clipboard");
}); });
} }
private updateView(): void { private updateView(): void {
const container = this.logsContainer; const container = this.logsContainer;
if (container === undefined) { if (container === undefined) {
return; return;
} }
const logs = this.client.logger.getMessages(this.minLogLevel); const logs = this.client.logger.getMessages(this.minLogLevel);
if (this.logLineToElement.size === 0 && logs.length > 0) { if (this.logLineToElement.size === 0 && logs.length > 0) {
// Clear the "No logs available yet" message // Clear the "No logs available yet" message
container.empty(); container.empty();
} }
const shouldScroll = const shouldScroll =
container.scrollTop == 0 || container.scrollTop == 0 ||
container.scrollHeight - container.scrollHeight -
container.clientHeight - container.clientHeight -
container.scrollTop < container.scrollTop <
LogsView.MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX; LogsView.MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX;
logs.forEach((message) => { logs.forEach((message) => {
if (this.logLineToElement.has(message)) { if (this.logLineToElement.has(message)) {
return; return;
} }
const element = LogsView.createLogLineElement(container, message); const element = LogsView.createLogLineElement(container, message);
this.logLineToElement.set(message, element); this.logLineToElement.set(message, element);
}); });
const newLines = new Set(logs); const newLines = new Set(logs);
for (const [logLine, element] of this.logLineToElement) { for (const [logLine, element] of this.logLineToElement) {
if (!newLines.has(logLine)) { if (!newLines.has(logLine)) {
element.remove(); element.remove();
this.logLineToElement.delete(logLine); this.logLineToElement.delete(logLine);
} }
} }
if (logs.length === 0) { if (logs.length === 0) {
container.empty(); container.empty();
container.createEl("p", { container.createEl("p", {
text: "No logs available yet." text: "No logs available yet."
}); });
} else if (shouldScroll) { } else if (shouldScroll) {
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
} }
} }
} }

View file

@ -1,134 +1,168 @@
@mixin number-card { @mixin number-card {
padding: var(--size-2-1) var(--size-4-1); padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s); border-radius: var(--radius-s);
background-color: var(--color-base-30); background-color: var(--color-base-30);
font-size: var(--font-ui-small); font-size: var(--font-ui-small);
&.good { &.good {
background-color: rgba(var(--color-green-rgb), 0.35); background-color: rgba(var(--color-green-rgb), 0.35);
} }
&.bad { &.bad {
background-color: rgba(var(--color-red-rgb), 0.35); background-color: rgba(var(--color-red-rgb), 0.35);
} }
} }
.vault-link-settings-container { .vault-link-settings-container {
position: relative; position: relative;
.vault-link-settings { .vault-link-settings {
h2 { h2 {
display: flex; display: flex;
align-items: center; align-items: center;
font-size: var(--h2-size); font-size: var(--h2-size);
.version { .version {
@include number-card; @include number-card;
margin: var(--size-2-2) 0 0 var(--size-4-2); margin: var(--size-2-2) 0 0 var(--size-4-2);
background-color: var(--color-base-30); background-color: var(--color-base-30);
color: var(--color-base-70); color: var(--color-base-70);
font-size: var(--font-ui-smaller); font-size: var(--font-ui-smaller);
} }
} }
.button-container { .button-container {
display: flex; display: flex;
gap: var(--size-4-2); gap: var(--size-4-2);
} }
h3 { h3 {
font-size: var(--font-ui-large); font-size: var(--font-ui-large);
margin-top: var(--heading-spacing); margin-top: var(--heading-spacing);
} }
button, button,
input[type="range"], input[type="range"],
.checkbox-container, .checkbox-container,
.slider::-webkit-slider-thumb { .slider::-webkit-slider-thumb {
cursor: pointer; cursor: pointer;
} }
input[type="text"], input[type="text"],
textarea { textarea {
width: 250px; width: 250px;
} }
textarea { textarea {
resize: none; resize: none;
height: 75px; height: 75px;
} }
.applying-changes-overlay { .ignored-files-container {
position: absolute; margin-top: var(--size-4-3);
top: 50%; padding: var(--size-4-3);
left: 50%; border: 1px solid var(--background-modifier-border);
transform: translateY(-50%) translateX(-50%); border-radius: var(--radius-s);
z-index: 10; background-color: var(--background-secondary);
backdrop-filter: blur(10px);
.spinner-container { h4 {
background-color: rgba(var(--background-primary), 0.5); margin-top: 0;
border: 1px solid var(--background-modifier-border); margin-bottom: var(--size-4-2);
border-radius: var(--radius-m); color: var(--text-normal);
padding: var(--size-4-8); }
display: flex;
flex-direction: column;
align-items: center;
gap: var(--size-4-3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
min-width: 200px;
}
.spinner { .ignored-files-list {
width: 48px; max-height: 300px;
height: 48px; overflow-y: auto;
border: 4px solid var(--background-modifier-border); margin: 0;
border-top-color: var(--interactive-accent); padding-left: var(--size-4-4);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner-text { li {
color: var(--text-normal); font-family: var(--font-monospace);
font-size: var(--font-ui-medium); font-size: var(--font-ui-small);
font-weight: 500; color: var(--text-muted);
} padding: var(--size-2-1) 0;
}
}
.spinner-warning { p {
color: var(--text-muted); margin: 0;
font-size: var(--font-ui-small); color: var(--text-muted);
text-align: center; font-style: italic;
margin-top: var(--size-2-2); }
} }
}
@keyframes spin { .applying-changes-overlay {
from { position: absolute;
transform: rotate(0deg); top: 50%;
} left: 50%;
transform: translateY(-50%) translateX(-50%);
z-index: 10;
backdrop-filter: blur(10px);
to { .spinner-container {
transform: rotate(360deg); background-color: rgba(var(--background-primary), 0.5);
} border: 1px solid var(--background-modifier-border);
} border-radius: var(--radius-m);
padding: var(--size-4-8);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--size-4-3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
min-width: 200px;
}
&.applying-changes { .spinner {
.setting-item-control { width: 48px;
pointer-events: none; height: 48px;
opacity: 0.5; border: 4px solid var(--background-modifier-border);
} border-top-color: var(--interactive-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
button:not(.applying-changes-overlay button) { .spinner-text {
pointer-events: none; color: var(--text-normal);
opacity: 0.5; font-size: var(--font-ui-medium);
} font-weight: 500;
}
input, .spinner-warning {
textarea, color: var(--text-muted);
select { font-size: var(--font-ui-small);
pointer-events: none; text-align: center;
opacity: 0.5; margin-top: var(--size-2-2);
} }
} }
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
&.applying-changes {
.setting-item-control {
pointer-events: none;
opacity: 0.5;
}
button:not(.applying-changes-overlay button) {
pointer-events: none;
opacity: 0.5;
}
input,
textarea,
select {
pointer-events: none;
opacity: 0.5;
}
}
}
} }

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,14 @@
.sync-status { .sync-status {
display: flex; display: flex;
gap: var(--size-4-2); gap: var(--size-4-2);
* { * {
display: block; display: block;
} }
.initialize-button { .initialize-button {
padding: 0 var(--size-4-2); padding: 0 var(--size-4-2);
background: rgba(var(--color-red-rgb), 0.4); background: rgba(var(--color-red-rgb), 0.4);
cursor: pointer; cursor: pointer;
} }
} }

View file

@ -4,72 +4,72 @@ import type { HistoryStats, SyncClient } from "sync-client";
import type VaultLinkPlugin from "../../vault-link-plugin"; import type VaultLinkPlugin from "../../vault-link-plugin";
export class StatusBar { export class StatusBar {
private readonly statusBarItem: HTMLElement; private readonly statusBarItem: HTMLElement;
private lastHistoryStats: HistoryStats | undefined; private lastHistoryStats: HistoryStats | undefined;
private lastRemaining: number | undefined; private lastRemaining: number | undefined;
public constructor( public constructor(
private readonly plugin: VaultLinkPlugin, private readonly plugin: VaultLinkPlugin,
private readonly syncClient: SyncClient private readonly syncClient: SyncClient
) { ) {
this.statusBarItem = plugin.addStatusBarItem(); this.statusBarItem = plugin.addStatusBarItem();
this.syncClient.addSyncHistoryUpdateListener((status) => { this.syncClient.onSyncHistoryUpdated.add((status) => {
this.lastHistoryStats = status; this.lastHistoryStats = status;
this.updateStatus(); this.updateStatus();
}); });
this.syncClient.addRemainingSyncOperationsListener( this.syncClient.onRemainingOperationsCountChanged.add(
(remainingOperations) => { (remainingOperations) => {
this.lastRemaining = remainingOperations; this.lastRemaining = remainingOperations;
this.updateStatus(); this.updateStatus();
} }
); );
this.syncClient.addOnSettingsChangeListener(() => { this.syncClient.onSettingsChanged.add(() => {
this.updateStatus(); this.updateStatus();
}); });
} }
private updateStatus(): void { private updateStatus(): void {
this.statusBarItem.empty(); this.statusBarItem.empty();
const container = this.statusBarItem.createDiv({ const container = this.statusBarItem.createDiv({
cls: ["sync-status"] cls: ["sync-status"]
}); });
if (!this.syncClient.getSettings().isSyncEnabled) { if (!this.syncClient.getSettings().isSyncEnabled) {
const button = container.createEl("button", { const button = container.createEl("button", {
text: "VaultLink is disabled, click to configure", text: "VaultLink is disabled, click to configure",
cls: "initialize-button" cls: "initialize-button"
}); });
button.onclick = this.plugin.openSettings.bind(this.plugin); button.onclick = this.plugin.openSettings.bind(this.plugin);
return; return;
} }
let hasShownMessage = false; let hasShownMessage = false;
if ((this.lastRemaining ?? 0) > 0) { if ((this.lastRemaining ?? 0) > 0) {
hasShownMessage = true; hasShownMessage = true;
container.createSpan({ text: `${this.lastRemaining}` }); container.createSpan({ text: `${this.lastRemaining}` });
} }
if ((this.lastHistoryStats?.success ?? 0) > 0) { if ((this.lastHistoryStats?.success ?? 0) > 0) {
hasShownMessage = true; hasShownMessage = true;
container.createSpan({ container.createSpan({
text: `${this.lastHistoryStats?.success ?? 0}` text: `${this.lastHistoryStats?.success ?? 0}`
}); });
} }
if ((this.lastHistoryStats?.error ?? 0) > 0) { if ((this.lastHistoryStats?.error ?? 0) > 0) {
hasShownMessage = true; hasShownMessage = true;
container.createSpan({ container.createSpan({
text: `${this.lastHistoryStats?.error ?? 0}` text: `${this.lastHistoryStats?.error ?? 0}`
}); });
} }
if (!hasShownMessage) { if (!hasShownMessage) {
container.createSpan({ text: "VaultLink is idle" }); container.createSpan({ text: "VaultLink is idle" });
} }
} }
} }

View file

@ -1,32 +1,32 @@
@mixin number-card { @mixin number-card {
padding: var(--size-2-1) var(--size-4-1); padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s); border-radius: var(--radius-s);
background-color: var(--color-base-30); background-color: var(--color-base-30);
font-size: var(--font-ui-small); font-size: var(--font-ui-small);
&.good { &.good {
background-color: rgba(var(--color-green-rgb), 0.35); background-color: rgba(var(--color-green-rgb), 0.35);
} }
&.bad { &.bad {
background-color: rgba(var(--color-red-rgb), 0.35); background-color: rgba(var(--color-red-rgb), 0.35);
} }
} }
.status-description { .status-description {
margin: var(--p-spacing) 0; margin: var(--p-spacing) 0;
.number { .number {
@include number-card; @include number-card;
font-family: var(--font-monospace); font-family: var(--font-monospace);
font-weight: var(--bold-weight); font-weight: var(--bold-weight);
} }
.error { .error {
color: rgb(var(--color-red-rgb)); color: rgb(var(--color-red-rgb));
} }
.warning { .warning {
color: rgb(var(--color-yellow-rgb)); color: rgb(var(--color-yellow-rgb));
} }
} }

View file

@ -1,148 +1,147 @@
import "./status-description.scss"; import "./status-description.scss";
import type { import type {
HistoryStats, HistoryStats,
NetworkConnectionStatus, NetworkConnectionStatus,
SyncClient SyncClient
} from "sync-client"; } from "sync-client";
import { utils } from "sync-client";
export class StatusDescription { export class StatusDescription {
private lastHistoryStats: HistoryStats | undefined; private lastHistoryStats: HistoryStats | undefined;
private lastRemaining: number | undefined; private lastRemaining: number | undefined;
private lastConnectionState: NetworkConnectionStatus | undefined; private lastConnectionState: NetworkConnectionStatus | undefined;
private statusChangeListeners: (() => unknown)[] = []; private readonly statusChangeListeners: (() => unknown)[] = [];
public constructor(private readonly syncClient: SyncClient) { public constructor(private readonly syncClient: SyncClient) {
void this.updateConnectionState(); void this.updateConnectionState();
syncClient.addSyncHistoryUpdateListener((status) => { syncClient.onSyncHistoryUpdated.add((status) => {
this.lastHistoryStats = status; this.lastHistoryStats = status;
this.updateDescription(); this.updateDescription();
}); });
this.syncClient.addRemainingSyncOperationsListener( this.syncClient.onRemainingOperationsCountChanged.add(
(remainingOperations) => { (remainingOperations) => {
this.lastRemaining = remainingOperations; this.lastRemaining = remainingOperations;
this.updateDescription(); this.updateDescription();
} }
); );
this.syncClient.addWebSocketStatusChangeListener(async () => this.syncClient.onWebSocketStatusChanged.add(async () =>
this.updateConnectionState() this.updateConnectionState()
); );
this.syncClient.addOnSettingsChangeListener(async () => this.syncClient.onSettingsChanged.add(async () =>
this.updateConnectionState() this.updateConnectionState()
); );
} }
public async updateConnectionState(): Promise<void> { public async updateConnectionState(): Promise<void> {
this.lastConnectionState = await this.syncClient.checkConnection(); this.lastConnectionState = await this.syncClient.checkConnection();
this.updateDescription(); this.updateDescription();
} }
public addStatusChangeListener(listener: () => unknown): void { public addStatusChangeListener(listener: () => unknown): void {
this.statusChangeListeners.push(listener); this.statusChangeListeners.push(listener);
} }
public removeStatusChangeListener(listener: () => unknown): void { public removeStatusChangeListener(listener: () => unknown): void {
this.statusChangeListeners = this.statusChangeListeners.filter( utils.removeFromArray(this.statusChangeListeners, listener);
(l) => l !== listener }
);
}
public renderStatusDescription(container: HTMLElement): void { public renderStatusDescription(container: HTMLElement): void {
container.empty(); container.empty();
container.addClass("status-description"); container.addClass("status-description");
if (this.lastConnectionState == undefined) { if (this.lastConnectionState == undefined) {
container.createSpan({ container.createSpan({
text: "VaultLink is starting up…", text: "VaultLink is starting up…",
cls: "warning" cls: "warning"
}); });
return; return;
} }
if (!this.lastConnectionState.isSuccessful) { if (!this.lastConnectionState.isSuccessful) {
container.createSpan({ container.createSpan({
text: `VaultLink failed to connect to the remote server with error '${this.lastConnectionState.serverMessage}'`, text: `VaultLink failed to connect to the remote server with error '${this.lastConnectionState.serverMessage}'`,
cls: "error" cls: "error"
}); });
return; return;
} }
if (!this.lastConnectionState.isWebSocketConnected) { if (!this.lastConnectionState.isWebSocketConnected) {
container.createSpan({ container.createSpan({
text: `${this.lastConnectionState.serverMessage} but the WebSocket connection could not be established.`, text: `${this.lastConnectionState.serverMessage} but the WebSocket connection could not be established.`,
cls: "error" cls: "error"
}); });
return; return;
} }
container.createSpan({ text: "VaultLink is connected to the server " }); container.createSpan({ text: "VaultLink is connected to the server " });
container.createEl("a", { container.createEl("a", {
text: this.syncClient.getSettings().remoteUri, text: this.syncClient.getSettings().remoteUri,
href: this.syncClient.getSettings().remoteUri href: this.syncClient.getSettings().remoteUri
}); });
container.createSpan({ container.createSpan({
text: ` and has indexed approximately ` text: ` and has indexed approximately `
}); });
container.createSpan({ container.createSpan({
text: `${this.syncClient.documentCount}`, text: `${this.syncClient.documentCount}`,
cls: "number" cls: "number"
}); });
container.createSpan({ container.createSpan({
text: ` documents. ` text: ` documents. `
}); });
if ( if (
(this.lastRemaining ?? 0) === 0 && (this.lastRemaining ?? 0) === 0 &&
(this.lastHistoryStats?.success ?? 0) === 0 && (this.lastHistoryStats?.success ?? 0) === 0 &&
(this.lastHistoryStats?.error ?? 0) === 0 (this.lastHistoryStats?.error ?? 0) === 0
) { ) {
if (this.syncClient.getSettings().isSyncEnabled) { if (this.syncClient.getSettings().isSyncEnabled) {
container.createSpan({ container.createSpan({
text: "Syncing is enabled but VaultLink hasn't found anything to sync yet." text: "Syncing is enabled but VaultLink hasn't found anything to sync yet."
}); });
} else { } else {
container.createSpan({ container.createSpan({
text: "However, syncing is disabled right now.", text: "However, syncing is disabled right now.",
cls: "warning" cls: "warning"
}); });
} }
return; return;
} }
container.createSpan({ container.createSpan({
text: "The plugin has " text: "The plugin has "
}); });
container.createSpan({ container.createSpan({
text: `${this.lastRemaining ?? 0}`, text: `${this.lastRemaining ?? 0}`,
cls: "number" cls: "number"
}); });
container.createSpan({ container.createSpan({
text: " outstanding operations while having succeeded " text: " outstanding operations while having succeeded "
}); });
container.createSpan({ container.createSpan({
text: `${this.lastHistoryStats?.success ?? 0}`, text: `${this.lastHistoryStats?.success ?? 0}`,
cls: ["number", "good"] cls: ["number", "good"]
}); });
container.createSpan({ container.createSpan({
text: " times and failed " text: " times and failed "
}); });
container.createSpan({ container.createSpan({
text: `${this.lastHistoryStats?.error ?? 0}`, text: `${this.lastHistoryStats?.error ?? 0}`,
cls: ["number", "bad"] cls: ["number", "bad"]
}); });
container.createSpan({ container.createSpan({
text: " times." text: " times."
}); });
} }
private updateDescription(): void { private updateDescription(): void {
this.statusChangeListeners.forEach((listener) => { this.statusChangeListeners.forEach((listener) => {
listener(); listener();
}); });
} }
} }

View file

@ -1,17 +1,17 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"module": "ESNext", "module": "ESNext",
"target": "ES2023", "target": "ES2023",
"strict": true, "strict": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"lib": [ "lib": [
"DOM", "DOM",
"ES2024" "ES2024"
] ]
}, },
"exclude": [ "exclude": [
"./dist" "./dist"
] ]
} }

View file

@ -4,114 +4,114 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const fs = require("fs-extra"); const fs = require("fs-extra");
module.exports = (env, argv) => ({ module.exports = (env, argv) => ({
devtool: argv.mode === "development" ? "inline-source-map" : false, devtool: argv.mode === "development" ? "inline-source-map" : false,
entry: { entry: {
index: "./src/vault-link-plugin.ts" index: "./src/vault-link-plugin.ts"
}, },
watchOptions: { watchOptions: {
ignored: "**/node_modules" ignored: "**/node_modules"
}, },
externals: { externals: {
obsidian: "commonjs obsidian", obsidian: "commonjs obsidian",
electron: "commonjs electron", electron: "commonjs electron",
"@codemirror/autocomplete": "commonjs @codemirror/autocomplete", "@codemirror/autocomplete": "commonjs @codemirror/autocomplete",
"@codemirror/collab": "commonjs @codemirror/collab", "@codemirror/collab": "commonjs @codemirror/collab",
"@codemirror/commands": "commonjs @codemirror/commands", "@codemirror/commands": "commonjs @codemirror/commands",
"@codemirror/language": "commonjs @codemirror/language", "@codemirror/language": "commonjs @codemirror/language",
"@codemirror/lint": "commonjs @codemirror/lint", "@codemirror/lint": "commonjs @codemirror/lint",
"@codemirror/search": "commonjs @codemirror/search", "@codemirror/search": "commonjs @codemirror/search",
"@codemirror/state": "commonjs @codemirror/state", "@codemirror/state": "commonjs @codemirror/state",
"@codemirror/view": "commonjs @codemirror/view" "@codemirror/view": "commonjs @codemirror/view"
}, },
optimization: { optimization: {
minimizer: [ minimizer: [
new TerserPlugin({ new TerserPlugin({
terserOptions: { terserOptions: {
module: true module: true
} }
}) })
] ]
}, },
plugins: [ plugins: [
new MiniCssExtractPlugin({ new MiniCssExtractPlugin({
filename: "styles.css" filename: "styles.css"
}), }),
{ {
apply: (compiler) => { apply: (compiler) => {
if (argv.mode !== "development") { if (argv.mode !== "development") {
return; return;
} }
compiler.hooks.done.tap("Copy Files Plugin", (stats) => { compiler.hooks.done.tap("Copy Files Plugin", (stats) => {
const source = path.resolve(__dirname, "dist"); const source = path.resolve(__dirname, "dist");
const destinations = [ const destinations = [
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link", "/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link", "/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link",
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link" // "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
]; ];
destinations.forEach((destination) => { destinations.forEach((destination) => {
fs.copy(source, destination) fs.copy(source, destination)
.then(() => .then(() =>
console.log( console.log(
"Files copied successfully after build!" "Files copied successfully after build!"
) )
) )
.catch((err) => .catch((err) =>
console.error("Error copying files:", err) console.error("Error copying files:", err)
); );
fs.createFile(path.join(destination, ".hotreload")); fs.createFile(path.join(destination, ".hotreload"));
}); });
}); });
} }
} }
], ],
module: { module: {
rules: [ rules: [
{ {
test: /\.json$/i, test: /\.json$/i,
type: "asset/resource", type: "asset/resource",
generator: { generator: {
filename: "[name][ext]" filename: "[name][ext]"
} }
}, },
{ {
test: /\.scss$/i, test: /\.scss$/i,
use: [ use: [
MiniCssExtractPlugin.loader, MiniCssExtractPlugin.loader,
"css-loader", "css-loader",
"resolve-url-loader", "resolve-url-loader",
{ {
loader: "sass-loader", loader: "sass-loader",
options: { options: {
sourceMap: true // required by resolve-url-loader sourceMap: true // required by resolve-url-loader
} }
} }
] ]
}, },
{ {
test: /\.ts$/, test: /\.ts$/,
use: ["ts-loader"] use: ["ts-loader"]
} }
] ]
}, },
resolve: { resolve: {
extensions: [ extensions: [
".ts", ".ts",
".js" // required for development ".js" // required for development
], ],
alias: { alias: {
root: __dirname, root: __dirname,
src: path.resolve(__dirname, "src") src: path.resolve(__dirname, "src")
} }
}, },
output: { output: {
clean: true, clean: true,
filename: "main.js", filename: "main.js",
library: { library: {
type: "commonjs" // required for Obsidian type: "commonjs" // required for Obsidian
}, },
path: path.resolve(__dirname, "dist"), path: path.resolve(__dirname, "dist"),
publicPath: "" publicPath: ""
} }
}); });

12305
frontend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,31 +1,32 @@
{ {
"name": "my-workspace", "name": "my-workspace",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"sync-client", "sync-client",
"obsidian-plugin", "obsidian-plugin",
"test-client", "test-client",
"local-client-cli" "local-client-cli"
], ],
"prettier": { "prettier": {
"trailingComma": "none", "trailingComma": "none",
"tabWidth": 4, "tabWidth": 4,
"useTabs": true, "useTabs": false,
"endOfLine": "lf" "endOfLine": "lf"
}, },
"scripts": { "scripts": {
"build": "npm run build --workspaces", "build": "npm run build --workspaces",
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"", "dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
"test": "npm run test --workspaces", "test": "npm run test --workspaces",
"lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"", "lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"",
"update": "ncu -u -ws" "update": "ncu -u -ws"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"eslint": "9.38.0", "eclint": "^2.8.1",
"eslint-plugin-unused-imports": "^4.1.4", "eslint": "9.38.0",
"npm-check-updates": "^19.1.1", "eslint-plugin-unused-imports": "^4.1.4",
"prettier": "^3.6.2", "npm-check-updates": "^19.1.1",
"typescript-eslint": "8.41.0" "prettier": "^3.6.2",
} "typescript-eslint": "8.41.0"
}
} }

View file

@ -10,7 +10,7 @@
"scripts": { "scripts": {
"dev": "webpack watch --mode development", "dev": "webpack watch --mode development",
"build": "webpack --mode production", "build": "webpack --mode production",
"test": "tsx --test src/**/*.test.ts" "test": "tsx --test 'src/**/*.test.ts'"
}, },
"devDependencies": { "devDependencies": {
"byte-base64": "^1.1.0", "byte-base64": "^1.1.0",

View file

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

View file

@ -1,8 +1,8 @@
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import type { import type {
Database, Database,
DocumentRecord, DocumentRecord,
RelativePath RelativePath
} from "../persistence/database"; } from "../persistence/database";
import { FileOperations } from "./file-operations"; import { FileOperations } from "./file-operations";
import { Logger } from "../tracing/logger"; import { Logger } from "../tracing/logger";
@ -12,224 +12,224 @@ import type { TextWithCursors } from "reconcile-text";
import type { ServerConfig, ServerConfigData } from "../services/server-config"; import type { ServerConfig, ServerConfigData } from "../services/server-config";
class MockServerConfig implements Pick<ServerConfig, "getConfig"> { class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
public getConfig(): ServerConfigData { public getConfig(): ServerConfigData {
return { return {
mergeableFileExtensions: ["md", "txt"], mergeableFileExtensions: ["md", "txt"],
supportedApiVersion: 1, supportedApiVersion: 1,
isAuthenticated: true isAuthenticated: true
}; };
} }
} }
class MockDatabase implements Partial<Database> { class MockDatabase implements Partial<Database> {
public getLatestDocumentByRelativePath( public getLatestDocumentByRelativePath(
_find: RelativePath _find: RelativePath
): DocumentRecord | undefined { ): DocumentRecord | undefined {
// no-op // no-op
return undefined; return undefined;
} }
public move( public move(
_oldRelativePath: RelativePath, _oldRelativePath: RelativePath,
_newRelativePath: RelativePath _newRelativePath: RelativePath
): void { ): void {
// no-op // no-op
} }
} }
class FakeFileSystemOperations implements FileSystemOperations { class FakeFileSystemOperations implements FileSystemOperations {
public readonly names = new Set<string>(); public readonly names = new Set<string>();
public async listFilesRecursively( public async listFilesRecursively(
_root: RelativePath | undefined _root: RelativePath | undefined
): Promise<RelativePath[]> { ): Promise<RelativePath[]> {
return ["file.md"]; return ["file.md"];
} }
public async read(_path: RelativePath): Promise<Uint8Array> { public async read(_path: RelativePath): Promise<Uint8Array> {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");
} }
public async write( public async write(
path: RelativePath, path: RelativePath,
_content: Uint8Array _content: Uint8Array
): Promise<void> { ): Promise<void> {
this.names.add(path); this.names.add(path);
} }
public async atomicUpdateText( public async atomicUpdateText(
_path: RelativePath, _path: RelativePath,
_updater: (current: TextWithCursors) => TextWithCursors _updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> { ): Promise<string> {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");
} }
public async getFileSize(_path: RelativePath): Promise<number> { public async getFileSize(_path: RelativePath): Promise<number> {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");
} }
public async getModificationTime(_path: RelativePath): Promise<Date> { public async getModificationTime(_path: RelativePath): Promise<Date> {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");
} }
public async exists(path: RelativePath): Promise<boolean> { public async exists(path: RelativePath): Promise<boolean> {
return this.names.has(path); return this.names.has(path);
} }
public async createDirectory(_path: RelativePath): Promise<void> { public async createDirectory(_path: RelativePath): Promise<void> {
// this is called but irrelevant for this mock // this is called but irrelevant for this mock
} }
public async delete(_path: RelativePath): Promise<void> { public async delete(_path: RelativePath): Promise<void> {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");
} }
public async rename( public async rename(
oldPath: RelativePath, oldPath: RelativePath,
newPath: RelativePath newPath: RelativePath
): Promise<void> { ): Promise<void> {
this.names.delete(oldPath); this.names.delete(oldPath);
this.names.add(newPath); this.names.add(newPath);
} }
} }
describe("File operations", () => { describe("File operations", () => {
it("should deconflict renames", async () => { it("should deconflict renames", async () => {
const fileSystemOperations = new FakeFileSystemOperations(); const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations( const fileOperations = new FileOperations(
new Logger(), new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations, fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
); );
await fileOperations.create("a", new Uint8Array()); await fileOperations.create("a", new Uint8Array());
assertSetContainsExactly(fileSystemOperations.names, "a"); assertSetContainsExactly(fileSystemOperations.names, "a");
await fileOperations.move("a", "b"); await fileOperations.move("a", "b");
assertSetContainsExactly(fileSystemOperations.names, "b"); assertSetContainsExactly(fileSystemOperations.names, "b");
await fileOperations.create("c", new Uint8Array()); await fileOperations.create("c", new Uint8Array());
assertSetContainsExactly(fileSystemOperations.names, "b", "c"); assertSetContainsExactly(fileSystemOperations.names, "b", "c");
await fileOperations.move("c", "b"); await fileOperations.move("c", "b");
assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)"); assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)");
await fileOperations.create("c", new Uint8Array()); await fileOperations.create("c", new Uint8Array());
await fileOperations.move("c", "b"); await fileOperations.move("c", "b");
assertSetContainsExactly( assertSetContainsExactly(
fileSystemOperations.names, fileSystemOperations.names,
"b", "b",
"b (1)", "b (1)",
"b (2)" "b (2)"
); );
}); });
it("should deconflict renames with file extension", async () => { it("should deconflict renames with file extension", async () => {
const fileSystemOperations = new FakeFileSystemOperations(); const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations( const fileOperations = new FileOperations(
new Logger(), new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations, fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
); );
await fileOperations.create("b.md", new Uint8Array()); await fileOperations.create("b.md", new Uint8Array());
await fileOperations.create("c.md", new Uint8Array()); await fileOperations.create("c.md", new Uint8Array());
await fileOperations.move("c.md", "b.md"); await fileOperations.move("c.md", "b.md");
assertSetContainsExactly( assertSetContainsExactly(
fileSystemOperations.names, fileSystemOperations.names,
"b.md", "b.md",
"b (1).md" "b (1).md"
); );
await fileOperations.create("d.md", new Uint8Array()); await fileOperations.create("d.md", new Uint8Array());
await fileOperations.move("d.md", "b.md"); await fileOperations.move("d.md", "b.md");
assertSetContainsExactly( assertSetContainsExactly(
fileSystemOperations.names, fileSystemOperations.names,
"b.md", "b.md",
"b (1).md", "b (1).md",
"b (2).md" "b (2).md"
); );
await fileOperations.create("file-23.md", new Uint8Array()); await fileOperations.create("file-23.md", new Uint8Array());
await fileOperations.create("file-23 (1).md", new Uint8Array()); await fileOperations.create("file-23 (1).md", new Uint8Array());
await fileOperations.move("file-23.md", "file-23 (1).md"); await fileOperations.move("file-23.md", "file-23 (1).md");
assertSetContainsExactly( assertSetContainsExactly(
fileSystemOperations.names, fileSystemOperations.names,
"b.md", "b.md",
"b (1).md", "b (1).md",
"b (2).md", "b (2).md",
"file-23 (1).md", "file-23 (1).md",
"file-23 (2).md" "file-23 (2).md"
); );
}); });
it("should deconflict renames with paths", async () => { it("should deconflict renames with paths", async () => {
const fileSystemOperations = new FakeFileSystemOperations(); const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations( const fileOperations = new FileOperations(
new Logger(), new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations, fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
); );
await fileOperations.create("a/b.c/d", new Uint8Array()); await fileOperations.create("a/b.c/d", new Uint8Array());
await fileOperations.create("a/b.c/e", new Uint8Array()); await fileOperations.create("a/b.c/e", new Uint8Array());
await fileOperations.move("a/b.c/d", "a/b.c/e"); await fileOperations.move("a/b.c/d", "a/b.c/e");
assertSetContainsExactly( assertSetContainsExactly(
fileSystemOperations.names, fileSystemOperations.names,
"a/b.c/e", "a/b.c/e",
"a/b.c/e (1)" "a/b.c/e (1)"
); );
}); });
it("should continue deconfliction from existing number in filename", async () => { it("should continue deconfliction from existing number in filename", async () => {
const fileSystemOperations = new FakeFileSystemOperations(); const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations( const fileOperations = new FileOperations(
new Logger(), new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations, fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
); );
await fileOperations.create("document (5).md", new Uint8Array()); await fileOperations.create("document (5).md", new Uint8Array());
await fileOperations.create("other.md", new Uint8Array()); await fileOperations.create("other.md", new Uint8Array());
await fileOperations.move("other.md", "document (5).md"); await fileOperations.move("other.md", "document (5).md");
assertSetContainsExactly( assertSetContainsExactly(
fileSystemOperations.names, fileSystemOperations.names,
"document (5).md", "document (5).md",
"document (6).md" "document (6).md"
); );
await fileOperations.create("another.md", new Uint8Array()); await fileOperations.create("another.md", new Uint8Array());
await fileOperations.move("another.md", "document (5).md"); await fileOperations.move("another.md", "document (5).md");
assertSetContainsExactly( assertSetContainsExactly(
fileSystemOperations.names, fileSystemOperations.names,
"document (5).md", "document (5).md",
"document (6).md", "document (6).md",
"document (7).md" "document (7).md"
); );
}); });
it("should handle dotfiles correctly", async () => { it("should handle dotfiles correctly", async () => {
const fileSystemOperations = new FakeFileSystemOperations(); const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations( const fileOperations = new FileOperations(
new Logger(), new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations, fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
); );
await fileOperations.create(".gitignore", new Uint8Array()); await fileOperations.create(".gitignore", new Uint8Array());
await fileOperations.create("temp", new Uint8Array()); await fileOperations.create("temp", new Uint8Array());
await fileOperations.move("temp", ".gitignore"); await fileOperations.move("temp", ".gitignore");
assertSetContainsExactly( assertSetContainsExactly(
fileSystemOperations.names, fileSystemOperations.names,
".gitignore", ".gitignore",
".gitignore (1)" ".gitignore (1)"
); );
await fileOperations.create(".config.json", new Uint8Array()); await fileOperations.create(".config.json", new Uint8Array());
await fileOperations.create("temp2", new Uint8Array()); await fileOperations.create("temp2", new Uint8Array());
await fileOperations.move("temp2", ".config.json"); await fileOperations.move("temp2", ".config.json");
assertSetContainsExactly( assertSetContainsExactly(
fileSystemOperations.names, fileSystemOperations.names,
".gitignore", ".gitignore",
".gitignore (1)", ".gitignore (1)",
".config.json", ".config.json",
".config (1).json" ".config (1).json"
); );
}); });
}); });

View file

@ -9,283 +9,283 @@ import { isBinary } from "../utils/is-binary";
import type { ServerConfig } from "../services/server-config"; import type { ServerConfig } from "../services/server-config";
export class FileOperations { export class FileOperations {
private static readonly PARENTHESES_REGEX = / \((?<count>\d+)\)$/; private static readonly PARENTHESES_REGEX = / \((?<count>\d+)\)$/;
private readonly fs: SafeFileSystemOperations; private readonly fs: SafeFileSystemOperations;
public constructor( public constructor(
private readonly logger: Logger, private readonly logger: Logger,
private readonly database: Database, private readonly database: Database,
fs: FileSystemOperations, fs: FileSystemOperations,
private readonly serverConfig: ServerConfig, private readonly serverConfig: ServerConfig,
private readonly nativeLineEndings = "\n" private readonly nativeLineEndings = "\n"
) { ) {
this.fs = new SafeFileSystemOperations(fs, logger); this.fs = new SafeFileSystemOperations(fs, logger);
} }
private static getParentDirAndFile( private static getParentDirAndFile(
path: RelativePath path: RelativePath
): [RelativePath, RelativePath] { ): [RelativePath, RelativePath] {
const pathParts = path.split("/"); const pathParts = path.split("/");
const fileName = pathParts.pop(); const fileName = pathParts.pop();
if (fileName == null || fileName === "") { if (fileName == null || fileName === "") {
throw new Error(`Path '${path}' cannot be empty`); throw new Error(`Path '${path}' cannot be empty`);
} }
return [pathParts.join("/"), fileName]; return [pathParts.join("/"), fileName];
} }
public async listFilesRecursively( public async listFilesRecursively(
root: RelativePath | undefined = undefined root: RelativePath | undefined = undefined
): Promise<RelativePath[]> { ): Promise<RelativePath[]> {
return this.fs.listFilesRecursively(root); return this.fs.listFilesRecursively(root);
} }
public async read(path: RelativePath): Promise<Uint8Array> { public async read(path: RelativePath): Promise<Uint8Array> {
return this.fromNativeLineEndings(await this.fs.read(path)); return this.fromNativeLineEndings(await this.fs.read(path));
} }
/** /**
* Create a file at the specified path. * Create a file at the specified path.
* *
* If a file with the same name already exists, it is moved before creating the new one. * If a file with the same name already exists, it is moved before creating the new one.
* Parent directories are created if necessary. * Parent directories are created if necessary.
*/ */
public async create( public async create(
path: RelativePath, path: RelativePath,
newContent: Uint8Array newContent: Uint8Array
): Promise<void> { ): Promise<void> {
await this.ensureClearPath(path); await this.ensureClearPath(path);
return this.fs.write(path, this.toNativeLineEndings(newContent)); return this.fs.write(path, this.toNativeLineEndings(newContent));
} }
public async ensureClearPath(path: RelativePath): Promise<void> { public async ensureClearPath(path: RelativePath): Promise<void> {
if (await this.fs.exists(path)) { if (await this.fs.exists(path)) {
const deconflictedPath = await this.deconflictPath(path); const deconflictedPath = await this.deconflictPath(path);
try { try {
this.logger.debug( this.logger.debug(
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'` `Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
); );
this.database.move(path, deconflictedPath); this.database.move(path, deconflictedPath);
await this.fs.rename(path, deconflictedPath, true); await this.fs.rename(path, deconflictedPath, true);
} finally { } finally {
this.fs.unlock(deconflictedPath); this.fs.unlock(deconflictedPath);
} }
} else { } else {
await this.createParentDirectories(path); await this.createParentDirectories(path);
} }
} }
/** /**
* Update the file at the given path. * Update the file at the given path.
* *
* Performs a 3-way merge before writing if the file's content differs from `expectedContent`. * Performs a 3-way merge before writing if the file's content differs from `expectedContent`.
* Does not recreate the file if it no longer exists, returning an empty array instead. * Does not recreate the file if it no longer exists, returning an empty array instead.
*/ */
public async write( public async write(
path: RelativePath, path: RelativePath,
expectedContent: Uint8Array, expectedContent: Uint8Array,
newContent: Uint8Array newContent: Uint8Array
): Promise<void> { ): Promise<void> {
if (!(await this.fs.exists(path))) { if (!(await this.fs.exists(path))) {
this.logger.debug( this.logger.debug(
`The caller assumed ${path} exists, but it no longer, so we wont recreate it` `The caller assumed ${path} exists, but it no longer, so we wont recreate it`
); );
return; return;
} }
if ( if (
!isFileTypeMergable( !isFileTypeMergable(
path, path,
this.serverConfig.getConfig().mergeableFileExtensions this.serverConfig.getConfig().mergeableFileExtensions
) || ) ||
isBinary(expectedContent) || isBinary(expectedContent) ||
isBinary(newContent) isBinary(newContent)
) { ) {
this.logger.debug( this.logger.debug(
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it` `The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
); );
await this.fs.write( await this.fs.write(
path, path,
// `newContent` might not be binary so we still have to ensure the line endings are correct // `newContent` might not be binary so we still have to ensure the line endings are correct
this.toNativeLineEndings(newContent) this.toNativeLineEndings(newContent)
); );
return; return;
} }
const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings
const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings
await this.fs.atomicUpdateText( await this.fs.atomicUpdateText(
path, path,
({ text, cursors }: TextWithCursors): TextWithCursors => { ({ text, cursors }: TextWithCursors): TextWithCursors => {
this.logger.debug( this.logger.debug(
`Performing a 3-way merge for ${path} with the expected content` `Performing a 3-way merge for ${path} with the expected content`
); );
text = text.replaceAll(this.nativeLineEndings, "\n"); text = text.replaceAll(this.nativeLineEndings, "\n");
const merged = reconcile( const merged = reconcile(
expectedText, expectedText,
{ text, cursors }, { text, cursors },
newText newText
); );
const resultText = merged.text.replaceAll( const resultText = merged.text.replaceAll(
"\n", "\n",
this.nativeLineEndings this.nativeLineEndings
); );
return { return {
text: resultText, text: resultText,
cursors: merged.cursors cursors: merged.cursors
}; };
} }
); );
} }
public async delete(path: RelativePath): Promise<void> { public async delete(path: RelativePath): Promise<void> {
if (await this.exists(path)) { if (await this.exists(path)) {
await this.fs.delete(path); await this.fs.delete(path);
await this.deletingEmptyParentDirectoriesOfDeletedFile(path); await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
} else { } else {
this.logger.debug(`No need to delete '${path}', it doesn't exist`); this.logger.debug(`No need to delete '${path}', it doesn't exist`);
} }
} }
public async getFileSize(path: RelativePath): Promise<number> { public async getFileSize(path: RelativePath): Promise<number> {
return this.fs.getFileSize(path); return this.fs.getFileSize(path);
} }
public async exists(path: RelativePath): Promise<boolean> { public async exists(path: RelativePath): Promise<boolean> {
return this.fs.exists(path); return this.fs.exists(path);
} }
public async move( public async move(
oldPath: RelativePath, oldPath: RelativePath,
newPath: RelativePath newPath: RelativePath
): Promise<void> { ): Promise<void> {
if (oldPath === newPath) { if (oldPath === newPath) {
return; return;
} }
await this.ensureClearPath(newPath); await this.ensureClearPath(newPath);
this.database.move(oldPath, newPath); this.database.move(oldPath, newPath);
await this.fs.rename(oldPath, newPath); await this.fs.rename(oldPath, newPath);
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
} }
public reset(): void { public reset(): void {
this.fs.reset(); this.fs.reset();
} }
private async deletingEmptyParentDirectoriesOfDeletedFile( private async deletingEmptyParentDirectoriesOfDeletedFile(
path: RelativePath path: RelativePath
): Promise<void> { ): Promise<void> {
let directory = path; let directory = path;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) { while (true) {
[directory] = FileOperations.getParentDirAndFile(directory); [directory] = FileOperations.getParentDirAndFile(directory);
if (directory.length === 0) { if (directory.length === 0) {
break; break;
} }
const remainingContent = const remainingContent =
await this.fs.listFilesRecursively(directory); await this.fs.listFilesRecursively(directory);
if (remainingContent.length === 0) { if (remainingContent.length === 0) {
this.logger.debug( this.logger.debug(
`Folder (${directory}) is now empty, deleting` `Folder (${directory}) is now empty, deleting`
); );
await this.fs.delete(directory); await this.fs.delete(directory);
} else { } else {
break; break;
} }
} }
} }
private fromNativeLineEndings(content: Uint8Array): Uint8Array { private fromNativeLineEndings(content: Uint8Array): Uint8Array {
if (isBinary(content)) { if (isBinary(content)) {
return content; return content;
} }
const decoder = new TextDecoder("utf-8"); const decoder = new TextDecoder("utf-8");
let text = decoder.decode(content); let text = decoder.decode(content);
text = text.replaceAll(this.nativeLineEndings, "\n"); text = text.replaceAll(this.nativeLineEndings, "\n");
return new TextEncoder().encode(text); return new TextEncoder().encode(text);
} }
private toNativeLineEndings(content: Uint8Array): Uint8Array { private toNativeLineEndings(content: Uint8Array): Uint8Array {
if (isBinary(content)) { if (isBinary(content)) {
return content; return content;
} }
const decoder = new TextDecoder("utf-8"); const decoder = new TextDecoder("utf-8");
let text = decoder.decode(content); let text = decoder.decode(content);
text = text.replaceAll("\n", this.nativeLineEndings); text = text.replaceAll("\n", this.nativeLineEndings);
return new TextEncoder().encode(text); return new TextEncoder().encode(text);
} }
private async createParentDirectories(path: string): Promise<void> { private async createParentDirectories(path: string): Promise<void> {
const components = path.split("/"); const components = path.split("/");
if (components.length === 1) { if (components.length === 1) {
return; return;
} }
for (let i = 1; i < components.length; i++) { for (let i = 1; i < components.length; i++) {
const parentDir = components.slice(0, i).join("/"); const parentDir = components.slice(0, i).join("/");
if (!(await this.fs.exists(parentDir))) { if (!(await this.fs.exists(parentDir))) {
await this.fs.createDirectory(parentDir); await this.fs.createDirectory(parentDir);
} }
} }
} }
/** /**
* Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found. * Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found.
* The returned path has a lock acquired on it; it must be released by the caller when no longer needed. * The returned path has a lock acquired on it; it must be released by the caller when no longer needed.
* *
* @param path The starting path to deconflict * @param path The starting path to deconflict
* @returns a non-existent path with a lock acquired on it * @returns a non-existent path with a lock acquired on it
*/ */
private async deconflictPath(path: RelativePath): Promise<RelativePath> { private async deconflictPath(path: RelativePath): Promise<RelativePath> {
// eslint-disable-next-line prefer-const // eslint-disable-next-line prefer-const
let [directory, fileName] = FileOperations.getParentDirAndFile(path); let [directory, fileName] = FileOperations.getParentDirAndFile(path);
if (directory) { if (directory) {
directory += "/"; directory += "/";
} }
const nameParts = fileName.split("."); const nameParts = fileName.split(".");
// Handle dotfiles: ".gitignore" should have no extension, ".config.json" should have ".json" // Handle dotfiles: ".gitignore" should have no extension, ".config.json" should have ".json"
const isDotfile = fileName.startsWith(".") && nameParts[0] === ""; const isDotfile = fileName.startsWith(".") && nameParts[0] === "";
const extension = const extension =
nameParts.length > 1 && !(isDotfile && nameParts.length === 2) nameParts.length > 1 && !(isDotfile && nameParts.length === 2)
? "." + nameParts[nameParts.length - 1] ? "." + nameParts[nameParts.length - 1]
: ""; : "";
let stem = extension ? nameParts.slice(0, -1).join(".") : fileName; let stem = extension ? nameParts.slice(0, -1).join(".") : fileName;
let currentCount = Number.parseInt( let currentCount = Number.parseInt(
FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.count ?? "0" FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.count ?? "0"
); );
stem = stem.replace(FileOperations.PARENTHESES_REGEX, ""); stem = stem.replace(FileOperations.PARENTHESES_REGEX, "");
let newName = path; let newName = path;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) { while (true) {
currentCount++; currentCount++;
newName = `${directory}${stem} (${currentCount})${extension}`; newName = `${directory}${stem} (${currentCount})${extension}`;
// Avoid multiple deconflictPath calls returning the same path // Avoid multiple deconflictPath calls returning the same path
if (this.fs.tryLock(newName)) { if (this.fs.tryLock(newName)) {
const newDocument = const newDocument =
this.database.getLatestDocumentByRelativePath(newName); this.database.getLatestDocumentByRelativePath(newName);
if ( if (
newDocument?.isDeleted === false || // the document might have been confirmed by the server at a new path but haven't yet moved there locally newDocument?.isDeleted === false || // 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)) (await this.fs.exists(newName, true))
) { ) {
this.fs.unlock(newName); this.fs.unlock(newName);
} else { } else {
return newName; return newName;
} }
} }
} }
} }
} }

View file

@ -3,35 +3,35 @@ import type { RelativePath } from "../persistence/database";
import type { TextWithCursors } from "reconcile-text"; import type { TextWithCursors } from "reconcile-text";
export interface FileSystemOperations { export interface FileSystemOperations {
// List all files under root that should be synced. If root is undefined, return every file. // List all files under root that should be synced. If root is undefined, return every file.
listFilesRecursively: ( listFilesRecursively: (
root: RelativePath | undefined root: RelativePath | undefined
) => Promise<RelativePath[]>; ) => Promise<RelativePath[]>;
// Read the content of a file. // Read the content of a file.
read: (path: RelativePath) => Promise<Uint8Array>; read: (path: RelativePath) => Promise<Uint8Array>;
// Create or overwrite a file with the given content. // Create or overwrite a file with the given content.
write: (path: RelativePath, content: Uint8Array) => Promise<void>; write: (path: RelativePath, content: Uint8Array) => Promise<void>;
// Atomically update the content of a text file. // Atomically update the content of a text file.
atomicUpdateText: ( atomicUpdateText: (
path: RelativePath, path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors updater: (current: TextWithCursors) => TextWithCursors
) => Promise<string>; ) => Promise<string>;
// Get the size of a file in bytes. // Get the size of a file in bytes.
getFileSize: (path: RelativePath) => Promise<number>; getFileSize: (path: RelativePath) => Promise<number>;
// Check if a file exists. // Check if a file exists.
exists: (path: RelativePath) => Promise<boolean>; exists: (path: RelativePath) => Promise<boolean>;
// Create a directory at the specified path. All parent directories must already exist. // Create a directory at the specified path. All parent directories must already exist.
createDirectory: (path: RelativePath) => Promise<void>; createDirectory: (path: RelativePath) => Promise<void>;
// Delete a file. It is expected that the path points to an existing file. // Delete a file. It is expected that the path points to an existing file.
delete: (path: RelativePath) => Promise<void>; delete: (path: RelativePath) => Promise<void>;
// Rename a file. It is expected that the oldPath points to an existing file and the newPath does not exist. // Rename a file. It is expected that the oldPath points to an existing file and the newPath does not exist.
rename: (oldPath: RelativePath, newPath: RelativePath) => Promise<void>; rename: (oldPath: RelativePath, newPath: RelativePath) => Promise<void>;
} }

View file

@ -11,160 +11,160 @@ import type { TextWithCursors } from "reconcile-text";
* single request in-flight for any one file through the use of locks. * single request in-flight for any one file through the use of locks.
*/ */
export class SafeFileSystemOperations implements FileSystemOperations { export class SafeFileSystemOperations implements FileSystemOperations {
private readonly locks: Locks<RelativePath>; private readonly locks: Locks<RelativePath>;
public constructor( public constructor(
private readonly fs: FileSystemOperations, private readonly fs: FileSystemOperations,
private readonly logger: Logger private readonly logger: Logger
) { ) {
this.locks = new Locks(logger); this.locks = new Locks(logger);
} }
public async listFilesRecursively( public async listFilesRecursively(
root: RelativePath | undefined root: RelativePath | undefined
): Promise<RelativePath[]> { ): Promise<RelativePath[]> {
this.logger.debug("Listing all files"); this.logger.debug("Listing all files");
const result = await this.fs.listFilesRecursively(root); const result = await this.fs.listFilesRecursively(root);
this.logger.debug(`Listed ${result.length} files`); this.logger.debug(`Listed ${result.length} files`);
return result; return result;
} }
public async read(path: RelativePath): Promise<Uint8Array> { public async read(path: RelativePath): Promise<Uint8Array> {
this.logger.debug(`Reading file '${path}'`); this.logger.debug(`Reading file '${path}'`);
return this.safeOperation( return this.safeOperation(
path, path,
async () => async () =>
this.locks.withLock(path, async () => this.fs.read(path)), this.locks.withLock(path, async () => this.fs.read(path)),
"read" "read"
); );
} }
public async write(path: RelativePath, content: Uint8Array): Promise<void> { public async write(path: RelativePath, content: Uint8Array): Promise<void> {
this.logger.debug(`Writing to file '${path}'`); this.logger.debug(`Writing to file '${path}'`);
return this.locks.withLock(path, async () => return this.locks.withLock(path, async () =>
this.fs.write(path, content) this.fs.write(path, content)
); );
} }
public async atomicUpdateText( public async atomicUpdateText(
path: RelativePath, path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> { ): Promise<string> {
this.logger.debug(`Atomically updating file '${path}'`); this.logger.debug(`Atomically updating file '${path}'`);
return this.safeOperation( return this.safeOperation(
path, path,
async () => async () =>
this.locks.withLock(path, async () => this.locks.withLock(path, async () =>
this.fs.atomicUpdateText(path, updater) this.fs.atomicUpdateText(path, updater)
), ),
"atomicUpdateText" "atomicUpdateText"
); );
} }
public async getFileSize(path: RelativePath): Promise<number> { public async getFileSize(path: RelativePath): Promise<number> {
// Logging this would be too noisy // Logging this would be too noisy
return this.safeOperation( return this.safeOperation(
path, path,
async () => async () =>
this.locks.withLock(path, async () => this.locks.withLock(path, async () =>
this.fs.getFileSize(path) this.fs.getFileSize(path)
), ),
"getFileSize" "getFileSize"
); );
} }
public async exists( public async exists(
path: RelativePath, path: RelativePath,
skipLock = false skipLock = false
): Promise<boolean> { ): Promise<boolean> {
this.logger.debug(`Checking if file '${path}' exists`); this.logger.debug(`Checking if file '${path}' exists`);
if (skipLock) { if (skipLock) {
return this.fs.exists(path); return this.fs.exists(path);
} else { } else {
return this.locks.withLock(path, async () => this.fs.exists(path)); return this.locks.withLock(path, async () => this.fs.exists(path));
} }
} }
public async createDirectory(path: RelativePath): Promise<void> { public async createDirectory(path: RelativePath): Promise<void> {
this.logger.debug(`Creating directory '${path}'`); this.logger.debug(`Creating directory '${path}'`);
return this.locks.withLock(path, async () => return this.locks.withLock(path, async () =>
this.fs.createDirectory(path) this.fs.createDirectory(path)
); );
} }
public async delete(path: RelativePath): Promise<void> { public async delete(path: RelativePath): Promise<void> {
this.logger.debug(`Deleting file '${path}'`); this.logger.debug(`Deleting file '${path}'`);
return this.locks.withLock(path, async () => this.fs.delete(path)); return this.locks.withLock(path, async () => this.fs.delete(path));
} }
public async rename( public async rename(
oldPath: RelativePath, oldPath: RelativePath,
newPath: RelativePath, newPath: RelativePath,
skipLock = false skipLock = false
): Promise<void> { ): Promise<void> {
this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`); this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`);
return this.safeOperation( return this.safeOperation(
oldPath, oldPath,
async () => { async () => {
if (skipLock) { if (skipLock) {
return this.fs.rename(oldPath, newPath); return this.fs.rename(oldPath, newPath);
} else { } else {
return this.locks.withLock([oldPath, newPath], async () => return this.locks.withLock([oldPath, newPath], async () =>
this.fs.rename(oldPath, newPath) this.fs.rename(oldPath, newPath)
); );
} }
}, },
"rename" "rename"
); );
} }
public tryLock(path: RelativePath): boolean { public tryLock(path: RelativePath): boolean {
return this.locks.tryLock(path); return this.locks.tryLock(path);
} }
public async waitForLock(path: RelativePath): Promise<void> { public async waitForLock(path: RelativePath): Promise<void> {
return this.locks.waitForLock(path); return this.locks.waitForLock(path);
} }
public unlock(path: RelativePath): void { public unlock(path: RelativePath): void {
this.locks.unlock(path); this.locks.unlock(path);
} }
public reset(): void { public reset(): void {
this.locks.reset(); this.locks.reset();
} }
/** /**
* Decorate an operation to ensure that the file exists before running it. * Decorate an operation to ensure that the file exists before running it.
* If the operation fails, it will check if the file still exists and throw * If the operation fails, it will check if the file still exists and throw
* a FileNotFoundError if it doesn't. * a FileNotFoundError if it doesn't.
*/ */
private async safeOperation<T>( private async safeOperation<T>(
path: RelativePath, path: RelativePath,
operation: () => Promise<T>, operation: () => Promise<T>,
operationName: string operationName: string
): Promise<T> { ): Promise<T> {
if (!(await this.fs.exists(path))) { if (!(await this.fs.exists(path))) {
throw new FileNotFoundError( throw new FileNotFoundError(
`File not found before trying to ${operationName}`, `File not found before trying to ${operationName}`,
path path
); );
} }
try { try {
return await operation(); return await operation();
} catch (error) { } catch (error) {
// Without locking the file, this isn't atomic, however, it's good enough in practice. // Without locking the file, this isn't atomic, however, it's good enough in practice.
// This will only break if the file exists, gets deleted and then immediately // This will only break if the file exists, gets deleted and then immediately
// recreated while `operation` is running. // recreated while `operation` is running.
if (await this.fs.exists(path)) { if (await this.fs.exists(path)) {
throw error; throw error;
} else { } else {
throw new FileNotFoundError( throw new FileNotFoundError(
`File not found when trying to ${operationName}`, `File not found when trying to ${operationName}`,
path path
); );
} }
} }
} }
} }

View file

@ -5,17 +5,18 @@ import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory"
import { getRandomColor } from "./utils/get-random-color"; import { getRandomColor } from "./utils/get-random-color";
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position"; import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
import { positionToLineAndColumn } from "./utils/position-to-line-and-column"; import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
import { removeFromArray } from "./utils/remove-from-array";
export { export {
SyncType, SyncType,
SyncStatus, SyncStatus,
type HistoryStats, type HistoryStats,
type HistoryEntry, type HistoryEntry,
type SyncDetails, type SyncDetails,
type SyncCreateDetails, type SyncCreateDetails,
type SyncUpdateDetails, type SyncUpdateDetails,
type SyncMovedDetails, type SyncMovedDetails,
type SyncDeleteDetails type SyncDeleteDetails
} from "./tracing/sync-history"; } from "./tracing/sync-history";
export { Logger, LogLevel, LogLine } from "./tracing/logger"; export { Logger, LogLevel, LogLine } from "./tracing/logger";
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings"; export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
@ -34,14 +35,17 @@ export { SyncClient } from "./sync-client";
export type { TextWithCursors, CursorPosition } from "reconcile-text"; export type { TextWithCursors, CursorPosition } from "reconcile-text";
export const debugging = { export const debugging = {
slowFetchFactory, slowFetchFactory,
slowWebSocketFactory, slowWebSocketFactory,
logToConsole logToConsole
}; };
export { globsToRegexes } from "./utils/globs-to-regexes";
export const utils = { export const utils = {
getRandomColor, getRandomColor,
positionToLineAndColumn, positionToLineAndColumn,
lineAndColumnToPosition, lineAndColumnToPosition,
awaitAll awaitAll,
removeFromArray
}; };

View file

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

View file

@ -1,4 +1,4 @@
export interface PersistenceProvider<T> { export interface PersistenceProvider<T> {
load: () => Promise<T | undefined>; load: () => Promise<T | undefined>;
save: (data: T) => Promise<void>; save: (data: T) => Promise<void>;
} }

View file

@ -1,115 +1,94 @@
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import { awaitAll } from "../utils/await-all";
import { Lock } from "../utils/data-structures/locks"; import { Lock } from "../utils/data-structures/locks";
import { EventListeners } from "../utils/data-structures/event-listeners";
export interface SyncSettings { export interface SyncSettings {
remoteUri: string; remoteUri: string;
token: string; token: string;
vaultName: string; vaultName: string;
syncConcurrency: number; syncConcurrency: number;
isSyncEnabled: boolean; isSyncEnabled: boolean;
maxFileSizeMB: number; maxFileSizeMB: number;
ignorePatterns: string[]; ignorePatterns: string[];
webSocketRetryIntervalMs: number; webSocketRetryIntervalMs: number;
diffCacheSizeMB: number; diffCacheSizeMB: number;
enableTelemetry: boolean; enableTelemetry: boolean;
networkRetryIntervalMs: number; networkRetryIntervalMs: number;
minimumSaveIntervalMs: number; minimumSaveIntervalMs: number;
} }
export const DEFAULT_SETTINGS: SyncSettings = { export const DEFAULT_SETTINGS: SyncSettings = {
remoteUri: "", remoteUri: "",
token: "", token: "",
vaultName: "default", vaultName: "default",
syncConcurrency: 1, syncConcurrency: 1,
isSyncEnabled: false, isSyncEnabled: false,
maxFileSizeMB: 10, maxFileSizeMB: 10,
ignorePatterns: [], ignorePatterns: [],
webSocketRetryIntervalMs: 3500, webSocketRetryIntervalMs: 3500,
diffCacheSizeMB: 4, diffCacheSizeMB: 4,
enableTelemetry: false, enableTelemetry: false,
networkRetryIntervalMs: 1000, networkRetryIntervalMs: 1000,
minimumSaveIntervalMs: 1000 minimumSaveIntervalMs: 1000
}; };
export class Settings { export class Settings {
private settings: SyncSettings; public readonly onSettingsChanged = new EventListeners<
private readonly lock: Lock = new Lock(); (newSettings: SyncSettings, oldSettings: SyncSettings) => unknown
>();
private readonly onSettingsChangeHandlers: (( private settings: SyncSettings;
newSettings: SyncSettings, private readonly lock: Lock = new Lock();
oldSettings: SyncSettings
) => unknown)[] = [];
public constructor( public constructor(
private readonly logger: Logger, private readonly logger: Logger,
initialState: Partial<SyncSettings> | undefined, initialState: Partial<SyncSettings> | undefined,
private readonly saveData: (data: SyncSettings) => Promise<void> private readonly saveData: (data: SyncSettings) => Promise<void>
) { ) {
this.settings = { this.settings = {
...DEFAULT_SETTINGS, ...DEFAULT_SETTINGS,
...(initialState ?? {}) ...(initialState ?? {})
}; };
this.logger.debug( this.logger.debug(
`Loaded settings: ${JSON.stringify(this.settings, null, 2)}` `Loaded settings: ${JSON.stringify(this.settings, null, 2)}`
); );
} }
public getSettings(): SyncSettings { public getSettings(): SyncSettings {
return this.settings; return this.settings;
} }
public addOnSettingsChangeListener( public async setSetting<T extends keyof SyncSettings>(
listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown key: T,
): void { value: SyncSettings[T]
this.onSettingsChangeHandlers.push(listener); ): Promise<void> {
} await this.setSettings({
[key]: value
});
}
public removeOnSettingsChangeListener( public async setSettings(value: Partial<SyncSettings>): Promise<void> {
listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown await this.lock.withLock(async () => {
): void { this.logger.debug(
const index = this.onSettingsChangeHandlers.indexOf(listener); `Updating settings with: ${JSON.stringify(value)}`
if (index !== -1) { );
this.onSettingsChangeHandlers.splice(index, 1); const oldSettings = this.settings;
} this.settings = {
} ...this.settings,
...value
};
public async setSetting<T extends keyof SyncSettings>( await this.onSettingsChanged.triggerAsync(
key: T, this.settings,
value: SyncSettings[T] oldSettings
): Promise<void> { );
await this.setSettings({
[key]: value
});
}
public async setSettings(value: Partial<SyncSettings>): Promise<void> { await this.save();
await this.lock.withLock(async () => { });
this.logger.debug( }
`Updating settings with: ${JSON.stringify(value)}`
);
const oldSettings = this.settings;
this.settings = {
...this.settings,
...value
};
await awaitAll( private async save(): Promise<void> {
this.onSettingsChangeHandlers await this.saveData(this.settings);
.map((handler) => { }
return handler(this.settings, oldSettings);
})
.filter((result): result is Promise<unknown> => {
return result instanceof Promise;
})
);
await this.save();
});
}
private async save(): Promise<void> {
await this.saveData(this.settings);
}
} }

View file

@ -1,6 +1,6 @@
export class AuthenticationError extends Error { export class AuthenticationError extends Error {
public constructor(message: string) { public constructor(message: string) {
super(message); super(message);
this.name = "AuthenticationError"; this.name = "AuthenticationError";
} }
} }

View file

@ -7,171 +7,171 @@ import { SyncResetError } from "./sync-reset-error";
import { sleep } from "../utils/sleep"; import { sleep } from "../utils/sleep";
describe("FetchController", () => { describe("FetchController", () => {
const createMockFetch = ( const createMockFetch = (
shouldSleep: boolean shouldSleep: boolean
): Mock<() => Promise<Response>> => ): Mock<() => Promise<Response>> =>
mock.fn(async () => { mock.fn(async () => {
if (shouldSleep) { if (shouldSleep) {
await sleep(30); await sleep(30);
} }
return Promise.resolve(new Response("OK", { status: 200 })); return Promise.resolve(new Response("OK", { status: 200 }));
}); });
beforeEach(() => { beforeEach(() => {
mock.timers.enable({ apis: ["setTimeout"] }); mock.timers.enable({ apis: ["setTimeout"] });
}); });
afterEach(() => { afterEach(() => {
mock.timers.reset(); mock.timers.reset();
}); });
it("should allow fetch when canFetch is true", async () => { it("should allow fetch when canFetch is true", async () => {
const logger = new Logger(); const logger = new Logger();
const controller = new FetchController(true, logger); const controller = new FetchController(true, logger);
const mockFetch = createMockFetch(false); const mockFetch = createMockFetch(false);
const controlledFetch = controller.getControlledFetchImplementation( const controlledFetch = controller.getControlledFetchImplementation(
logger, logger,
mockFetch mockFetch
); );
const response = await controlledFetch("http://example.com"); const response = await controlledFetch("http://example.com");
assert.strictEqual(await response.text(), "OK"); assert.strictEqual(await response.text(), "OK");
assert.strictEqual(mockFetch.mock.calls.length, 1); assert.strictEqual(mockFetch.mock.calls.length, 1);
}); });
it("should block fetch until canFetch becomes true", async () => { it("should block fetch until canFetch becomes true", async () => {
const logger = new Logger(); const logger = new Logger();
const controller = new FetchController(false, logger); const controller = new FetchController(false, logger);
const mockFetch = createMockFetch(true); const mockFetch = createMockFetch(true);
const controlledFetch = controller.getControlledFetchImplementation( const controlledFetch = controller.getControlledFetchImplementation(
logger, logger,
mockFetch mockFetch
); );
const fetchPromise = controlledFetch("http://example.com"); const fetchPromise = controlledFetch("http://example.com");
assert.strictEqual(mockFetch.mock.calls.length, 0); assert.strictEqual(mockFetch.mock.calls.length, 0);
controller.canFetch = true; controller.canFetch = true;
await Promise.resolve(); await Promise.resolve();
mock.timers.tick(30); mock.timers.tick(30);
const response = await fetchPromise; const response = await fetchPromise;
assert.strictEqual(await response.text(), "OK"); assert.strictEqual(await response.text(), "OK");
assert.strictEqual(mockFetch.mock.calls.length, 1); assert.strictEqual(mockFetch.mock.calls.length, 1);
}); });
it("should reject during reset", async () => { it("should reject during reset", async () => {
const logger = new Logger(); const logger = new Logger();
const controller = new FetchController(true, logger); const controller = new FetchController(true, logger);
const mockFetch = createMockFetch(true); const mockFetch = createMockFetch(true);
const controlledFetch = controller.getControlledFetchImplementation( const controlledFetch = controller.getControlledFetchImplementation(
logger, logger,
mockFetch mockFetch
); );
const firstRequest = controlledFetch("http://example.com"); const firstRequest = controlledFetch("http://example.com");
assert.strictEqual(mockFetch.mock.calls.length, 1); assert.strictEqual(mockFetch.mock.calls.length, 1);
controller.startReset(); controller.startReset();
const secondRequest = controlledFetch("http://example.com"); const secondRequest = controlledFetch("http://example.com");
await assert.rejects( await assert.rejects(
firstRequest, firstRequest,
(error: unknown) => error instanceof SyncResetError (error: unknown) => error instanceof SyncResetError
); );
await assert.rejects( await assert.rejects(
secondRequest, secondRequest,
(error: unknown) => error instanceof SyncResetError (error: unknown) => error instanceof SyncResetError
); );
assert.strictEqual(mockFetch.mock.calls.length, 1); assert.strictEqual(mockFetch.mock.calls.length, 1);
}); });
it("should allow fetch after reset finishes", async () => { it("should allow fetch after reset finishes", async () => {
const logger = new Logger(); const logger = new Logger();
const controller = new FetchController(true, logger); const controller = new FetchController(true, logger);
const mockFetch = createMockFetch(false); const mockFetch = createMockFetch(false);
const controlledFetch = controller.getControlledFetchImplementation( const controlledFetch = controller.getControlledFetchImplementation(
logger, logger,
mockFetch mockFetch
); );
controller.startReset(); controller.startReset();
controller.finishReset(); controller.finishReset();
const response = await controlledFetch("http://example.com"); const response = await controlledFetch("http://example.com");
assert.strictEqual(await response.text(), "OK"); assert.strictEqual(await response.text(), "OK");
}); });
it("should defer canFetch changes during reset", async () => { it("should defer canFetch changes during reset", async () => {
const logger = new Logger(); const logger = new Logger();
const controller = new FetchController(false, logger); const controller = new FetchController(false, logger);
const mockFetch = createMockFetch(true); const mockFetch = createMockFetch(true);
const controlledFetch = controller.getControlledFetchImplementation( const controlledFetch = controller.getControlledFetchImplementation(
logger, logger,
mockFetch mockFetch
); );
controller.startReset(); controller.startReset();
controller.canFetch = true; controller.canFetch = true;
await assert.rejects( await assert.rejects(
async () => controlledFetch("http://example.com"), async () => controlledFetch("http://example.com"),
(error: unknown) => error instanceof SyncResetError (error: unknown) => error instanceof SyncResetError
); );
controller.finishReset(); controller.finishReset();
const fetchPromise = controlledFetch("http://example.com"); const fetchPromise = controlledFetch("http://example.com");
mock.timers.tick(30); mock.timers.tick(30);
const response = await fetchPromise; const response = await fetchPromise;
assert.strictEqual(await response.text(), "OK"); assert.strictEqual(await response.text(), "OK");
}); });
it("should handle different input types", async () => { it("should handle different input types", async () => {
const logger = new Logger(); const logger = new Logger();
const controller = new FetchController(true, logger); const controller = new FetchController(true, logger);
const mockFetch = createMockFetch(false); const mockFetch = createMockFetch(false);
const controlledFetch = controller.getControlledFetchImplementation( const controlledFetch = controller.getControlledFetchImplementation(
logger, logger,
mockFetch mockFetch
); );
await controlledFetch("http://example.com"); await controlledFetch("http://example.com");
await controlledFetch(new URL("http://example.com")); await controlledFetch(new URL("http://example.com"));
await controlledFetch( await controlledFetch(
new Request("http://example.com", { method: "POST" }) new Request("http://example.com", { method: "POST" })
); );
assert.strictEqual(mockFetch.mock.calls.length, 3); assert.strictEqual(mockFetch.mock.calls.length, 3);
}); });
it("should handle fetch errors", async () => { it("should handle fetch errors", async () => {
const logger = new Logger(); const logger = new Logger();
const controller = new FetchController(true, logger); const controller = new FetchController(true, logger);
const mockFetch = mock.fn(async () => { const mockFetch = mock.fn(async () => {
throw new Error("Network error"); throw new Error("Network error");
}); });
const controlledFetch = controller.getControlledFetchImplementation( const controlledFetch = controller.getControlledFetchImplementation(
logger, logger,
mockFetch mockFetch
); );
await assert.rejects( await assert.rejects(
async () => controlledFetch("http://example.com"), async () => controlledFetch("http://example.com"),
(error: unknown) => (error: unknown) =>
error instanceof Error && error.message === "Network error" error instanceof Error && error.message === "Network error"
); );
}); });
it("should not create unhandled rejection on reset with no waiting fetches", async () => { it("should not create unhandled rejection on reset with no waiting fetches", async () => {
const logger = new Logger(); const logger = new Logger();
const controller = new FetchController(true, logger); const controller = new FetchController(true, logger);
controller.startReset(); controller.startReset();
mock.timers.tick(10); mock.timers.tick(10);
controller.finishReset(); controller.finishReset();
}); });
}); });

View file

@ -7,143 +7,143 @@ import { SyncResetError } from "./sync-reset-error";
* and aborts outstanding requests when a reset is started. * and aborts outstanding requests when a reset is started.
*/ */
export class FetchController { export class FetchController {
private static readonly UNTIL_RESOLUTION = Symbol(); private static readonly UNTIL_RESOLUTION = Symbol();
private isResetting = false; private isResetting = false;
// Promise resolves on the next state change: sync enabled/disabled or reset started/ended // Promise resolves on the next state change: sync enabled/disabled or reset started/ended
private until: Promise<symbol>; private until: Promise<symbol>;
private resolveUntil: (result: symbol) => unknown; private resolveUntil: (result: symbol) => unknown;
private rejectUntil: (reason: unknown) => unknown; private rejectUntil: (reason: unknown) => unknown;
public constructor( public constructor(
private _canFetch: boolean, private _canFetch: boolean,
private readonly logger: Logger private readonly logger: Logger
) { ) {
[this.until, this.resolveUntil, this.rejectUntil] = [this.until, this.resolveUntil, this.rejectUntil] =
createPromise<symbol>(); createPromise<symbol>();
} }
/** /**
* Whether the fetch implementation can immediately send requests once outside of a reset. * Whether the fetch implementation can immediately send requests once outside of a reset.
*/ */
public get canFetch(): boolean { public get canFetch(): boolean {
return this._canFetch; return this._canFetch;
} }
/** /**
* Allow or disallow fetching. The changes only take effect if not resetting. * Allow or disallow fetching. The changes only take effect if not resetting.
* When called during a reset, its effect is deferred until the reset is finished. * When called during a reset, its effect is deferred until the reset is finished.
* *
* @param canFetch Whether fetching is enabled * @param canFetch Whether fetching is enabled
*/ */
public set canFetch(canFetch: boolean) { public set canFetch(canFetch: boolean) {
this._canFetch = canFetch; this._canFetch = canFetch;
if (!this.isResetting) { if (!this.isResetting) {
const previousResolve = this.resolveUntil; const previousResolve = this.resolveUntil;
[this.until, this.resolveUntil, this.rejectUntil] = [this.until, this.resolveUntil, this.rejectUntil] =
createPromise<symbol>(); createPromise<symbol>();
previousResolve(FetchController.UNTIL_RESOLUTION); previousResolve(FetchController.UNTIL_RESOLUTION);
} }
} }
private static getUrlFromInput(input: RequestInfo | URL): string { private static getUrlFromInput(input: RequestInfo | URL): string {
if (input instanceof URL) { if (input instanceof URL) {
return input.href; return input.href;
} }
if (typeof input === "string") { if (typeof input === "string") {
return input; return input;
} }
return input.url; return input.url;
} }
/** /**
* Starts a reset, causing all ongoing and future fetches to be rejected * Starts a reset, causing all ongoing and future fetches to be rejected
* with a SyncResetError until finishReset is called. * with a SyncResetError until finishReset is called.
*/ */
public startReset(): void { public startReset(): void {
this.isResetting = true; this.isResetting = true;
this.rejectUntil(new SyncResetError()); this.rejectUntil(new SyncResetError());
// Catch unhandled rejection if no fetches are waiting // Catch unhandled rejection if no fetches are waiting
this.until.catch(() => { this.until.catch(() => {
// Intentionally ignore - this rejection is handled by waiting fetches // Intentionally ignore - this rejection is handled by waiting fetches
}); });
} }
/** /**
* Finishes a reset, allowing fetches to proceed or wait again depending on * Finishes a reset, allowing fetches to proceed or wait again depending on
* the current sync settings. * the current sync settings.
*/ */
public finishReset(): void { public finishReset(): void {
if (!this.isResetting) { if (!this.isResetting) {
return; return;
} }
this.isResetting = false; this.isResetting = false;
[this.until, this.resolveUntil, this.rejectUntil] = createPromise(); [this.until, this.resolveUntil, this.rejectUntil] = createPromise();
} }
/** /**
* *
* |------------------|---------------|-----------------------------------------------------| * |------------------|---------------|-----------------------------------------------------|
* | | Sync enabled | Sync disabled | * | | Sync enabled | Sync disabled |
* |------------------|-------------- |-----------------------------------------------------| * |------------------|-------------- |-----------------------------------------------------|
* | During reset | Rejects with SyncResetError without sending request | * | During reset | Rejects with SyncResetError without sending request |
* |------------------|-------------- |-----------------------------------------------------| * |------------------|-------------- |-----------------------------------------------------|
* | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch | * | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch |
* |------------------|---------------|-----------------------------------------------------| * |------------------|---------------|-----------------------------------------------------|
* *
* @param logger for errors * @param logger for errors
* @param fetch to wrap * @param fetch to wrap
* @returns a wrapped fetch implementation affected by the FetchController state * @returns a wrapped fetch implementation affected by the FetchController state
*/ */
public getControlledFetchImplementation( public getControlledFetchImplementation(
logger: Logger, logger: Logger,
fetch: typeof globalThis.fetch = globalThis.fetch fetch: typeof globalThis.fetch = globalThis.fetch
): typeof globalThis.fetch { ): typeof globalThis.fetch {
return async ( return async (
input: RequestInfo | URL, input: RequestInfo | URL,
init?: RequestInit init?: RequestInit
): Promise<Response> => { ): Promise<Response> => {
while (!this.canFetch || this.isResetting) { while (!this.canFetch || this.isResetting) {
await this.until; await this.until;
} }
try { try {
// https://github.com/jonbern/fetch-retry/blob/8684ef4e688375f623bd76f13add76dbc1d67cfb/index.js#L67C1-L70C21 // https://github.com/jonbern/fetch-retry/blob/8684ef4e688375f623bd76f13add76dbc1d67cfb/index.js#L67C1-L70C21
const _input = const _input =
typeof Request !== "undefined" && input instanceof Request typeof Request !== "undefined" && input instanceof Request
? input.clone() ? input.clone()
: input; : input;
const fetchPromise = fetch(_input, init); const fetchPromise = fetch(_input, init);
// We only want to catch rejections from `this.until` // We only want to catch rejections from `this.until`
let result: symbol | Response | undefined = undefined; let result: symbol | Response | undefined = undefined;
do { do {
result = await Promise.race([this.until, fetchPromise]); result = await Promise.race([this.until, fetchPromise]);
} while (result === FetchController.UNTIL_RESOLUTION); } while (result === FetchController.UNTIL_RESOLUTION);
const fetchResult: Response = result as Response; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion const fetchResult: Response = result as Response; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
if (!fetchResult.ok) { if (!fetchResult.ok) {
this.logger.warn( this.logger.warn(
`Fetch for ${FetchController.getUrlFromInput( `Fetch for ${FetchController.getUrlFromInput(
input input
)}, got status ${fetchResult.status}` )}, got status ${fetchResult.status}`
); );
} }
return fetchResult; return fetchResult;
} catch (error) { } catch (error) {
logger.warn( logger.warn(
`Fetch for ${FetchController.getUrlFromInput( `Fetch for ${FetchController.getUrlFromInput(
input input
)}, got error: ${error}` )}, got error: ${error}`
); );
throw error; throw error;
} }
}; };
} }
} }

View file

@ -5,83 +5,83 @@ import type { SyncService } from "./sync-service";
import type { PingResponse } from "./types/PingResponse"; import type { PingResponse } from "./types/PingResponse";
export interface ServerConfigData { export interface ServerConfigData {
mergeableFileExtensions: string[]; mergeableFileExtensions: string[];
supportedApiVersion: number; supportedApiVersion: number;
isAuthenticated: boolean; isAuthenticated: boolean;
} }
export class ServerConfig { export class ServerConfig {
private response: Promise<PingResponse> | undefined; private response: Promise<PingResponse> | undefined;
private config: ServerConfigData | undefined; private config: ServerConfigData | undefined;
public constructor(private readonly syncService: SyncService) {} public constructor(private readonly syncService: SyncService) {}
public async initialize(): Promise<void> { public async initialize(): Promise<void> {
this.response = this.syncService.ping(); this.response = this.syncService.ping();
this.config = await this.response; this.config = await this.response;
if (this.config.supportedApiVersion !== SUPPORTED_API_VERSION) { if (this.config.supportedApiVersion !== SUPPORTED_API_VERSION) {
const shouldUpgradeClient = const shouldUpgradeClient =
this.config.supportedApiVersion > SUPPORTED_API_VERSION; this.config.supportedApiVersion > SUPPORTED_API_VERSION;
throw new ServerVersionMismatchError( throw new ServerVersionMismatchError(
`Unsupported API version: ${this.config.supportedApiVersion}. Consider upgrading the ${ `Unsupported API version: ${this.config.supportedApiVersion}. Consider upgrading the ${
shouldUpgradeClient ? "client" : "sync-server" shouldUpgradeClient ? "client" : "sync-server"
} to ensure compatibility.` } to ensure compatibility.`
); );
} }
if (!this.config.isAuthenticated) { if (!this.config.isAuthenticated) {
throw new AuthenticationError( throw new AuthenticationError(
"Failed to authenticate with the sync-server." "Failed to authenticate with the sync-server."
); );
} }
} }
public async checkConnection(forceUpdate = false): Promise<{ public async checkConnection(forceUpdate = false): Promise<{
isSuccessful: boolean; isSuccessful: boolean;
message: string; message: string;
}> { }> {
try { try {
let { response } = this; let { response } = this;
if (!response && !forceUpdate) { if (!response && !forceUpdate) {
throw new Error("ServerConfig not initialized"); throw new Error("ServerConfig not initialized");
} else if (forceUpdate) { } else if (forceUpdate) {
response = this.response = this.syncService.ping(); response = this.response = this.syncService.ping();
} }
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const result: PingResponse = (await response)!; // it must be defined, otherwise we would have thrown above const result: PingResponse = (await response)!; // it must be defined, otherwise we would have thrown above
this.config = result; this.config = result;
if (result.isAuthenticated) { if (result.isAuthenticated) {
return { return {
isSuccessful: true, isSuccessful: true,
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated` message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated`
}; };
} }
return { return {
isSuccessful: false, isSuccessful: false,
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate` message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate`
}; };
} catch (e) { } catch (e) {
return { return {
isSuccessful: false, isSuccessful: false,
message: `Failed to connect to server: ${e}` message: `Failed to connect to server: ${e}`
}; };
} }
} }
public getConfig(): ServerConfigData { public getConfig(): ServerConfigData {
if (!this.config) { if (!this.config) {
throw new Error("ServerConfig not initialized"); throw new Error("ServerConfig not initialized");
} }
return this.config; return this.config;
} }
public reset(): void { public reset(): void {
this.response = undefined; this.response = undefined;
this.config = undefined; this.config = undefined;
} }
} }

View file

@ -1,6 +1,6 @@
export class ServerVersionMismatchError extends Error { export class ServerVersionMismatchError extends Error {
public constructor(message: string) { public constructor(message: string) {
super(message); super(message);
this.name = "ServerVersionMismatchError"; this.name = "ServerVersionMismatchError";
} }
} }

View file

@ -1,6 +1,6 @@
export class SyncResetError extends Error { export class SyncResetError extends Error {
public constructor() { public constructor() {
super("SyncClient has been reset, cleaning up"); super("SyncClient has been reset, cleaning up");
this.name = "SyncResetError"; this.name = "SyncResetError";
} }
} }

View file

@ -1,7 +1,7 @@
import type { import type {
DocumentId, DocumentId,
RelativePath, RelativePath,
VaultUpdateId VaultUpdateId
} from "../persistence/database"; } from "../persistence/database";
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
@ -19,416 +19,416 @@ import type { DeleteDocumentVersion } from "./types/DeleteDocumentVersion";
import type { UpdateTextDocumentVersion } from "./types/UpdateTextDocumentVersion"; import type { UpdateTextDocumentVersion } from "./types/UpdateTextDocumentVersion";
export class SyncService { export class SyncService {
private readonly client: typeof globalThis.fetch; private readonly client: typeof globalThis.fetch;
private readonly pingClient: typeof globalThis.fetch; private readonly pingClient: typeof globalThis.fetch;
public constructor( public constructor(
private readonly deviceId: string, private readonly deviceId: string,
private readonly fetchController: FetchController, private readonly fetchController: FetchController,
private readonly settings: Settings, private readonly settings: Settings,
private readonly logger: Logger, private readonly logger: Logger,
fetchImplementation: typeof globalThis.fetch = globalThis.fetch fetchImplementation: typeof globalThis.fetch = globalThis.fetch
) { ) {
// ensure that if it's called a method, `this` won't be bound to the instance // ensure that if it's called a method, `this` won't be bound to the instance
const unboundFetch: typeof globalThis.fetch = async (...args) => const unboundFetch: typeof globalThis.fetch = async (...args) =>
fetchImplementation(...args); fetchImplementation(...args);
this.client = this.fetchController.getControlledFetchImplementation( this.client = this.fetchController.getControlledFetchImplementation(
this.logger, this.logger,
unboundFetch unboundFetch
); );
this.pingClient = unboundFetch; this.pingClient = unboundFetch;
} }
private static async errorFromResponse( private static async errorFromResponse(
response: Response response: Response
): Promise<string> { ): Promise<string> {
if ( if (
response.headers response.headers
.get("Content-Type") .get("Content-Type")
?.includes("application/json") == true ?.includes("application/json") == true
) { ) {
const result: SerializedError = const result: SerializedError =
(await response.json()) as SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion (await response.json()) as SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
return SyncService.formatError(result); return SyncService.formatError(result);
} }
return `HTTP ${response.status}: ${response.statusText}`; return `HTTP ${response.status}: ${response.statusText}`;
} }
private static formatError(error: SerializedError): string { private static formatError(error: SerializedError): string {
let result = error.message; let result = error.message;
if (error.causes.length > 0) { if (error.causes.length > 0) {
const causes = error.causes.join(", "); const causes = error.causes.join(", ");
result += ` caused by: ${causes}`; result += ` caused by: ${causes}`;
} }
return result; return result;
} }
public async create({ public async create({
documentId, documentId,
relativePath, relativePath,
contentBytes contentBytes
}: { }: {
documentId?: DocumentId; documentId?: DocumentId;
relativePath: RelativePath; relativePath: RelativePath;
contentBytes: Uint8Array; contentBytes: Uint8Array;
}): Promise<DocumentVersionWithoutContent> { }): Promise<DocumentVersionWithoutContent> {
return this.retryForever(async () => { return this.retryForever(async () => {
const formData = new FormData(); const formData = new FormData();
if (documentId !== undefined) { if (documentId !== undefined) {
formData.append("document_id", documentId); formData.append("document_id", documentId);
} }
formData.append("relative_path", relativePath); formData.append("relative_path", relativePath);
formData.append( formData.append(
"content", "content",
new Blob([new Uint8Array(contentBytes)]) new Blob([new Uint8Array(contentBytes)])
); );
this.logger.debug( this.logger.debug(
`Creating document with id ${documentId} and relative path ${relativePath}` `Creating document with id ${documentId} and relative path ${relativePath}`
); );
const response = await this.client(this.getUrl("/documents"), { const response = await this.client(this.getUrl("/documents"), {
method: "POST", method: "POST",
body: formData, body: formData,
headers: this.getDefaultHeaders() headers: this.getDefaultHeaders()
}); });
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
`Failed to create document: ${await SyncService.errorFromResponse( `Failed to create document: ${await SyncService.errorFromResponse(
response response
)}` )}`
); );
} }
const result: DocumentVersionWithoutContent = const result: DocumentVersionWithoutContent =
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion (await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug(`Created document ${JSON.stringify(result)}`); this.logger.debug(`Created document ${JSON.stringify(result)}`);
return result; return result;
}); });
} }
public async putText({ public async putText({
parentVersionId, parentVersionId,
documentId, documentId,
relativePath, relativePath,
content content
}: { }: {
parentVersionId: VaultUpdateId; parentVersionId: VaultUpdateId;
documentId: DocumentId; documentId: DocumentId;
relativePath: RelativePath; relativePath: RelativePath;
content: (number | string)[]; content: (number | string)[];
}): Promise<DocumentUpdateResponse> { }): Promise<DocumentUpdateResponse> {
return this.retryForever(async () => { return this.retryForever(async () => {
this.logger.debug( this.logger.debug(
`Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}, content [${content.join(", ")}]` `Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}, content [${content.join(", ")}]`
); );
const request: UpdateTextDocumentVersion = { const request: UpdateTextDocumentVersion = {
parentVersionId, parentVersionId,
relativePath, relativePath,
content content
}; };
const response = await this.client( const response = await this.client(
this.getUrl(`/documents/${documentId}/text`), this.getUrl(`/documents/${documentId}/text`),
{ {
method: "PUT", method: "PUT",
body: JSON.stringify(request), body: JSON.stringify(request),
headers: this.getDefaultHeaders({ type: "json" }) headers: this.getDefaultHeaders({ type: "json" })
} }
); );
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
`Failed to update document: ${await SyncService.errorFromResponse( `Failed to update document: ${await SyncService.errorFromResponse(
response response
)}` )}`
); );
} }
const result: DocumentUpdateResponse = const result: DocumentUpdateResponse =
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug( this.logger.debug(
`Updated document ${JSON.stringify(result)} with id ${ `Updated document ${JSON.stringify(result)} with id ${
result.documentId result.documentId
}}` }}`
); );
return result; return result;
}); });
} }
public async putBinary({ public async putBinary({
parentVersionId, parentVersionId,
documentId, documentId,
relativePath, relativePath,
contentBytes contentBytes
}: { }: {
parentVersionId: VaultUpdateId; parentVersionId: VaultUpdateId;
documentId: DocumentId; documentId: DocumentId;
relativePath: RelativePath; relativePath: RelativePath;
contentBytes: Uint8Array; contentBytes: Uint8Array;
}): Promise<DocumentUpdateResponse> { }): Promise<DocumentUpdateResponse> {
return this.retryForever(async () => { return this.retryForever(async () => {
this.logger.debug( this.logger.debug(
`Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}` `Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}`
); );
const formData = new FormData(); const formData = new FormData();
formData.append("parent_version_id", parentVersionId.toString()); formData.append("parent_version_id", parentVersionId.toString());
formData.append("relative_path", relativePath); formData.append("relative_path", relativePath);
formData.append( formData.append(
"content", "content",
new Blob([new Uint8Array(contentBytes)]) new Blob([new Uint8Array(contentBytes)])
); );
const response = await this.client( const response = await this.client(
this.getUrl(`/documents/${documentId}/binary`), this.getUrl(`/documents/${documentId}/binary`),
{ {
method: "PUT", method: "PUT",
body: formData, body: formData,
headers: this.getDefaultHeaders() headers: this.getDefaultHeaders()
} }
); );
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
`Failed to update document: ${await SyncService.errorFromResponse( `Failed to update document: ${await SyncService.errorFromResponse(
response response
)}` )}`
); );
} }
const result: DocumentUpdateResponse = const result: DocumentUpdateResponse =
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug( this.logger.debug(
`Updated document ${JSON.stringify(result)} with id ${ `Updated document ${JSON.stringify(result)} with id ${
result.documentId result.documentId
}}` }}`
); );
return result; return result;
}); });
} }
public async delete({ public async delete({
documentId, documentId,
relativePath relativePath
}: { }: {
documentId: DocumentId; documentId: DocumentId;
relativePath: RelativePath; relativePath: RelativePath;
}): Promise<DocumentVersionWithoutContent> { }): Promise<DocumentVersionWithoutContent> {
return this.retryForever(async () => { return this.retryForever(async () => {
const request: DeleteDocumentVersion = { const request: DeleteDocumentVersion = {
relativePath relativePath
}; };
this.logger.debug( this.logger.debug(
`Delete document with id ${documentId} and relative path ${relativePath}` `Delete document with id ${documentId} and relative path ${relativePath}`
); );
const response = await this.client( const response = await this.client(
this.getUrl(`/documents/${documentId}`), this.getUrl(`/documents/${documentId}`),
{ {
method: "DELETE", method: "DELETE",
body: JSON.stringify(request), body: JSON.stringify(request),
headers: this.getDefaultHeaders({ type: "json" }) headers: this.getDefaultHeaders({ type: "json" })
} }
); );
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
`Failed to delete document: ${await SyncService.errorFromResponse( `Failed to delete document: ${await SyncService.errorFromResponse(
response response
)}` )}`
); );
} }
const result: DocumentVersionWithoutContent = const result: DocumentVersionWithoutContent =
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion (await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug( this.logger.debug(
`Deleted document ${relativePath} with id ${documentId}` `Deleted document ${relativePath} with id ${documentId}`
); );
return result; return result;
}); });
} }
public async get({ public async get({
documentId documentId
}: { }: {
documentId: DocumentId; documentId: DocumentId;
}): Promise<DocumentVersion> { }): Promise<DocumentVersion> {
return this.retryForever(async () => { return this.retryForever(async () => {
this.logger.debug(`Getting document with id ${documentId}`); this.logger.debug(`Getting document with id ${documentId}`);
const response = await this.client( const response = await this.client(
this.getUrl(`/documents/${documentId}`), this.getUrl(`/documents/${documentId}`),
{ {
headers: this.getDefaultHeaders() headers: this.getDefaultHeaders()
} }
); );
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
`Failed to get document: ${await SyncService.errorFromResponse( `Failed to get document: ${await SyncService.errorFromResponse(
response response
)}` )}`
); );
} }
const result: DocumentVersion = const result: DocumentVersion =
(await response.json()) as DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion (await response.json()) as DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug(`Got document ${JSON.stringify(result)}`); this.logger.debug(`Got document ${JSON.stringify(result)}`);
return result; return result;
}); });
} }
public async getDocumentVersionContent({ public async getDocumentVersionContent({
documentId, documentId,
vaultUpdateId vaultUpdateId
}: { }: {
documentId: DocumentId; documentId: DocumentId;
vaultUpdateId: VaultUpdateId; vaultUpdateId: VaultUpdateId;
}): Promise<Uint8Array> { }): Promise<Uint8Array> {
return this.retryForever(async () => { return this.retryForever(async () => {
this.logger.debug( this.logger.debug(
`Getting document with id ${documentId} and version ${vaultUpdateId}` `Getting document with id ${documentId} and version ${vaultUpdateId}`
); );
const response = await this.client( const response = await this.client(
this.getUrl( this.getUrl(
`/documents/${documentId}/versions/${vaultUpdateId}/content` `/documents/${documentId}/versions/${vaultUpdateId}/content`
), ),
{ {
headers: this.getDefaultHeaders() headers: this.getDefaultHeaders()
} }
); );
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
`Failed to get document: ${await SyncService.errorFromResponse( `Failed to get document: ${await SyncService.errorFromResponse(
response response
)}` )}`
); );
} }
const result = await response.bytes(); const result = await response.bytes();
this.logger.debug( this.logger.debug(
`Got document version content for document ${documentId} version ${vaultUpdateId}` `Got document version content for document ${documentId} version ${vaultUpdateId}`
); );
return result; return result;
}); });
} }
public async getAll( public async getAll(
since?: VaultUpdateId since?: VaultUpdateId
): Promise<FetchLatestDocumentsResponse> { ): Promise<FetchLatestDocumentsResponse> {
return this.retryForever(async () => { return this.retryForever(async () => {
this.logger.debug( this.logger.debug(
"Getting all documents" + "Getting all documents" +
(since != null ? ` since ${since}` : "") (since != null ? ` since ${since}` : "")
); );
const url = new URL(this.getUrl("/documents")); const url = new URL(this.getUrl("/documents"));
if (since !== undefined) { if (since !== undefined) {
url.searchParams.append("since", since.toString()); url.searchParams.append("since", since.toString());
} }
const response = await this.client(url.toString(), { const response = await this.client(url.toString(), {
headers: this.getDefaultHeaders() headers: this.getDefaultHeaders()
}); });
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
`Failed to get documents: ${await SyncService.errorFromResponse( `Failed to get documents: ${await SyncService.errorFromResponse(
response response
)}` )}`
); );
} }
const result: FetchLatestDocumentsResponse = const result: FetchLatestDocumentsResponse =
(await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion (await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug( this.logger.debug(
`Got ${result.latestDocuments.length} document metadata` `Got ${result.latestDocuments.length} document metadata`
); );
return result; return result;
}); });
} }
public async ping(): Promise<PingResponse> { public async ping(): Promise<PingResponse> {
this.logger.debug("Pinging server"); this.logger.debug("Pinging server");
const response = await this.pingClient(this.getUrl("/ping"), { const response = await this.pingClient(this.getUrl("/ping"), {
headers: this.getDefaultHeaders() headers: this.getDefaultHeaders()
}); });
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
`Failed to ping server: ${await SyncService.errorFromResponse( `Failed to ping server: ${await SyncService.errorFromResponse(
response response
)}` )}`
); );
} }
const result: PingResponse = (await response.json()) as PingResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion const result: PingResponse = (await response.json()) as PingResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug( this.logger.debug(
`Pinged server, got response: ${JSON.stringify(result)}` `Pinged server, got response: ${JSON.stringify(result)}`
); );
return result; return result;
} }
private getUrl(path: string): string { private getUrl(path: string): string {
const { vaultName, remoteUri } = this.settings.getSettings(); const { vaultName, remoteUri } = this.settings.getSettings();
const remoteUriWithoutTrailingSlash = remoteUri.replace(/\/+$/, ""); const remoteUriWithoutTrailingSlash = remoteUri.replace(/\/+$/, "");
const encodedVaultName = encodeURIComponent(vaultName.trim()); const encodedVaultName = encodeURIComponent(vaultName.trim());
return `${remoteUriWithoutTrailingSlash}/vaults/${encodedVaultName}${path}`; return `${remoteUriWithoutTrailingSlash}/vaults/${encodedVaultName}${path}`;
} }
private getDefaultHeaders( private getDefaultHeaders(
{ type }: { type?: "json" } = { type: undefined } { type }: { type?: "json" } = { type: undefined }
): Record<string, string> { ): Record<string, string> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
"device-id": this.deviceId, "device-id": this.deviceId,
authorization: `Bearer ${this.settings.getSettings().token}` authorization: `Bearer ${this.settings.getSettings().token}`
}; };
if (type === "json") { if (type === "json") {
headers["Content-Type"] = "application/json"; headers["Content-Type"] = "application/json";
} }
return headers; return headers;
} }
private async retryForever<T>(fn: () => Promise<T>): Promise<T> { private async retryForever<T>(fn: () => Promise<T>): Promise<T> {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) { while (true) {
try { try {
return await fn(); return await fn();
} catch (e) { } catch (e) {
// We must not retry errors coming from reset // We must not retry errors coming from reset
if (e instanceof SyncResetError) { if (e instanceof SyncResetError) {
throw e; throw e;
} }
const retryInterval = const retryInterval =
this.settings.getSettings().networkRetryIntervalMs; this.settings.getSettings().networkRetryIntervalMs;
this.logger.error( this.logger.error(
`Failed network call (${e}), retrying in ${retryInterval}ms` `Failed network call (${e}), retrying in ${retryInterval}ms`
); );
await sleep(retryInterval); await sleep(retryInterval);
} }
} }
} }
} }

View file

@ -2,7 +2,7 @@
import type { DocumentWithCursors } from "./DocumentWithCursors"; import type { DocumentWithCursors } from "./DocumentWithCursors";
export interface ClientCursors { export interface ClientCursors {
userName: string; userName: string;
deviceId: string; deviceId: string;
documentsWithCursors: DocumentWithCursors[]; documentsWithCursors: DocumentWithCursors[];
} }

View file

@ -1,13 +1,13 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface CreateDocumentVersion { export interface CreateDocumentVersion {
/** /**
* The client can decide the document id (if it wishes to) in order * The client can decide the document id (if it wishes to) in order
* to help with syncing. If the client does not provide a document id, * to help with syncing. If the client does not provide a document id,
* the server will generate one. If the client provides a document id * the server will generate one. If the client provides a document id
* it must not already exist in the database. * it must not already exist in the database.
*/ */
document_id: string | null; document_id: string | null;
relative_path: string; relative_path: string;
content: number[]; content: number[];
} }

View file

@ -2,5 +2,5 @@
import type { DocumentWithCursors } from "./DocumentWithCursors"; import type { DocumentWithCursors } from "./DocumentWithCursors";
export interface CursorPositionFromClient { export interface CursorPositionFromClient {
documentsWithCursors: DocumentWithCursors[]; documentsWithCursors: DocumentWithCursors[];
} }

View file

@ -2,5 +2,5 @@
import type { ClientCursors } from "./ClientCursors"; import type { ClientCursors } from "./ClientCursors";
export interface CursorPositionFromServer { export interface CursorPositionFromServer {
clients: ClientCursors[]; clients: ClientCursors[];
} }

View file

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

View file

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

View file

@ -6,5 +6,5 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
* Response to an update document request. * Response to an update document request.
*/ */
export type DocumentUpdateResponse = export type DocumentUpdateResponse =
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent) | ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
| ({ type: "MergingUpdate" } & DocumentVersion); | ({ type: "MergingUpdate" } & DocumentVersion);

View file

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

View file

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

View file

@ -2,8 +2,8 @@
import type { CursorSpan } from "./CursorSpan"; import type { CursorSpan } from "./CursorSpan";
export interface DocumentWithCursors { export interface DocumentWithCursors {
vault_update_id: number | null; vault_update_id: number | null;
document_id: string; document_id: string;
relative_path: string; relative_path: string;
cursors: CursorSpan[]; cursors: CursorSpan[];
} }

View file

@ -5,9 +5,9 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
* Response to a fetch latest documents request. * Response to a fetch latest documents request.
*/ */
export interface FetchLatestDocumentsResponse { export interface FetchLatestDocumentsResponse {
latestDocuments: DocumentVersionWithoutContent[]; latestDocuments: DocumentVersionWithoutContent[];
/** /**
* The update ID of the latest document in the response. * The update ID of the latest document in the response.
*/ */
lastUpdateId: bigint; lastUpdateId: bigint;
} }

View file

@ -4,22 +4,22 @@
* Response to a ping request. * Response to a ping request.
*/ */
export interface PingResponse { export interface PingResponse {
/** /**
* Semantic version of the server. * Semantic version of the server.
*/ */
serverVersion: string; serverVersion: string;
/** /**
* Whether the client is authenticated based on the sent Authorization * Whether the client is authenticated based on the sent Authorization
* header. * header.
*/ */
isAuthenticated: boolean; isAuthenticated: boolean;
/** /**
* List of file extensions that are allowed to be merged. * List of file extensions that are allowed to be merged.
*/ */
mergeableFileExtensions: string[]; mergeableFileExtensions: string[];
/** /**
* API version ensuring backwards & forwards compatibility between the client * API version ensuring backwards & forwards compatibility between the client
* and server. * and server.
*/ */
supportedApiVersion: number; supportedApiVersion: number;
} }

View file

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

View file

@ -1,7 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface UpdateDocumentVersion { export interface UpdateDocumentVersion {
parent_version_id: bigint; parent_version_id: bigint;
relative_path: string; relative_path: string;
content: number[]; content: number[];
} }

View file

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

View file

@ -3,5 +3,5 @@ import type { CursorPositionFromClient } from "./CursorPositionFromClient";
import type { WebSocketHandshake } from "./WebSocketHandshake"; import type { WebSocketHandshake } from "./WebSocketHandshake";
export type WebSocketClientMessage = export type WebSocketClientMessage =
| ({ type: "handshake" } & WebSocketHandshake) | ({ type: "handshake" } & WebSocketHandshake)
| ({ type: "cursorPositions" } & CursorPositionFromClient); | ({ type: "cursorPositions" } & CursorPositionFromClient);

View file

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

View file

@ -3,5 +3,5 @@ import type { CursorPositionFromServer } from "./CursorPositionFromServer";
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
export type WebSocketServerMessage = export type WebSocketServerMessage =
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate) | ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
| ({ type: "cursorPositions" } & CursorPositionFromServer); | ({ type: "cursorPositions" } & CursorPositionFromServer);

View file

@ -2,6 +2,6 @@
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
export interface WebSocketVaultUpdate { export interface WebSocketVaultUpdate {
documents: DocumentVersionWithoutContent[]; documents: DocumentVersionWithoutContent[];
isInitialSync: boolean; isInitialSync: boolean;
} }

View file

@ -8,291 +8,291 @@ import type { Settings } from "../persistence/settings";
const WebSocket = require("ws") as typeof globalThis.WebSocket; const WebSocket = require("ws") as typeof globalThis.WebSocket;
class MockCloseEvent extends Event { class MockCloseEvent extends Event {
public code: number; public code: number;
public reason: string; public reason: string;
public constructor( public constructor(
type: string, type: string,
options: { code: number; reason: string } options: { code: number; reason: string }
) { ) {
super(type); super(type);
this.code = options.code; this.code = options.code;
this.reason = options.reason; this.reason = options.reason;
} }
} }
class MockMessageEvent extends Event { class MockMessageEvent extends Event {
public data: string; public data: string;
public constructor(type: string, options: { data: string }) { public constructor(type: string, options: { data: string }) {
super(type); super(type);
this.data = options.data; this.data = options.data;
} }
} }
class MockWebSocket { class MockWebSocket {
public readyState: number = WebSocket.CONNECTING; public readyState: number = WebSocket.CONNECTING;
public onopen: ((event: Event) => void) | null = null; public onopen: ((event: Event) => void) | null = null;
public onclose: ((event: MockCloseEvent) => void) | null = null; public onclose: ((event: MockCloseEvent) => void) | null = null;
public onmessage: ((event: MockMessageEvent) => void) | null = null; public onmessage: ((event: MockMessageEvent) => void) | null = null;
public onerror: ((event: Event) => void) | null = null; public onerror: ((event: Event) => void) | null = null;
public sentMessages: string[] = []; public sentMessages: string[] = [];
public constructor(public url: string) { public constructor(public url: string) {
setTimeout(() => { setTimeout(() => {
if (this.readyState === WebSocket.CONNECTING) { if (this.readyState === WebSocket.CONNECTING) {
this.readyState = WebSocket.OPEN; this.readyState = WebSocket.OPEN;
this.onopen?.(new Event("open")); this.onopen?.(new Event("open"));
} }
}, 0); }, 0);
} }
public send(data: string): void { public send(data: string): void {
if (this.readyState !== WebSocket.OPEN) { if (this.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket is not open"); throw new Error("WebSocket is not open");
} }
this.sentMessages.push(data); this.sentMessages.push(data);
} }
public close(code?: number, reason?: string): void { public close(code?: number, reason?: string): void {
this.readyState = WebSocket.CLOSED; this.readyState = WebSocket.CLOSED;
this.onclose?.( this.onclose?.(
new MockCloseEvent("close", { new MockCloseEvent("close", {
code: code ?? 1000, code: code ?? 1000,
reason: reason ?? "" reason: reason ?? ""
}) })
); );
} }
public simulateMessage(data: unknown): void { public simulateMessage(data: unknown): void {
this.onmessage?.( this.onmessage?.(
new MockMessageEvent("message", { data: JSON.stringify(data) }) new MockMessageEvent("message", { data: JSON.stringify(data) })
); );
} }
} }
type MockFn<T extends (...args: unknown[]) => unknown> = T & { type MockFn<T extends (...args: unknown[]) => unknown> = T & {
calls: Parameters<T>[]; calls: Parameters<T>[];
}; };
function createMockFn<T extends (...args: unknown[]) => unknown>( function createMockFn<T extends (...args: unknown[]) => unknown>(
implementation?: T implementation?: T
): MockFn<T> { ): MockFn<T> {
const calls: Parameters<T>[] = []; const calls: Parameters<T>[] = [];
const mockFn = ((...args: Parameters<T>) => { const mockFn = ((...args: Parameters<T>) => {
calls.push(args); calls.push(args);
return implementation?.(...args); return implementation?.(...args);
}) as unknown as MockFn<T>; }) as unknown as MockFn<T>;
mockFn.calls = calls; mockFn.calls = calls;
return mockFn; return mockFn;
} }
describe("WebSocketManager", () => { describe("WebSocketManager", () => {
let mockLogger: Logger = undefined as unknown as Logger; let mockLogger: Logger = undefined as unknown as Logger;
let mockSettings: Settings = undefined as unknown as Settings; let mockSettings: Settings = undefined as unknown as Settings;
let deviceId = "test-device-123"; let deviceId = "test-device-123";
beforeEach(() => { beforeEach(() => {
deviceId = "test-device-123"; deviceId = "test-device-123";
const noop = (): void => { const noop = (): void => {
// Intentionally empty for mock // Intentionally empty for mock
}; };
mockLogger = { mockLogger = {
info: createMockFn(noop), info: createMockFn(noop),
warn: createMockFn(noop), warn: createMockFn(noop),
error: createMockFn(noop), error: createMockFn(noop),
debug: createMockFn(noop) debug: createMockFn(noop)
} as unknown as Logger; } as unknown as Logger;
mockSettings = { mockSettings = {
getSettings: () => ({ getSettings: () => ({
remoteUri: "https://example.com", remoteUri: "https://example.com",
vaultName: "test-vault", vaultName: "test-vault",
webSocketRetryIntervalMs: 1000 webSocketRetryIntervalMs: 1000
}) })
} as unknown as Settings; } as unknown as Settings;
}); });
it("cleans up promises after message handling", async () => { it("cleans up promises after message handling", async () => {
const manager = new WebSocketManager( const manager = new WebSocketManager(
deviceId, deviceId,
mockLogger, mockLogger,
mockSettings, mockSettings,
MockWebSocket as unknown as typeof WebSocket MockWebSocket as unknown as typeof WebSocket
); );
manager.addRemoteVaultUpdateListener(async () => { manager.onRemoteVaultUpdateReceived.add(async () => {
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
}); });
manager.start(); manager.start();
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
const { outstandingPromises } = manager as unknown as { const { outstandingPromises } = manager as unknown as {
outstandingPromises: Promise<unknown>[]; outstandingPromises: Promise<unknown>[];
}; };
const mockWs = (manager as unknown as { webSocket: MockWebSocket }) const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket; .webSocket;
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] }); mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] }); mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] }); mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
assert.strictEqual(outstandingPromises.length, 0); assert.strictEqual(outstandingPromises.length, 0);
await manager.stop(); await manager.stop();
}); });
it("cleans up cursor position promises", async () => { it("cleans up cursor position promises", async () => {
const manager = new WebSocketManager( const manager = new WebSocketManager(
deviceId, deviceId,
mockLogger, mockLogger,
mockSettings, mockSettings,
MockWebSocket as unknown as typeof WebSocket MockWebSocket as unknown as typeof WebSocket
); );
manager.addRemoteCursorsUpdateListener(async () => { manager.onRemoteCursorsUpdateReceived.add(async () => {
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
}); });
manager.start(); manager.start();
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
const { outstandingPromises } = manager as unknown as { const { outstandingPromises } = manager as unknown as {
outstandingPromises: Promise<unknown>[]; outstandingPromises: Promise<unknown>[];
}; };
const mockWs = (manager as unknown as { webSocket: MockWebSocket }) const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket; .webSocket;
mockWs.simulateMessage({ mockWs.simulateMessage({
type: "cursorPositions", type: "cursorPositions",
clients: [{ deviceId: "other-device", cursors: [] }] clients: [{ deviceId: "other-device", cursors: [] }]
}); });
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
assert.strictEqual(outstandingPromises.length, 0); assert.strictEqual(outstandingPromises.length, 0);
await manager.stop(); await manager.stop();
}); });
it("logs handshake send errors", async () => { it("logs handshake send errors", async () => {
const manager = new WebSocketManager( const manager = new WebSocketManager(
deviceId, deviceId,
mockLogger, mockLogger,
mockSettings, mockSettings,
MockWebSocket as unknown as typeof WebSocket MockWebSocket as unknown as typeof WebSocket
); );
manager.start(); manager.start();
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
const mockWs = (manager as unknown as { webSocket: MockWebSocket }) const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket; .webSocket;
mockWs.send = (): void => { mockWs.send = (): void => {
throw new Error("Buffer full"); throw new Error("Buffer full");
}; };
assert.throws(() => { assert.throws(() => {
manager.sendHandshakeMessage({ manager.sendHandshakeMessage({
type: "handshake", type: "handshake",
token: "test", token: "test",
deviceId: "test", deviceId: "test",
lastSeenVaultUpdateId: null lastSeenVaultUpdateId: null
}); });
}); });
await manager.stop(); await manager.stop();
}); });
it("completes stop with timeout protection", async () => { it("completes stop with timeout protection", async () => {
const manager = new WebSocketManager( const manager = new WebSocketManager(
deviceId, deviceId,
mockLogger, mockLogger,
mockSettings, mockSettings,
MockWebSocket as unknown as typeof WebSocket MockWebSocket as unknown as typeof WebSocket
); );
manager.start(); manager.start();
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
await manager.stop(); await manager.stop();
assert.ok(true); assert.ok(true);
}); });
it("clears old handlers on reconnection", async () => { it("clears old handlers on reconnection", async () => {
const manager = new WebSocketManager( const manager = new WebSocketManager(
deviceId, deviceId,
mockLogger, mockLogger,
mockSettings, mockSettings,
MockWebSocket as unknown as typeof WebSocket MockWebSocket as unknown as typeof WebSocket
); );
let statusChangeCount = 0; let statusChangeCount = 0;
manager.addWebSocketStatusChangeListener(() => { manager.onWebSocketStatusChanged.add(() => {
statusChangeCount++; statusChangeCount++;
}); });
manager.start(); manager.start();
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
const firstWs = (manager as unknown as { webSocket: MockWebSocket }) const firstWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket; .webSocket;
statusChangeCount = 0; statusChangeCount = 0;
( (
manager as unknown as { initializeWebSocket: () => void } manager as unknown as { initializeWebSocket: () => void }
).initializeWebSocket(); ).initializeWebSocket();
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
statusChangeCount = 0; statusChangeCount = 0;
// Old handler should be cleared // Old handler should be cleared
firstWs.onclose?.( firstWs.onclose?.(
new MockCloseEvent("close", { code: 1000, reason: "test" }) new MockCloseEvent("close", { code: 1000, reason: "test" })
); );
assert.strictEqual(statusChangeCount, 0); assert.strictEqual(statusChangeCount, 0);
await manager.stop(); await manager.stop();
}); });
it("tracks message handling promises", async () => { it("tracks message handling promises", async () => {
const manager = new WebSocketManager( const manager = new WebSocketManager(
deviceId, deviceId,
mockLogger, mockLogger,
mockSettings, mockSettings,
MockWebSocket as unknown as typeof WebSocket MockWebSocket as unknown as typeof WebSocket
); );
// eslint-disable-next-line @typescript-eslint/init-declarations // eslint-disable-next-line @typescript-eslint/init-declarations
let resolveListener: () => void; let resolveListener: () => void;
const listenerPromise = new Promise<void>((resolve) => { const listenerPromise = new Promise<void>((resolve) => {
resolveListener = resolve; resolveListener = resolve;
}); });
manager.addRemoteVaultUpdateListener(async () => { manager.onRemoteVaultUpdateReceived.add(async () => {
await listenerPromise; await listenerPromise;
}); });
manager.start(); manager.start();
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
const mockWs = (manager as unknown as { webSocket: MockWebSocket }) const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket; .webSocket;
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] }); mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
const { outstandingPromises } = manager as unknown as { const { outstandingPromises } = manager as unknown as {
outstandingPromises: Promise<unknown>[]; outstandingPromises: Promise<unknown>[];
}; };
assert.ok(outstandingPromises.length > 0); assert.ok(outstandingPromises.length > 0);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
resolveListener!(); resolveListener!();
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
assert.strictEqual(outstandingPromises.length, 0); assert.strictEqual(outstandingPromises.length, 0);
await manager.stop(); await manager.stop();
}); });
}); });

View file

@ -6,296 +6,260 @@ import type { CursorPositionFromClient } from "./types/CursorPositionFromClient"
import type { ClientCursors } from "./types/ClientCursors"; import type { ClientCursors } from "./types/ClientCursors";
import { createPromise } from "../utils/create-promise"; import { createPromise } from "../utils/create-promise";
import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate"; import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate";
import { awaitAll } from "../utils/await-all";
import { WEBSOCKET_DISCONNECT_TIMEOUT_IN_S } from "../consts"; import { WEBSOCKET_DISCONNECT_TIMEOUT_IN_S } from "../consts";
import { removeFromArray } from "../utils/remove-from-array";
import { EventListeners } from "../utils/data-structures/event-listeners";
import { awaitAll } from "../utils/await-all";
export class WebSocketManager { export class WebSocketManager {
private readonly webSocketStatusChangeListeners: (( public readonly onWebSocketStatusChanged = new EventListeners<
isConnected: boolean (isConnected: boolean) => unknown
) => unknown)[] = []; >();
private readonly remoteVaultUpdateListeners: (( public readonly onRemoteVaultUpdateReceived = new EventListeners<
update: WebSocketVaultUpdate (update: WebSocketVaultUpdate) => Promise<void>
) => Promise<void>)[] = []; >();
private readonly remoteCursorsUpdateListeners: (( public readonly onRemoteCursorsUpdateReceived = new EventListeners<
cursors: ClientCursors[] (cursors: ClientCursors[]) => Promise<void>
) => Promise<void>)[] = []; >();
private isStopped = true; private isStopped = true;
private resolveDisconnectingPromise: null | (() => unknown) = null; private resolveDisconnectingPromise: null | (() => unknown) = null;
private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined; private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined;
private readonly outstandingPromises: Promise<unknown>[] = []; private readonly outstandingPromises: Promise<unknown>[] = [];
private webSocket: WebSocket | undefined; private webSocket: WebSocket | undefined;
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket; private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket;
public constructor( public constructor(
private readonly deviceId: string, private readonly deviceId: string,
private readonly logger: Logger, private readonly logger: Logger,
private readonly settings: Settings, private readonly settings: Settings,
webSocketImplementation?: typeof globalThis.WebSocket webSocketImplementation?: typeof globalThis.WebSocket
) { ) {
if (webSocketImplementation) { if (webSocketImplementation) {
this.webSocketFactoryImplementation = webSocketImplementation; this.webSocketFactoryImplementation = webSocketImplementation;
} else { } else {
if ( if (
typeof globalThis !== "undefined" && typeof globalThis !== "undefined" &&
typeof globalThis.WebSocket === "undefined" typeof globalThis.WebSocket === "undefined"
) { ) {
// eslint-disable-next-line // eslint-disable-next-line
this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js
} else { } else {
this.webSocketFactoryImplementation = WebSocket; this.webSocketFactoryImplementation = WebSocket;
} }
} }
} }
public get isWebSocketConnected(): boolean { public get isWebSocketConnected(): boolean {
return ( return (
this.webSocket?.readyState === this.webSocket?.readyState ===
this.webSocketFactoryImplementation.OPEN this.webSocketFactoryImplementation.OPEN
); );
} }
public addWebSocketStatusChangeListener( public start(): void {
listener: (isConnected: boolean) => unknown this.isStopped = false;
): void { this.initializeWebSocket();
this.webSocketStatusChangeListeners.push(listener); }
}
public addRemoteCursorsUpdateListener( public async stop(): Promise<void> {
listener: (cursors: ClientCursors[]) => Promise<void> const [promise, resolve] = createPromise();
): void { this.resolveDisconnectingPromise = resolve;
this.remoteCursorsUpdateListeners.push(listener);
}
public addRemoteVaultUpdateListener( this.isStopped = true;
listener: (update: WebSocketVaultUpdate) => Promise<void>
): void {
this.remoteVaultUpdateListeners.push(listener);
}
public start(): void { if (this.reconnectTimeoutId !== undefined) {
this.isStopped = false; clearTimeout(this.reconnectTimeoutId);
this.initializeWebSocket(); this.reconnectTimeoutId = undefined;
} }
public async stop(): Promise<void> { this.webSocket?.close(1000, "WebSocketManager has been stopped");
const [promise, resolve] = createPromise();
this.resolveDisconnectingPromise = resolve;
this.isStopped = true; // eslint-disable-next-line @typescript-eslint/init-declarations
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<void>((_, reject) => {
timeoutId = setTimeout(() => {
reject(
new Error(
`Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_S} seconds`
)
);
}, WEBSOCKET_DISCONNECT_TIMEOUT_IN_S * 1000);
});
if (this.reconnectTimeoutId !== undefined) { try {
clearTimeout(this.reconnectTimeoutId); while (this.isWebSocketConnected) {
this.reconnectTimeoutId = undefined; await Promise.race([promise, timeoutPromise]);
} }
} catch (error) {
this.logger.error(
`Error while waiting for WebSocket to close: ${String(error)}`
);
// Force cleanup even if close didn't work
this.resolveDisconnectingPromise();
this.resolveDisconnectingPromise = null;
} finally {
// Clear timeout to prevent unhandled rejection
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
this.webSocket?.close(1000, "WebSocketManager has been stopped"); await this.waitUntilFinished();
}
// eslint-disable-next-line @typescript-eslint/init-declarations public async waitUntilFinished(): Promise<void> {
let timeoutId: ReturnType<typeof setTimeout> | undefined; await awaitAll(this.outstandingPromises);
const timeoutPromise = new Promise<void>((_, reject) => { }
timeoutId = setTimeout(() => {
reject(
new Error(
`Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_S} seconds`
)
);
}, WEBSOCKET_DISCONNECT_TIMEOUT_IN_S * 1000);
});
try { public sendHandshakeMessage(
while (this.isWebSocketConnected) { message: WebSocketClientMessage & { type: "handshake" }
await Promise.race([promise, timeoutPromise]); ): void {
} const { webSocket } = this;
} catch (error) { if (!webSocket) {
this.logger.error( throw new Error(
`Error while waiting for WebSocket to close: ${String(error)}` "WebSocket is not connected, cannot send handshake message"
); );
// Force cleanup even if close didn't work }
this.resolveDisconnectingPromise();
this.resolveDisconnectingPromise = null;
} finally {
// Clear timeout to prevent unhandled rejection
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
await this.waitUntilFinished(); try {
} webSocket.send(JSON.stringify(message));
} catch (error) {
this.logger.error(
`Failed to send handshake message: ${String(error)}`
);
throw error;
}
}
public async waitUntilFinished(): Promise<void> { public updateLocalCursors(cursorPositions: CursorPositionFromClient): void {
await awaitAll(this.outstandingPromises); if (!this.isWebSocketConnected || !this.webSocket) {
} // A missing cursor update is fine, we can just skip it if needed
this.logger.warn(
"WebSocket is not connected, cannot send cursor positions"
);
return;
}
public sendHandshakeMessage( const message: WebSocketClientMessage = {
message: WebSocketClientMessage & { type: "handshake" } type: "cursorPositions",
): void { ...cursorPositions
const { webSocket } = this; };
if (!webSocket) {
throw new Error(
"WebSocket is not connected, cannot send handshake message"
);
}
try { try {
webSocket.send(JSON.stringify(message)); this.webSocket.send(JSON.stringify(message));
} catch (error) { this.logger.debug(
this.logger.error( `Sent cursor positions: ${JSON.stringify(cursorPositions)}`
`Failed to send handshake message: ${String(error)}` );
); } catch (error) {
throw error; this.logger.warn(
} `Failed to send cursor positions: ${String(error)}`
} );
}
}
public updateLocalCursors(cursorPositions: CursorPositionFromClient): void { private initializeWebSocket(): void {
if (!this.isWebSocketConnected || !this.webSocket) { // Clean up old WebSocket handlers to prevent race conditions
// A missing cursor update is fine, we can just skip it if needed if (this.webSocket) {
this.logger.warn( try {
"WebSocket is not connected, cannot send cursor positions" // Remove handlers to prevent them from firing after new connection
); this.webSocket.onopen = null;
return; this.webSocket.onclose = null;
} this.webSocket.onmessage = null;
this.webSocket.onerror = null;
this.webSocket.close();
} catch (e) {
this.logger.error(
`Failed to close previous WebSocket connection: ${e}`
);
}
}
const message: WebSocketClientMessage = { const wsUri = new URL(this.settings.getSettings().remoteUri);
type: "cursorPositions", wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
...cursorPositions wsUri.pathname = `/vaults/${this.settings.getSettings().vaultName}/ws`;
};
try { this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`);
this.webSocket.send(JSON.stringify(message));
this.logger.debug(
`Sent cursor positions: ${JSON.stringify(cursorPositions)}`
);
} catch (error) {
this.logger.warn(
`Failed to send cursor positions: ${String(error)}`
);
}
}
private initializeWebSocket(): void { this.webSocket = new this.webSocketFactoryImplementation(wsUri);
// Clean up old WebSocket handlers to prevent race conditions
if (this.webSocket) {
try {
// Remove handlers to prevent them from firing after new connection
this.webSocket.onopen = null;
this.webSocket.onclose = null;
this.webSocket.onmessage = null;
this.webSocket.onerror = null;
this.webSocket.close();
} catch (e) {
this.logger.error(
`Failed to close previous WebSocket connection: ${e}`
);
}
}
const wsUri = new URL(this.settings.getSettings().remoteUri); this.webSocket.onopen = (): void => {
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws"; this.logger.info("WebSocket connection opened");
wsUri.pathname = `/vaults/${this.settings.getSettings().vaultName}/ws`; this.onWebSocketStatusChanged.trigger(true);
};
this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`); this.webSocket.onmessage = (event): void => {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const message = JSON.parse(
event.data
) as WebSocketServerMessage;
this.webSocket = new this.webSocketFactoryImplementation(wsUri); // Track the message handling promise
const messageHandlingPromise = this.handleWebSocketMessage(
message
)
.catch((error: unknown) => {
this.logger.error(
`Error handling WebSocket message: ${String(error)}`
);
})
.finally(() => {
removeFromArray(
this.outstandingPromises,
messageHandlingPromise
);
});
this.webSocket.onopen = (): void => { void this.outstandingPromises.push(messageHandlingPromise); // ignore the returned promise
this.logger.info("WebSocket connection opened"); } catch (error) {
this.webSocketStatusChangeListeners.forEach((listener) => this.logger.error(
listener(true) `Error parsing WebSocket message: ${String(error)}`
); );
}; }
};
this.webSocket.onmessage = (event): void => { this.webSocket.onclose = (event): void => {
try { this.logger.warn(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion `WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
const message = JSON.parse( );
event.data this.onWebSocketStatusChanged.trigger(false);
) as WebSocketServerMessage;
// Track the message handling promise if (this.isStopped) {
const messageHandlingPromise = this.handleWebSocketMessage( this.resolveDisconnectingPromise?.();
message this.resolveDisconnectingPromise = null;
) } else {
.catch((error: unknown) => { this.reconnectTimeoutId = setTimeout(() => {
this.logger.error( this.reconnectTimeoutId = undefined;
`Error handling WebSocket message: ${String(error)}` this.initializeWebSocket();
); }, this.settings.getSettings().webSocketRetryIntervalMs);
}) }
.finally(() => { };
const index = this.outstandingPromises.indexOf( }
messageHandlingPromise
);
if (index !== -1) {
void this.outstandingPromises.splice(index, 1); // ignore the returned promise
}
});
void this.outstandingPromises.push(messageHandlingPromise); // ignore the returned promise private async handleWebSocketMessage(
} catch (error) { message: WebSocketServerMessage
this.logger.error( ): Promise<void> {
`Error parsing WebSocket message: ${String(error)}` if (message.type === "vaultUpdate") {
); await this.onRemoteVaultUpdateReceived.triggerAsync(message);
}
};
this.webSocket.onclose = (event): void => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
this.logger.warn( } else if (message.type === "cursorPositions") {
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})` this.logger.debug(
); `Received cursor positions for ${JSON.stringify(message.clients)}`
this.webSocketStatusChangeListeners.forEach((listener) => );
listener(false)
);
if (this.isStopped) { await this.onRemoteCursorsUpdateReceived.triggerAsync(
this.resolveDisconnectingPromise?.(); message.clients
this.resolveDisconnectingPromise = null; );
} else { } else {
this.reconnectTimeoutId = setTimeout(() => { this.logger.warn(
this.reconnectTimeoutId = undefined; `Received unknown message type: ${JSON.stringify(message)}`
this.initializeWebSocket(); );
}, this.settings.getSettings().webSocketRetryIntervalMs); }
} }
};
}
private async handleWebSocketMessage(
message: WebSocketServerMessage
): Promise<void> {
if (message.type === "vaultUpdate") {
await awaitAll(
this.remoteVaultUpdateListeners.map(async (listener) => {
await listener(message).catch((error: unknown) => {
this.logger.error(
`Error in vault update listener: ${String(error)}`
);
});
})
);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (message.type === "cursorPositions") {
this.logger.debug(
`Received cursor positions for ${JSON.stringify(message.clients)}`
);
await awaitAll(
this.remoteCursorsUpdateListeners.map(async (listener) => {
await listener(message.clients).catch((error: unknown) => {
this.logger.error(
`Error in cursor positions listener: ${String(error)}`
);
});
})
);
} else {
this.logger.warn(
`Received unknown message type: ${JSON.stringify(message)}`
);
}
}
} }

View file

@ -26,495 +26,506 @@ import { FixedSizeDocumentCache } from "./utils/data-structures/fix-sized-cache"
import { setUpTelemetry } from "./utils/set-up-telemetry"; import { setUpTelemetry } from "./utils/set-up-telemetry";
import { DIFF_CACHE_SIZE_MB } from "./consts"; import { DIFF_CACHE_SIZE_MB } from "./consts";
import { ServerConfig } from "./services/server-config"; import { ServerConfig } from "./services/server-config";
import type { EventListeners } from "./utils/data-structures/event-listeners";
export class SyncClient { export class SyncClient {
private hasStartedOfflineSync = false; private hasStartedOfflineSync = false;
private hasFinishedOfflineSync = false; private hasFinishedOfflineSync = false;
private hasStarted = false; private hasStarted = false;
private hasBeenDestroyed = false; private hasBeenDestroyed = false;
private unloadTelemetry?: () => void; private unloadTelemetry?: () => void;
private constructor( private constructor(
private readonly history: SyncHistory, private readonly history: SyncHistory,
private readonly settings: Settings, private readonly settings: Settings,
private readonly database: Database, private readonly database: Database,
private readonly syncer: Syncer, private readonly syncer: Syncer,
private readonly webSocketManager: WebSocketManager, private readonly webSocketManager: WebSocketManager,
public readonly logger: Logger, public readonly logger: Logger,
private readonly fetchController: FetchController, private readonly fetchController: FetchController,
private readonly cursorTracker: CursorTracker, private readonly cursorTracker: CursorTracker,
private readonly fileChangeNotifier: FileChangeNotifier, private readonly fileChangeNotifier: FileChangeNotifier,
private readonly contentCache: FixedSizeDocumentCache, private readonly contentCache: FixedSizeDocumentCache,
private readonly fileOperations: FileOperations, private readonly fileOperations: FileOperations,
private readonly serverConfig: ServerConfig, private readonly serverConfig: ServerConfig,
private readonly persistence: PersistenceProvider< private readonly persistence: PersistenceProvider<
Partial<{ Partial<{
settings: Partial<SyncSettings>; settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>; database: Partial<StoredDatabase>;
}> }>
> >
) {} ) {}
public get documentCount(): number { public get documentCount(): number {
return this.database.length; return this.database.length;
} }
public get isWebSocketConnected(): boolean { public get isWebSocketConnected(): boolean {
return this.webSocketManager.isWebSocketConnected; return this.webSocketManager.isWebSocketConnected;
} }
public static async create({
fs, public get onSyncHistoryUpdated(): EventListeners<
persistence, (stats: HistoryStats) => unknown
fetch, > {
webSocket, this.checkIfDestroyed("onSyncHistoryUpdated getter");
nativeLineEndings = "\n" return this.history.onHistoryUpdated;
}: { }
fs: FileSystemOperations;
persistence: PersistenceProvider< public get onSettingsChanged(): EventListeners<
Partial<{ (newSettings: SyncSettings, oldSettings: SyncSettings) => unknown
settings: Partial<SyncSettings>; > {
database: Partial<StoredDatabase>; this.checkIfDestroyed("onSettingsChanged getter");
}> return this.settings.onSettingsChanged;
>; }
fetch?: typeof globalThis.fetch;
webSocket?: typeof globalThis.WebSocket; public get onRemainingOperationsCountChanged(): EventListeners<
nativeLineEndings?: string; (remainingOperationsCount: number) => unknown
}): Promise<SyncClient> { > {
const logger = new Logger(); this.checkIfDestroyed("onRemainingOperationsCountChanged getter");
return this.syncer.onRemainingOperationsCountChanged;
const deviceId = createClientId(); }
logger.info(`Creating SyncClient with client id ${deviceId}`); public get onWebSocketStatusChanged(): EventListeners<
(isConnected: boolean) => unknown
const history = new SyncHistory(logger); > {
this.checkIfDestroyed("onWebSocketStatusChanged getter");
let state = (await persistence.load()) ?? { return this.webSocketManager.onWebSocketStatusChanged;
settings: undefined, }
database: undefined
}; public get onRemoteCursorsUpdated(): EventListeners<
(cursors: MaybeOutdatedClientCursors[]) => unknown
const settings = new Settings( > {
logger, this.checkIfDestroyed("onRemoteCursorsUpdated getter");
state.settings, return this.cursorTracker.onRemoteCursorsUpdated;
async (data): Promise<void> => { }
state = { ...state, settings: data };
// we're not rate-limiting settings saves as (1) we need to initialise the settings to know the rate limit public static async create({
// and (2) settings changes are infrequent enough that rate-limiting is not necessary fs,
await persistence.save(state); persistence,
} fetch,
); webSocket,
nativeLineEndings = "\n"
const rateLimitedSave = rateLimit( }: {
persistence.save, fs: FileSystemOperations;
() => settings.getSettings().minimumSaveIntervalMs persistence: PersistenceProvider<
); Partial<{
settings: Partial<SyncSettings>;
const database = new Database( database: Partial<StoredDatabase>;
logger, }>
state.database, >;
async (data): Promise<void> => { fetch?: typeof globalThis.fetch;
state = { ...state, database: data }; webSocket?: typeof globalThis.WebSocket;
await rateLimitedSave(state); nativeLineEndings?: string;
} }): Promise<SyncClient> {
); const logger = new Logger();
const fetchController = new FetchController( const deviceId = createClientId();
settings.getSettings().isSyncEnabled,
logger logger.info(`Creating SyncClient with client id ${deviceId}`);
);
settings.addOnSettingsChangeListener((newSettings, oldSettings) => { const history = new SyncHistory(logger);
if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) {
fetchController.canFetch = newSettings.isSyncEnabled; let state = (await persistence.load()) ?? {
} settings: undefined,
}); database: undefined
};
const syncService = new SyncService(
deviceId, const settings = new Settings(
fetchController, logger,
settings, state.settings,
logger, async (data): Promise<void> => {
fetch state = { ...state, settings: data };
); // we're not rate-limiting settings saves as (1) we need to initialise the settings to know the rate limit
// and (2) settings changes are infrequent enough that rate-limiting is not necessary
const serverConfig = new ServerConfig(syncService); await persistence.save(state);
}
const fileOperations = new FileOperations( );
logger,
database, const rateLimitedSave = rateLimit(
fs, persistence.save,
serverConfig, () => settings.getSettings().minimumSaveIntervalMs
nativeLineEndings );
);
const database = new Database(
const contentCache = new FixedSizeDocumentCache( logger,
1024 * 1024 * DIFF_CACHE_SIZE_MB state.database,
); async (data): Promise<void> => {
const unrestrictedSyncer = new UnrestrictedSyncer( state = { ...state, database: data };
logger, await rateLimitedSave(state);
database, }
settings, );
syncService,
fileOperations, const fetchController = new FetchController(
history, settings.getSettings().isSyncEnabled,
contentCache, logger
serverConfig );
); settings.onSettingsChanged.add((newSettings, oldSettings) => {
if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) {
const webSocketManager = new WebSocketManager( fetchController.canFetch = newSettings.isSyncEnabled;
deviceId, }
logger, });
settings,
webSocket const syncService = new SyncService(
); deviceId,
fetchController,
const syncer = new Syncer( settings,
deviceId, logger,
logger, fetch
database, );
settings,
syncService, const serverConfig = new ServerConfig(syncService);
webSocketManager,
fileOperations, const fileOperations = new FileOperations(
unrestrictedSyncer logger,
); database,
fs,
const fileChangeNotifier = new FileChangeNotifier(); serverConfig,
const cursorTracker = new CursorTracker( nativeLineEndings
database, );
webSocketManager,
fileOperations, const contentCache = new FixedSizeDocumentCache(
fileChangeNotifier 1024 * 1024 * DIFF_CACHE_SIZE_MB
); );
const client = new SyncClient( const unrestrictedSyncer = new UnrestrictedSyncer(
history, logger,
settings, database,
database, settings,
syncer, syncService,
webSocketManager, fileOperations,
logger, history,
fetchController, contentCache,
cursorTracker, serverConfig
fileChangeNotifier, );
contentCache,
fileOperations, const webSocketManager = new WebSocketManager(
serverConfig, deviceId,
persistence logger,
); settings,
webSocket
logger.info("SyncClient created successfully"); );
return client; const syncer = new Syncer(
} deviceId,
logger,
public async start(): Promise<void> { database,
this.checkIfDestroyed("start"); settings,
syncService,
if (this.hasStarted) { webSocketManager,
throw new Error("SyncClient has already been started"); fileOperations,
} unrestrictedSyncer
this.hasStarted = true; );
if ( const fileChangeNotifier = new FileChangeNotifier();
!this.unloadTelemetry && const cursorTracker = new CursorTracker(
this.settings.getSettings().enableTelemetry database,
) { webSocketManager,
this.unloadTelemetry = setUpTelemetry(); fileOperations,
} fileChangeNotifier
);
this.logger.addOnMessageListener((log): void => { const client = new SyncClient(
if (log.level === LogLevel.ERROR && Sentry.isInitialized()) { history,
Sentry.captureMessage(log.message); settings,
} database,
}); syncer,
webSocketManager,
this.settings.addOnSettingsChangeListener( logger,
this.onSettingsChange.bind(this) fetchController,
); cursorTracker,
fileChangeNotifier,
if (this.settings.getSettings().isSyncEnabled) { contentCache,
this.logger.info("Starting SyncClient"); fileOperations,
await this.startSyncing(); serverConfig,
this.logger.info("SyncClient has successfully started"); persistence
} );
}
logger.info("SyncClient created successfully");
/**
* Reload settings from disk overriding current in-memory settings. return client;
* Missing values will be filled in from DEFAULT_SETTINGS rather than }
* retaining current in-memory settings.
*/ public async start(): Promise<void> {
public async reloadSettings(): Promise<void> { this.checkIfDestroyed("start");
this.checkIfDestroyed("reloadSettings");
if (this.hasStarted) {
const state = (await this.persistence.load()) ?? { throw new Error("SyncClient has already been started");
settings: undefined }
}; this.hasStarted = true;
const settings = { if (
...DEFAULT_SETTINGS, !this.unloadTelemetry &&
...(state.settings ?? {}) this.settings.getSettings().enableTelemetry
}; ) {
this.unloadTelemetry = setUpTelemetry();
await this.setSettings(settings); }
}
this.logger.onLogEmitted.add((log): void => {
public async checkConnection(): Promise<NetworkConnectionStatus> { if (log.level === LogLevel.ERROR && Sentry.isInitialized()) {
this.checkIfDestroyed("checkConnection"); Sentry.captureMessage(log.message);
}
const server = await this.serverConfig.checkConnection(true); });
return {
isSuccessful: server.isSuccessful, this.settings.onSettingsChanged.add(this.onSettingsChange.bind(this));
serverMessage: server.message,
isWebSocketConnected: this.webSocketManager.isWebSocketConnected if (this.settings.getSettings().isSyncEnabled) {
}; this.logger.info("Starting SyncClient");
} await this.startSyncing();
this.logger.info("SyncClient has successfully started");
public getHistoryEntries(): readonly HistoryEntry[] { }
return this.history.entries; }
}
/**
public addSyncHistoryUpdateListener( * Reload settings from disk overriding current in-memory settings.
listener: (stats: HistoryStats) => unknown * Missing values will be filled in from DEFAULT_SETTINGS rather than
): void { * retaining current in-memory settings.
this.checkIfDestroyed("addSyncHistoryUpdateListener"); */
public async reloadSettings(): Promise<void> {
this.history.addSyncHistoryUpdateListener(listener); this.checkIfDestroyed("reloadSettings");
}
const state = (await this.persistence.load()) ?? {
/** settings: undefined
* Wait for the in-flight operations to finish, reset all tracking, };
* and the local database but retain the settings.
* The SyncClient can be used again after calling this method. const settings = {
*/ ...DEFAULT_SETTINGS,
public async reset(): Promise<void> { ...(state.settings ?? {})
this.checkIfDestroyed("reset"); };
this.logger.info( await this.setSettings(settings);
"Stopping SyncClient to apply changed connection settings" }
);
await this.pause(); public async checkConnection(): Promise<NetworkConnectionStatus> {
this.checkIfDestroyed("checkConnection");
// clear all local state
this.logger.info("Resetting SyncClient's local state"); const server = await this.serverConfig.checkConnection(true);
this.database.reset(); return {
await this.database.save(); // ensure the new database reads as empty isSuccessful: server.isSuccessful,
this.resetInMemoryState(); serverMessage: server.message,
this.hasStartedOfflineSync = false; isWebSocketConnected: this.webSocketManager.isWebSocketConnected
this.hasFinishedOfflineSync = false; };
this.serverConfig.reset(); }
await this.startSyncing(); public getHistoryEntries(): readonly HistoryEntry[] {
} return this.history.entries;
}
public getSettings(): SyncSettings {
return this.settings.getSettings(); /**
} * Wait for the in-flight operations to finish, reset all tracking,
* and the local database but retain the settings.
public async setSetting<T extends keyof SyncSettings>( * The SyncClient can be used again after calling this method.
key: T, */
value: SyncSettings[T] public async reset(): Promise<void> {
): Promise<void> { this.checkIfDestroyed("reset");
this.checkIfDestroyed("setSetting");
this.logger.info(
await this.settings.setSetting(key, value); "Stopping SyncClient to apply changed connection settings"
} );
await this.pause();
public async setSettings(value: Partial<SyncSettings>): Promise<void> {
this.checkIfDestroyed("setSettings"); // clear all local state
this.logger.info("Resetting SyncClient's local state");
await this.settings.setSettings(value); this.database.reset();
} await this.database.save(); // ensure the new database reads as empty
this.resetInMemoryState();
public addOnSettingsChangeListener( this.hasStartedOfflineSync = false;
listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown this.hasFinishedOfflineSync = false;
): void { this.serverConfig.reset();
this.checkIfDestroyed("addOnSettingsChangeListener");
await this.startSyncing();
this.settings.addOnSettingsChangeListener(listener); }
}
public getSettings(): SyncSettings {
public addRemainingSyncOperationsListener( return this.settings.getSettings();
listener: (remainingOperations: number) => unknown }
): void {
this.checkIfDestroyed("addRemainingSyncOperationsListener"); public async setSetting<T extends keyof SyncSettings>(
key: T,
this.syncer.addRemainingOperationsListener(listener); value: SyncSettings[T]
} ): Promise<void> {
this.checkIfDestroyed("setSetting");
public addWebSocketStatusChangeListener(listener: () => unknown): void {
this.checkIfDestroyed("addWebSocketStatusChangeListener"); await this.settings.setSetting(key, value);
}
this.webSocketManager.addWebSocketStatusChangeListener(listener);
} public async setSettings(value: Partial<SyncSettings>): Promise<void> {
this.checkIfDestroyed("setSettings");
public async syncLocallyCreatedFile(
relativePath: RelativePath await this.settings.setSettings(value);
): Promise<void> { }
this.checkIfDestroyed("syncLocallyCreatedFile");
public async syncLocallyCreatedFile(
this.fileChangeNotifier.notifyOfFileChange(relativePath); relativePath: RelativePath
return this.syncer.syncLocallyCreatedFile(relativePath); ): Promise<void> {
} this.checkIfDestroyed("syncLocallyCreatedFile");
public async syncLocallyDeletedFile( this.fileChangeNotifier.notifyOfFileChange(relativePath);
relativePath: RelativePath return this.syncer.syncLocallyCreatedFile(relativePath);
): Promise<void> { }
this.checkIfDestroyed("syncLocallyDeletedFile");
public async syncLocallyDeletedFile(
this.fileChangeNotifier.notifyOfFileChange(relativePath); relativePath: RelativePath
return this.syncer.syncLocallyDeletedFile(relativePath); ): Promise<void> {
} this.checkIfDestroyed("syncLocallyDeletedFile");
public async syncLocallyUpdatedFile({ this.fileChangeNotifier.notifyOfFileChange(relativePath);
oldPath, return this.syncer.syncLocallyDeletedFile(relativePath);
relativePath }
}: {
oldPath?: RelativePath; public async syncLocallyUpdatedFile({
relativePath: RelativePath; oldPath,
}): Promise<void> { relativePath
this.checkIfDestroyed("syncLocallyUpdatedFile"); }: {
oldPath?: RelativePath;
this.fileChangeNotifier.notifyOfFileChange(relativePath); relativePath: RelativePath;
return this.syncer.syncLocallyUpdatedFile({ }): Promise<void> {
oldPath, this.checkIfDestroyed("syncLocallyUpdatedFile");
relativePath
}); this.fileChangeNotifier.notifyOfFileChange(relativePath);
} return this.syncer.syncLocallyUpdatedFile({
oldPath,
public getDocumentSyncingStatus( relativePath
relativePath: RelativePath });
): DocumentSyncStatus { }
this.checkIfDestroyed("getDocumentSyncingStatus");
public getDocumentSyncingStatus(
if (!this.settings.getSettings().isSyncEnabled) { relativePath: RelativePath
return DocumentSyncStatus.SYNCING_IS_DISABLED; ): DocumentSyncStatus {
} this.checkIfDestroyed("getDocumentSyncingStatus");
if (!this.syncer.isFirstSyncComplete || !this.hasFinishedOfflineSync) { if (!this.settings.getSettings().isSyncEnabled) {
return DocumentSyncStatus.SYNCING; return DocumentSyncStatus.SYNCING_IS_DISABLED;
} }
const document = if (!this.syncer.isFirstSyncComplete || !this.hasFinishedOfflineSync) {
this.database.getLatestDocumentByRelativePath(relativePath); return DocumentSyncStatus.SYNCING;
if (document === undefined) { }
return DocumentSyncStatus.SYNCING;
} const document =
return document.updates.length > 0 this.database.getLatestDocumentByRelativePath(relativePath);
? DocumentSyncStatus.SYNCING if (document === undefined) {
: DocumentSyncStatus.UP_TO_DATE; return DocumentSyncStatus.SYNCING;
} }
return document.updates.length > 0
public async updateLocalCursors( ? DocumentSyncStatus.SYNCING
documentToCursors: Record<RelativePath, CursorSpan[]> : DocumentSyncStatus.UP_TO_DATE;
): Promise<void> { }
this.checkIfDestroyed("updateLocalCursors");
public async updateLocalCursors(
await this.cursorTracker.sendLocalCursorsToServer(documentToCursors); documentToCursors: Record<RelativePath, CursorSpan[]>
} ): Promise<void> {
this.checkIfDestroyed("updateLocalCursors");
public addRemoteCursorsUpdateListener(
listener: (cursors: MaybeOutdatedClientCursors[]) => unknown await this.cursorTracker.sendLocalCursorsToServer(documentToCursors);
): void { }
this.checkIfDestroyed("addRemoteCursorsUpdateListener");
public getTrackedFilePaths(): RelativePath[] {
this.cursorTracker.addRemoteCursorsUpdateListener(listener); this.checkIfDestroyed("getTrackedFilePaths");
}
return this.database.resolvedDocuments
public async waitUntilFinished(): Promise<void> { .filter((doc) => !doc.isDeleted && doc.metadata !== undefined)
this.checkIfDestroyed("waitUntilIdle"); .map((doc) => doc.relativePath);
await this.syncer.waitUntilFinished(); }
await this.webSocketManager.waitUntilFinished();
await this.database.save(); // flush all changes to disk public async getAllVaultFiles(): Promise<RelativePath[]> {
} this.checkIfDestroyed("getAllVaultFiles");
/** return this.fileOperations.listFilesRecursively(undefined);
* Completely destroy the SyncClient, cancelling all in-progress operations. }
* After calling this method, the SyncClient cannot be used again.
*/ public async waitUntilFinished(): Promise<void> {
public async destroy(): Promise<void> { this.checkIfDestroyed("waitUntilIdle");
this.checkIfDestroyed("destroy"); await this.syncer.waitUntilFinished();
await this.webSocketManager.waitUntilFinished();
// cancel everything that's in progress await this.database.save(); // flush all changes to disk
await this.pause(); }
this.hasBeenDestroyed = true; /**
* Completely destroy the SyncClient, cancelling all in-progress operations.
this.resetInMemoryState(); * After calling this method, the SyncClient cannot be used again.
*/
this.logger.info("SyncClient has been successfully disposed"); public async destroy(): Promise<void> {
this.checkIfDestroyed("destroy");
this.unloadTelemetry?.();
} // cancel everything that's in progress
await this.pause();
private async startSyncing(): Promise<void> {
this.checkIfDestroyed("startSyncing"); this.hasBeenDestroyed = true;
this.fetchController.finishReset();
this.resetInMemoryState();
await this.serverConfig.initialize();
this.webSocketManager.start(); this.logger.info("SyncClient has been successfully disposed");
if (!this.hasStartedOfflineSync) { this.unloadTelemetry?.();
this.hasStartedOfflineSync = true; }
await this.syncer.scheduleSyncForOfflineChanges();
} private async startSyncing(): Promise<void> {
this.checkIfDestroyed("startSyncing");
this.hasFinishedOfflineSync = true; this.fetchController.finishReset();
}
await this.serverConfig.initialize();
private async pause(): Promise<void> { this.webSocketManager.start();
this.fetchController.startReset();
await this.webSocketManager.stop(); if (!this.hasStartedOfflineSync) {
await this.waitUntilFinished(); this.hasStartedOfflineSync = true;
} await this.syncer.scheduleSyncForOfflineChanges();
}
private resetInMemoryState(): void {
this.history.reset(); this.hasFinishedOfflineSync = true;
this.contentCache.reset(); }
// don't reset the logger
this.cursorTracker.reset(); private async pause(): Promise<void> {
this.syncer.reset(); this.fetchController.startReset();
this.fileOperations.reset(); await this.webSocketManager.stop();
} await this.waitUntilFinished();
}
private async onSettingsChange(
newSettings: SyncSettings, private resetInMemoryState(): void {
oldSettings: SyncSettings this.history.reset();
): Promise<void> { this.contentCache.reset();
this.checkIfDestroyed("onSettingsChange"); // don't reset the logger
this.cursorTracker.reset();
if ( this.syncer.reset();
newSettings.vaultName !== oldSettings.vaultName || this.fileOperations.reset();
newSettings.remoteUri !== oldSettings.remoteUri }
) {
await this.reset(); private async onSettingsChange(
} newSettings: SyncSettings,
oldSettings: SyncSettings
if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) { ): Promise<void> {
if (newSettings.isSyncEnabled) { this.checkIfDestroyed("onSettingsChange");
await this.startSyncing();
} else { if (
await this.pause(); newSettings.vaultName !== oldSettings.vaultName ||
} newSettings.remoteUri !== oldSettings.remoteUri
} ) {
await this.reset();
if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) { }
this.contentCache.resize(newSettings.diffCacheSizeMB * 1024 * 1024);
} if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) {
if (newSettings.isSyncEnabled) {
if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) { await this.startSyncing();
if (newSettings.enableTelemetry) { } else {
this.unloadTelemetry = setUpTelemetry(); await this.pause();
} else { }
this.unloadTelemetry?.(); }
}
} if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) {
} this.contentCache.resize(newSettings.diffCacheSizeMB * 1024 * 1024);
}
private checkIfDestroyed(origin: string): void {
if (this.hasBeenDestroyed) { if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) {
throw new Error( if (newSettings.enableTelemetry) {
`SyncClient has been destroyed and can no longer be used; called from ${origin}` this.unloadTelemetry = setUpTelemetry();
); } else {
} this.unloadTelemetry?.();
} }
}
}
private checkIfDestroyed(origin: string): void {
if (this.hasBeenDestroyed) {
throw new Error(
`SyncClient has been destroyed and can no longer be used; called from ${origin}`
);
}
}
} }

View file

@ -9,252 +9,250 @@ import { DocumentUpToDateness } from "../types/document-up-to-dateness";
import { hash } from "../utils/hash"; import { hash } from "../utils/hash";
import type { FileChangeNotifier } from "./file-change-notifier"; import type { FileChangeNotifier } from "./file-change-notifier";
import { Lock } from "../utils/data-structures/locks"; import { Lock } from "../utils/data-structures/locks";
import { EventListeners } from "../utils/data-structures/event-listeners";
// Cursor positions are updated separately from documents. However, a given cursor position is only // Cursor positions are updated separately from documents. However, a given cursor position is only
// valid within a certain version of the document it belongs to. This class tracks previous and the latest // valid within a certain version of the document it belongs to. This class tracks previous and the latest
// known remote cursor positions, and for each document, tries to return the latest cursor positions that are // known remote cursor positions, and for each document, tries to return the latest cursor positions that are
// not from the future. // not from the future.
export class CursorTracker { export class CursorTracker {
private readonly updateLock = new Lock(); // The returned position may be accurate, if it matches the document version, or outdated, in which case
// the client has to heuristically guess it's current position based on the local edits.
public readonly onRemoteCursorsUpdated = new EventListeners<
(cursors: MaybeOutdatedClientCursors[]) => unknown
>();
private knownRemoteCursors: (ClientCursors & { private readonly updateLock = new Lock();
upToDateness: DocumentUpToDateness;
})[] = [];
private lastLocalCursorState: DocumentWithCursors[] = []; private knownRemoteCursors: (ClientCursors & {
private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] = upToDateness: DocumentUpToDateness;
[]; })[] = [];
public constructor( private lastLocalCursorState: DocumentWithCursors[] = [];
private readonly database: Database, private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] =
private readonly webSocketManager: WebSocketManager, [];
private readonly fileOperations: FileOperations,
private readonly fileChangeNotifier: FileChangeNotifier
) {
this.webSocketManager.addRemoteCursorsUpdateListener(
async (clientCursors) => {
await this.updateLock.withLock(async () => {
// The latest message will contain all active clients, so we can delete the ones
// from the local list which are no longer active.
const allIds = new Set(
clientCursors.map((c) => c.deviceId)
);
const updatedKnownRemoteCursors =
this.knownRemoteCursors.filter((c) =>
allIds.has(c.deviceId)
);
for (const cursor of clientCursors.filter((client) => public constructor(
client.documentsWithCursors.every( private readonly database: Database,
(doc) => doc.vault_update_id != null private readonly webSocketManager: WebSocketManager,
) private readonly fileOperations: FileOperations,
)) { private readonly fileChangeNotifier: FileChangeNotifier
updatedKnownRemoteCursors.push({ ) {
...cursor, this.webSocketManager.onRemoteCursorsUpdateReceived.add(
upToDateness: async (clientCursors) => {
await this.getDocumentsUpToDateness(cursor) await this.updateLock.withLock(async () => {
}); // The latest message will contain all active clients, so we can delete the ones
} // from the local list which are no longer active.
const allIds = new Set(
clientCursors.map((c) => c.deviceId)
);
const updatedKnownRemoteCursors =
this.knownRemoteCursors.filter((c) =>
allIds.has(c.deviceId)
);
this.knownRemoteCursors = updatedKnownRemoteCursors; for (const cursor of clientCursors.filter((client) =>
}); client.documentsWithCursors.every(
} (doc) => doc.vault_update_id != null
); )
)) {
updatedKnownRemoteCursors.push({
...cursor,
upToDateness:
await this.getDocumentsUpToDateness(cursor)
});
}
this.fileChangeNotifier.addFileChangeListener(async (relativePath) => this.knownRemoteCursors = updatedKnownRemoteCursors;
this.updateLock.withLock(async () => { });
for (const clientCursor of this.knownRemoteCursors) {
if (
clientCursor.documentsWithCursors.some(
(document) =>
document.relative_path === relativePath
)
) {
clientCursor.upToDateness =
await this.getDocumentsUpToDateness(clientCursor);
}
}
})
);
}
/// Update the local cursors for the given documents. this.onRemoteCursorsUpdated.trigger(
/// Can be called frequently as it only emits an event this.getRelevantAndPruneKnownClientCursors()
/// if the state has actually changed. );
public async sendLocalCursorsToServer( }
documentToCursors: Record<RelativePath, CursorSpan[]> );
): Promise<void> {
const documentsWithCursors: DocumentWithCursors[] = [];
for (const [relativePath, cursors] of Object.entries( this.fileChangeNotifier.onFileChanged.add(async (relativePath) =>
documentToCursors this.updateLock.withLock(async () => {
)) { for (const clientCursor of this.knownRemoteCursors) {
const record = if (
this.database.getLatestDocumentByRelativePath(relativePath); clientCursor.documentsWithCursors.some(
(document) =>
document.relative_path === relativePath
)
) {
clientCursor.upToDateness =
await this.getDocumentsUpToDateness(clientCursor);
}
}
})
);
}
if (!record) { /// Update the local cursors for the given documents.
continue; // Let's wait for the file to be created before sending cursors /// Can be called frequently as it only emits an event
} /// if the state has actually changed.
public async sendLocalCursorsToServer(
documentToCursors: Record<RelativePath, CursorSpan[]>
): Promise<void> {
const documentsWithCursors: DocumentWithCursors[] = [];
if (!record.metadata) { for (const [relativePath, cursors] of Object.entries(
continue; // this is a new document, no need to sync the cursors documentToCursors
} )) {
const record =
this.database.getLatestDocumentByRelativePath(relativePath);
documentsWithCursors.push({ if (!record) {
relative_path: relativePath, continue; // Let's wait for the file to be created before sending cursors
document_id: record.documentId, }
vault_update_id: record.metadata.parentVersionId,
cursors: cursors.map(({ start, end }) => ({
start: Math.min(start, end),
end: Math.max(start, end)
})) // the client might send directional selections
});
}
if ( if (!record.metadata) {
JSON.stringify(this.lastLocalCursorState) === continue; // this is a new document, no need to sync the cursors
JSON.stringify(documentsWithCursors) }
) {
// Caching step to avoid reading the edited files all the time
return;
}
this.lastLocalCursorState = documentsWithCursors;
for (const doc of documentsWithCursors) { documentsWithCursors.push({
const readContent = await this.fileOperations.read( relative_path: relativePath,
doc.relative_path document_id: record.documentId,
); vault_update_id: record.metadata.parentVersionId,
const record = this.database.getLatestDocumentByRelativePath( cursors: cursors.map(({ start, end }) => ({
doc.relative_path start: Math.min(start, end),
); end: Math.max(start, end)
if (record?.metadata?.hash !== hash(readContent)) { })) // the client might send directional selections
doc.vault_update_id = null; });
} }
}
if ( if (
JSON.stringify(this.lastLocalCursorStateWithoutDirtyDocuments) === JSON.stringify(this.lastLocalCursorState) ===
JSON.stringify(documentsWithCursors) JSON.stringify(documentsWithCursors)
) { ) {
return; // Caching step to avoid reading the edited files all the time
} return;
}
this.lastLocalCursorState = documentsWithCursors;
this.lastLocalCursorStateWithoutDirtyDocuments = documentsWithCursors; for (const doc of documentsWithCursors) {
const readContent = await this.fileOperations.read(
doc.relative_path
);
const record = this.database.getLatestDocumentByRelativePath(
doc.relative_path
);
if (record?.metadata?.hash !== hash(readContent)) {
doc.vault_update_id = null;
}
}
this.webSocketManager.updateLocalCursors({ documentsWithCursors }); if (
} JSON.stringify(this.lastLocalCursorStateWithoutDirtyDocuments) ===
JSON.stringify(documentsWithCursors)
) {
return;
}
// The returned position may be accurate, if it matches the document version, or outdated, in which case this.lastLocalCursorStateWithoutDirtyDocuments = documentsWithCursors;
// the client has to heuristically guess it's current position based on the local edits.
public addRemoteCursorsUpdateListener(
listener: (cursors: MaybeOutdatedClientCursors[]) => unknown
): void {
// CursorTracker registers its own event listener in the constructor so it must have been called before this
this.webSocketManager.addRemoteCursorsUpdateListener(async () => {
await this.updateLock.withLock(() =>
listener(this.getRelevantAndPruneKnownClientCursors())
);
});
}
public reset(): void { this.webSocketManager.updateLocalCursors({ documentsWithCursors });
this.knownRemoteCursors = []; }
this.lastLocalCursorState = [];
this.lastLocalCursorStateWithoutDirtyDocuments = [];
this.updateLock.reset();
}
private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] { public reset(): void {
const result: MaybeOutdatedClientCursors[] = []; this.knownRemoteCursors = [];
const included = new Set<string>(); this.lastLocalCursorState = [];
this.lastLocalCursorStateWithoutDirtyDocuments = [];
this.updateLock.reset();
}
const relevantCursors = []; private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] {
for (const clientCursors of [...this.knownRemoteCursors].reverse()) { const result: MaybeOutdatedClientCursors[] = [];
if (included.has(clientCursors.deviceId)) { const included = new Set<string>();
continue;
}
if (clientCursors.upToDateness === DocumentUpToDateness.Later) { const relevantCursors = [];
continue; for (const clientCursors of [...this.knownRemoteCursors].reverse()) {
} if (included.has(clientCursors.deviceId)) {
continue;
}
result.push({ if (clientCursors.upToDateness === DocumentUpToDateness.Later) {
...clientCursors, continue;
isOutdated: }
clientCursors.upToDateness === DocumentUpToDateness.Prior
});
included.add(clientCursors.deviceId); result.push({
relevantCursors.unshift(clientCursors); // to reverse order back to normal ...clientCursors,
} isOutdated:
clientCursors.upToDateness === DocumentUpToDateness.Prior
});
this.knownRemoteCursors = relevantCursors; included.add(clientCursors.deviceId);
relevantCursors.unshift(clientCursors); // to reverse order back to normal
}
return result; this.knownRemoteCursors = relevantCursors;
}
// We store up-to-dateness on a per-client basis to simplify the implementation. return result;
// An individual client won't have too many documents open at once, so this is a reasonable trade-off. }
private async getDocumentsUpToDateness(
clientCursor: ClientCursors
): Promise<DocumentUpToDateness> {
const results = [];
for (const document of clientCursor.documentsWithCursors) {
results.push(await this.getDocumentUpToDateness(document));
}
if ( // We store up-to-dateness on a per-client basis to simplify the implementation.
results.every((result) => result === DocumentUpToDateness.UpToDate) // An individual client won't have too many documents open at once, so this is a reasonable trade-off.
) { private async getDocumentsUpToDateness(
return DocumentUpToDateness.UpToDate; clientCursor: ClientCursors
} ): Promise<DocumentUpToDateness> {
const results = [];
for (const document of clientCursor.documentsWithCursors) {
results.push(await this.getDocumentUpToDateness(document));
}
if ( if (
results.every( results.every((result) => result === DocumentUpToDateness.UpToDate)
(result) => ) {
result === DocumentUpToDateness.UpToDate || return DocumentUpToDateness.UpToDate;
result === DocumentUpToDateness.Prior }
)
) {
return DocumentUpToDateness.Prior;
}
return DocumentUpToDateness.Later; if (
} results.every(
(result) =>
result === DocumentUpToDateness.UpToDate ||
result === DocumentUpToDateness.Prior
)
) {
return DocumentUpToDateness.Prior;
}
private async getDocumentUpToDateness( return DocumentUpToDateness.Later;
document: DocumentWithCursors }
): Promise<DocumentUpToDateness> {
const record = this.database.getLatestDocumentByRelativePath(
document.relative_path
);
if (!record) { private async getDocumentUpToDateness(
// the document of the cursor must be from the future document: DocumentWithCursors
return DocumentUpToDateness.Later; ): Promise<DocumentUpToDateness> {
} const record = this.database.getLatestDocumentByRelativePath(
document.relative_path
);
if ( if (!record) {
(record.metadata?.parentVersionId ?? 0) < // the document of the cursor must be from the future
(document.vault_update_id ?? 0) return DocumentUpToDateness.Later;
) { }
return DocumentUpToDateness.Later;
} else if (
(document.vault_update_id ?? 0) <
(record.metadata?.parentVersionId ?? 0)
) {
// the document of the cursor must be from the past
return DocumentUpToDateness.Prior;
}
const currentContent = await this.fileOperations.read( if (
document.relative_path (record.metadata?.parentVersionId ?? 0) <
); (document.vault_update_id ?? 0)
) {
return DocumentUpToDateness.Later;
} else if (
(document.vault_update_id ?? 0) <
(record.metadata?.parentVersionId ?? 0)
) {
// the document of the cursor must be from the past
return DocumentUpToDateness.Prior;
}
return this.database.getLatestDocumentByRelativePath( const currentContent = await this.fileOperations.read(
document.relative_path document.relative_path
)?.metadata?.hash === hash(currentContent) );
? DocumentUpToDateness.UpToDate
: DocumentUpToDateness.Prior; return this.database.getLatestDocumentByRelativePath(
} document.relative_path
)?.metadata?.hash === hash(currentContent)
? DocumentUpToDateness.UpToDate
: DocumentUpToDateness.Prior;
}
} }

View file

@ -1,24 +1,12 @@
import type { RelativePath } from "../persistence/database"; import type { RelativePath } from "../persistence/database";
import { EventListeners } from "../utils/data-structures/event-listeners";
export class FileChangeNotifier { export class FileChangeNotifier {
private readonly listeners: ((filePath: RelativePath) => unknown)[] = []; public readonly onFileChanged = new EventListeners<
(filePath: RelativePath) => unknown
>();
public addFileChangeListener( public notifyOfFileChange(filePath: RelativePath): void {
listener: (filePath: RelativePath) => unknown this.onFileChanged.trigger(filePath);
): void { }
this.listeners.push(listener);
}
public removeFileChangeListener(
listener: (filePath: RelativePath) => unknown
): void {
const index = this.listeners.indexOf(listener);
if (index !== -1) {
this.listeners.splice(index, 1);
}
}
public notifyOfFileChange(filePath: RelativePath): void {
this.listeners.forEach((listener) => listener(filePath));
}
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,89 +1,72 @@
import { MAX_LOG_MESSAGE_COUNT } from "../consts"; import { MAX_LOG_MESSAGE_COUNT } from "../consts";
import { EventListeners } from "../utils/data-structures/event-listeners";
export enum LogLevel { export enum LogLevel {
DEBUG = "DEBUG", DEBUG = "DEBUG",
INFO = "INFO", INFO = "INFO",
WARNING = "WARNING", WARNING = "WARNING",
ERROR = "ERROR" ERROR = "ERROR"
} }
const LOG_LEVEL_ORDER = { const LOG_LEVEL_ORDER = {
[LogLevel.DEBUG]: 0, [LogLevel.DEBUG]: 0,
[LogLevel.INFO]: 1, [LogLevel.INFO]: 1,
[LogLevel.WARNING]: 2, [LogLevel.WARNING]: 2,
[LogLevel.ERROR]: 3 [LogLevel.ERROR]: 3
}; };
export class LogLine { export class LogLine {
public timestamp = new Date(); public timestamp = new Date();
public constructor( public constructor(
public level: LogLevel, public level: LogLevel,
public message: string public message: string
) {} ) {}
} }
export class Logger { export class Logger {
private readonly messages: LogLine[] = []; public readonly onLogEmitted = new EventListeners<
private readonly onMessageListeners: ((message: LogLine) => unknown)[] = []; (message: LogLine) => unknown
>();
public constructor( private readonly messages: LogLine[] = [];
...onMessageListeners: ((message: LogLine) => unknown)[]
) {
this.onMessageListeners = onMessageListeners;
}
public debug(message: string): void { public debug(message: string): void {
this.pushMessage(message, LogLevel.DEBUG); this.pushMessage(message, LogLevel.DEBUG);
} }
public info(message: string): void { public info(message: string): void {
this.pushMessage(message, LogLevel.INFO); this.pushMessage(message, LogLevel.INFO);
} }
public warn(message: string): void { public warn(message: string): void {
this.pushMessage(message, LogLevel.WARNING); this.pushMessage(message, LogLevel.WARNING);
} }
public error(message: string): void { public error(message: string): void {
this.pushMessage(message, LogLevel.ERROR); this.pushMessage(message, LogLevel.ERROR);
} }
public getMessages(mininumSeverity: LogLevel): LogLine[] { public getMessages(mininumSeverity: LogLevel): LogLine[] {
return this.messages.filter( return this.messages.filter(
(message) => (message) =>
LOG_LEVEL_ORDER[message.level] >= LOG_LEVEL_ORDER[message.level] >=
LOG_LEVEL_ORDER[mininumSeverity] LOG_LEVEL_ORDER[mininumSeverity]
); );
} }
public addOnMessageListener(listener: (message: LogLine) => unknown): void { public reset(): void {
this.onMessageListeners.push(listener); this.messages.length = 0;
} this.debug("Logger has been reset");
}
public removeOnMessageListener( private pushMessage(message: string, level: LogLevel): void {
listener: (message: LogLine) => unknown const logLine = new LogLine(level, message);
): void { this.messages.push(logLine);
const index = this.onMessageListeners.indexOf(listener);
if (index !== -1) {
this.onMessageListeners.splice(index, 1);
}
}
public reset(): void { while (this.messages.length > MAX_LOG_MESSAGE_COUNT) {
this.messages.length = 0; this.messages.shift();
this.debug("Logger has been reset"); }
}
private pushMessage(message: string, level: LogLevel): void { this.onLogEmitted.trigger(logLine);
const logLine = new LogLine(level, message); }
this.messages.push(logLine);
while (this.messages.length > MAX_LOG_MESSAGE_COUNT) {
this.messages.shift();
}
this.onMessageListeners.forEach((listener) => {
listener(logLine);
});
}
} }

View file

@ -1,185 +1,167 @@
import { import {
MAX_HISTORY_ENTRY_COUNT, MAX_HISTORY_ENTRY_COUNT,
TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS
} from "../consts"; } from "../consts";
import type { RelativePath } from "../persistence/database"; import type { RelativePath } from "../persistence/database";
import type { Logger } from "./logger"; import type { Logger } from "./logger";
import { removeFromArray } from "../utils/remove-from-array";
import { EventListeners } from "../utils/data-structures/event-listeners";
export interface SyncCreateDetails { export interface SyncCreateDetails {
type: SyncType.CREATE; type: SyncType.CREATE;
relativePath: RelativePath; relativePath: RelativePath;
} }
export interface SyncUpdateDetails { export interface SyncUpdateDetails {
type: SyncType.UPDATE; type: SyncType.UPDATE;
relativePath: RelativePath; relativePath: RelativePath;
} }
export interface SyncMovedDetails { export interface SyncMovedDetails {
type: SyncType.MOVE; type: SyncType.MOVE;
relativePath: RelativePath; relativePath: RelativePath;
movedFrom: RelativePath; movedFrom: RelativePath;
} }
export interface SyncDeleteDetails { export interface SyncDeleteDetails {
type: SyncType.DELETE; type: SyncType.DELETE;
relativePath: RelativePath; relativePath: RelativePath;
} }
export interface SyncSkippedDetails { export interface SyncSkippedDetails {
type: SyncType.SKIPPED; type: SyncType.SKIPPED;
relativePath: RelativePath; relativePath: RelativePath;
} }
export type SyncDetails = export type SyncDetails =
| SyncCreateDetails | SyncCreateDetails
| SyncUpdateDetails | SyncUpdateDetails
| SyncDeleteDetails | SyncDeleteDetails
| SyncMovedDetails | SyncMovedDetails
| SyncSkippedDetails; | SyncSkippedDetails;
export interface CommonHistoryEntry { export interface CommonHistoryEntry {
status: SyncStatus; status: SyncStatus;
message: string; message: string;
details: SyncDetails; details: SyncDetails;
author?: string; author?: string;
timestamp?: Date; timestamp?: Date;
} }
export enum SyncType { export enum SyncType {
CREATE = "CREATE", CREATE = "CREATE",
UPDATE = "UPDATE", UPDATE = "UPDATE",
DELETE = "DELETE", DELETE = "DELETE",
MOVE = "MOVE", MOVE = "MOVE",
SKIPPED = "SKIPPED" SKIPPED = "SKIPPED"
} }
export enum SyncStatus { export enum SyncStatus {
SUCCESS = "SUCCESS", SUCCESS = "SUCCESS",
ERROR = "ERROR", ERROR = "ERROR",
SKIPPED = "SKIPPED" SKIPPED = "SKIPPED"
} }
export type HistoryEntry = CommonHistoryEntry & { timestamp: Date }; export type HistoryEntry = CommonHistoryEntry & { timestamp: Date };
export interface HistoryStats { export interface HistoryStats {
success: number; success: number;
error: number; error: number;
} }
export class SyncHistory { export class SyncHistory {
private _entries: HistoryEntry[] = []; public readonly onHistoryUpdated = new EventListeners<
(status: HistoryStats) => unknown
>();
private readonly syncHistoryUpdateListeners: (( private readonly _entries: HistoryEntry[] = [];
status: HistoryStats
) => unknown)[] = [];
private status: HistoryStats = { private status: HistoryStats = {
success: 0, success: 0,
error: 0 error: 0
}; };
public constructor(private readonly logger: Logger) {} public constructor(private readonly logger: Logger) {}
public get entries(): readonly HistoryEntry[] { public get entries(): readonly HistoryEntry[] {
return this._entries; return this._entries;
} }
/** /**
* Insert the entry at the beginning of the history list. If the entry * Insert the entry at the beginning of the history list. If the entry
* already in the list, it will get moved to the beginning and updated. * already in the list, it will get moved to the beginning and updated.
* *
* If the entry list is too long, the oldest entry will be removed. * If the entry list is too long, the oldest entry will be removed.
*/ */
public addHistoryEntry(entry: CommonHistoryEntry): void { public addHistoryEntry(entry: CommonHistoryEntry): void {
const historyEntry = { const historyEntry = {
...entry, ...entry,
timestamp: entry.timestamp ?? new Date() timestamp: entry.timestamp ?? new Date()
}; };
const candidate = this.findSimilarRecentUpdateEntry(historyEntry); const candidate = this.findSimilarRecentUpdateEntry(historyEntry);
if (candidate !== undefined) { if (candidate !== undefined) {
this._entries = this._entries.filter((e) => e !== candidate); removeFromArray(this._entries, candidate);
} }
// Insert the entry at the beginning // Insert the entry at the beginning
this._entries.unshift(historyEntry); this._entries.unshift(historyEntry);
if (this._entries.length > MAX_HISTORY_ENTRY_COUNT) { if (this._entries.length > MAX_HISTORY_ENTRY_COUNT) {
this._entries.pop(); this._entries.pop();
} }
this.updateSuccessCount(historyEntry); this.updateSuccessCount(historyEntry);
} }
public addSyncHistoryUpdateListener( public reset(): void {
listener: (stats: HistoryStats) => unknown this._entries.length = 0;
): void { this.status = {
this.syncHistoryUpdateListeners.push(listener); success: 0,
listener({ ...this.status }); error: 0
} };
this.onHistoryUpdated.trigger(this.status);
}
public removeSyncHistoryUpdateListener( private findSimilarRecentUpdateEntry(
listener: (stats: HistoryStats) => unknown entry: HistoryEntry
): void { ): HistoryEntry | undefined {
const index = this.syncHistoryUpdateListeners.indexOf(listener); if (entry.details.type !== SyncType.UPDATE) {
if (index !== -1) { return;
this.syncHistoryUpdateListeners.splice(index, 1); }
}
}
public reset(): void { const candidate = this._entries.find(
this._entries.length = 0; (e) =>
this.status = { e.details.type === SyncType.UPDATE &&
success: 0, e.details.relativePath === entry.details.relativePath
error: 0 );
}; if (
this.syncHistoryUpdateListeners.forEach((listener) => { candidate !== undefined &&
listener(this.status); (this._entries[0] === candidate ||
}); candidate.timestamp.getTime() +
} TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS * 1000 >
entry.timestamp.getTime())
) {
return candidate;
}
}
private findSimilarRecentUpdateEntry( private updateSuccessCount(entry: HistoryEntry): void {
entry: HistoryEntry const message = `${entry.details.relativePath} - ${entry.message} (${entry.details.type.toLocaleLowerCase()})`;
): HistoryEntry | undefined { switch (entry.status) {
if (entry.details.type !== SyncType.UPDATE) { case SyncStatus.SUCCESS:
return; this.status.success++;
} this.logger.info(`History entry: ${message}`);
break;
case SyncStatus.ERROR:
this.status.error++;
this.logger.error(`Cannot sync file: ${message}`);
break;
case SyncStatus.SKIPPED:
this.logger.warn(`Skipping file: ${message}`);
break;
}
const candidate = this._entries.find( this.onHistoryUpdated.trigger(this.status);
(e) => }
e.details.type === SyncType.UPDATE &&
e.details.relativePath === entry.details.relativePath
);
if (
candidate !== undefined &&
(this._entries[0] === candidate ||
candidate.timestamp.getTime() +
TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS * 1000 >
entry.timestamp.getTime())
) {
return candidate;
}
}
private updateSuccessCount(entry: HistoryEntry): void {
const message = `${entry.details.relativePath} - ${entry.message} (${entry.details.type.toLocaleLowerCase()})`;
switch (entry.status) {
case SyncStatus.SUCCESS:
this.status.success++;
this.logger.info(`History entry: ${message}`);
break;
case SyncStatus.ERROR:
this.status.error++;
this.logger.error(`Cannot sync file: ${message}`);
break;
case SyncStatus.SKIPPED:
this.logger.warn(`Skipping file: ${message}`);
break;
}
this.syncHistoryUpdateListeners.forEach((listener) => {
listener(this.status);
});
}
} }

View file

@ -1,5 +1,5 @@
export enum DocumentSyncStatus { export enum DocumentSyncStatus {
UP_TO_DATE = "UP_TO_DATE", UP_TO_DATE = "UP_TO_DATE",
SYNCING = "SYNCING", SYNCING = "SYNCING",
SYNCING_IS_DISABLED = "SYNCING_IS_DISABLED" SYNCING_IS_DISABLED = "SYNCING_IS_DISABLED"
} }

View file

@ -1,5 +1,5 @@
export enum DocumentUpToDateness { export enum DocumentUpToDateness {
UpToDate = "UpToDate", // easiest case, the client can just show the cursors as-is UpToDate = "UpToDate", // easiest case, the client can just show the cursors as-is
Prior = "Prior", // The cursors are outdated, so the client has to guess the cursor positions based on local updates. This is only possible if this client's cursor has once been up-to-date in a given document. Prior = "Prior", // The cursors are outdated, so the client has to guess the cursor positions based on local updates. This is only possible if this client's cursor has once been up-to-date in a given document.
Later = "Later" // The cursors are from a future version of a document, there's no way we can accuratly show them locally. Later = "Later" // The cursors are from a future version of a document, there's no way we can accuratly show them locally.
} }

View file

@ -1,5 +1,5 @@
import type { ClientCursors } from "../services/types/ClientCursors"; import type { ClientCursors } from "../services/types/ClientCursors";
export interface MaybeOutdatedClientCursors extends ClientCursors { export interface MaybeOutdatedClientCursors extends ClientCursors {
isOutdated: boolean; isOutdated: boolean;
} }

View file

@ -1,5 +1,5 @@
export interface NetworkConnectionStatus { export interface NetworkConnectionStatus {
isSuccessful: boolean; isSuccessful: boolean;
serverMessage: string; serverMessage: string;
isWebSocketConnected: boolean; isWebSocketConnected: boolean;
} }

View file

@ -1,13 +1,13 @@
import assert from "node:assert"; import assert from "node:assert";
export function assertSetContainsExactly<T>(set: Set<T>, ...values: T[]): void { export function assertSetContainsExactly<T>(set: Set<T>, ...values: T[]): void {
assert.ok( assert.ok(
set.size === values.length && set.size === values.length &&
Array.from(set).every((value) => values.includes(value)), Array.from(set).every((value) => values.includes(value)),
`Expected set to contain only ${values.map((v) => '"' + v + '"').join(", ")}, but it contained ${Array.from( `Expected set to contain only ${values.map((v) => '"' + v + '"').join(", ")}, but it contained ${Array.from(
set set
) )
.map((v) => '"' + v + '"') .map((v) => '"' + v + '"')
.join(", ")}` .join(", ")}`
); );
} }

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