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
tab_width = 4
[*.{yml,yaml}]
[*.{yml,yaml,md}]
indent_size = 2
tab_width = 2

View file

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

View file

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

1
docs/.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

@ -243,9 +243,9 @@ users:
2. Client sends authentication message:
```json
{
"type": "auth",
"token": "user-token",
"vault": "vault-name"
"type": "auth",
"token": "user-token",
"vault": "vault-name"
}
```
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">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
</linearGradient>
</defs>
@ -25,23 +25,23 @@
<!-- Link chain -->
<g opacity="0.9">
<!-- Left link -->
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Right link -->
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Center link connecting them -->
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Left link -->
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Right link -->
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Center link connecting them -->
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
</g>
<!-- Sync arrows (subtle) -->
<g opacity="0.5">
<!-- 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"/>
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
<!-- 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"/>
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
<!-- 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"/>
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
<!-- 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"/>
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
</g>
</g>
</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";
export default [
{
ignores: [
"sync-client/src/services/types.ts",
"**/dist/",
"**/*.mjs",
"**/*.js"
]
},
...tseslint.config({
plugins: {
"unused-imports": unusedImports
},
extends: [eslint.configs.recommended, tseslint.configs.all],
rules: {
"no-unused-vars": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-floating-promises": [
"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."
},
{
ignores: [
"sync-client/src/services/types.ts",
"**/dist/",
"**/*.mjs",
"**/*.js"
]
},
...tseslint.config({
plugins: {
"unused-imports": unusedImports
},
extends: [eslint.configs.recommended, tseslint.configs.all],
rules: {
"no-unused-vars": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-floating-promises": [
"error",
{
object: "Promise",
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."
}
],
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_"
}
]
},
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname
}
}
})
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",
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",
"version": "0.12.0",
"description": "Standalone CLI for VaultLink sync client",
"private": false,
"bin": {
"vaultlink": "./dist/cli.js"
},
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "tsx --test src/args.test.ts src/node-filesystem.test.ts"
},
"dependencies": {
"commander": "^14.0.2"
},
"devDependencies": {
"@types/node": "^24.8.1",
"sync-client": "file:../sync-client",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"tsx": "^4.20.6",
"typescript": "5.8.3",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
}
"name": "local-client-cli",
"version": "0.12.0",
"description": "Standalone CLI for VaultLink sync client",
"private": false,
"bin": {
"vaultlink": "./dist/cli.js"
},
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "tsx --test 'src/**/*.test.ts'"
},
"dependencies": {
"commander": "^14.0.2",
"watcher": "^2.3.1"
},
"devDependencies": {
"@types/node": "^24.8.1",
"sync-client": "file:../sync-client",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"tsx": "^4.20.6",
"typescript": "5.8.3",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
}
}

View file

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

View file

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

View file

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

View file

@ -1,102 +1,121 @@
import * as fs from "fs";
import Watcher from "watcher";
import * as path from "path";
import type { SyncClient, RelativePath } from "sync-client";
export class FileWatcher {
private watcher: fs.FSWatcher | undefined;
private isRunning = false;
private watcher: Watcher | undefined;
private isRunning = false;
public constructor(
private readonly basePath: string,
private readonly client: SyncClient
) {}
public constructor(
private readonly basePath: string,
private readonly client: SyncClient
) {}
public start(): void {
if (this.isRunning) {
return;
}
public start(): void {
if (this.isRunning) {
return;
}
this.isRunning = true;
this.isRunning = true;
this.watcher = fs.watch(
this.basePath,
{ recursive: true },
(eventType, filename) => {
if (filename === null || filename.length === 0) {
return;
}
this.watcher = new Watcher(this.basePath, {
recursive: true,
renameDetection: true,
renameTimeout: 125,
ignoreInitial: true
});
// Convert to forward slashes for consistency
const relativePath = this.toUnixPath(filename);
this.watcher.on("add", (filePath: string) => {
this.handleCreate(this.toRelativePath(filePath));
});
if (eventType === "rename") {
this.handleRenameOrDelete(relativePath);
} else {
// Must be "change" event
this.handleChange(relativePath);
}
}
);
this.watcher.on("change", (filePath: string) => {
this.handleChange(this.toRelativePath(filePath));
});
this.client.logger.info("File watcher started");
}
this.watcher.on("unlink", (filePath: string) => {
this.handleDelete(this.toRelativePath(filePath));
});
public stop(): void {
if (this.watcher !== undefined) {
this.watcher.close();
this.watcher = undefined;
}
this.isRunning = false;
this.client.logger.info("File watcher stopped");
}
this.watcher.on("rename", (oldPath: string, newPath: string) => {
this.handleRename(
this.toRelativePath(oldPath),
this.toRelativePath(newPath)
);
});
private handleChange(relativePath: RelativePath): void {
this.client
.syncLocallyUpdatedFile({ relativePath })
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync updated file ${relativePath}: ${err instanceof Error ? err.message : String(err)}`
);
});
}
this.client.logger.info("File watcher started");
}
private handleRenameOrDelete(relativePath: RelativePath): void {
const fullPath = path.join(this.basePath, relativePath);
public stop(): void {
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) => {
if (accessError) {
this.client
.syncLocallyDeletedFile(relativePath)
.catch((deleteErr: unknown) => {
this.client.logger.error(
`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;
}
private handleCreate(relativePath: RelativePath): void {
this.client
.syncLocallyCreatedFile(relativePath)
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync created file ${relativePath}: ${this.formatError(err)}`
);
});
}
this.client
.syncLocallyCreatedFile(relativePath)
.catch((createErr: unknown) => {
this.client.logger.error(
`Failed to sync created file ${relativePath}: ${createErr instanceof Error ? createErr.message : String(createErr)}`
);
});
});
}
});
}
private handleChange(relativePath: RelativePath): void {
this.client
.syncLocallyUpdatedFile({ relativePath })
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync updated file ${relativePath}: ${this.formatError(err)}`
);
});
}
/**
* Convert a native platform path to forward slashes
*/
private toUnixPath(nativePath: string): string {
if (path.sep === "\\") {
return nativePath.replace(/\\/g, "/");
}
return nativePath;
}
private handleDelete(relativePath: RelativePath): void {
this.client
.syncLocallyDeletedFile(relativePath)
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync deleted file ${relativePath}: ${this.formatError(err)}`
);
});
}
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";
function isHealthStatus(value: unknown): value is NetworkConnectionStatus {
if (typeof value !== "object" || value === null) {
return false;
}
if (typeof value !== "object" || value === null) {
return false;
}
return (
"isSuccessful" in value &&
typeof value.isSuccessful === "boolean" &&
"isWebSocketConnected" in value &&
typeof value.isWebSocketConnected === "boolean" &&
"serverMessage" in value &&
typeof value.serverMessage === "string"
);
return (
"isSuccessful" in value &&
typeof value.isSuccessful === "boolean" &&
"isWebSocketConnected" in value &&
typeof value.isWebSocketConnected === "boolean" &&
"serverMessage" in value &&
typeof value.serverMessage === "string"
);
}
function main(): void {
if (process.argv.length < 3) {
console.error("Usage: healthcheck <path-to-health-file>");
process.exit(1);
}
const [, , healthFile] = process.argv;
if (process.argv.length < 3) {
console.error("Usage: healthcheck <path-to-health-file>");
process.exit(1);
}
const [, , healthFile] = process.argv;
try {
// Check if health file exists
if (!fs.existsSync(healthFile)) {
console.error(`Health file does not exist: ${healthFile}`);
process.exit(1);
}
try {
// Check if health file exists
if (!fs.existsSync(healthFile)) {
console.error(`Health file does not exist: ${healthFile}`);
process.exit(1);
}
// Read and parse health status
const content = fs.readFileSync(healthFile, "utf-8");
const parsed: unknown = JSON.parse(content);
// Read and parse health status
const content = fs.readFileSync(healthFile, "utf-8");
const parsed: unknown = JSON.parse(content);
// Validate the parsed object using type guard
if (!isHealthStatus(parsed)) {
throw new Error("Invalid health status format");
}
// Validate the parsed object using type guard
if (!isHealthStatus(parsed)) {
throw new Error("Invalid health status format");
}
const status = parsed;
const status = parsed;
if (!status.isSuccessful || !status.isWebSocketConnected) {
console.error("Not connected to server: " + status.serverMessage);
process.exit(1);
}
if (!status.isSuccessful || !status.isWebSocketConnected) {
console.error("Not connected to server: " + status.serverMessage);
process.exit(1);
}
console.log("Healthy: Connected to server");
process.exit(0);
} catch (error) {
console.error(
`Health check failed: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
}
console.log("Healthy: Connected to server");
process.exit(0);
} catch (error) {
console.error(
`Health check failed: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
}
}
main();

View file

@ -2,85 +2,85 @@ import { LogLevel, type LogLine } from "sync-client";
// ANSI color codes
export const colors = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
// Foreground colors
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
gray: "\x1b[90m"
// Foreground colors
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
gray: "\x1b[90m"
} as const;
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
*/
export function styleText(
text: string,
...modifiers: (keyof typeof colors)[]
text: string,
...modifiers: (keyof typeof colors)[]
): string {
const prefix = modifiers.map((m) => colors[m]).join("");
return `${prefix}${text}${colors.reset}`;
const prefix = modifiers.map((m) => colors[m]).join("");
return `${prefix}${text}${colors.reset}`;
}
function formatTimestamp(date: Date): string {
const [time] = date.toTimeString().split(" ");
const ms = date.getMilliseconds().toString().padStart(3, "0");
return colorize(`${time}.${ms}`, "gray");
const [time] = date.toTimeString().split(" ");
const ms = date.getMilliseconds().toString().padStart(3, "0");
return colorize(`${time}.${ms}`, "gray");
}
function formatLevel(level: LogLevel): string {
const levelStr = level.padEnd(7);
switch (level) {
case LogLevel.DEBUG:
return colorize(levelStr, "cyan");
case LogLevel.INFO:
return colorize(levelStr, "green");
case LogLevel.WARNING:
return colorize(levelStr, "yellow");
case LogLevel.ERROR:
return colorize(levelStr, "red");
}
const levelStr = level.padEnd(7);
switch (level) {
case LogLevel.DEBUG:
return colorize(levelStr, "cyan");
case LogLevel.INFO:
return colorize(levelStr, "green");
case LogLevel.WARNING:
return colorize(levelStr, "yellow");
case LogLevel.ERROR:
return colorize(levelStr, "red");
}
}
function formatMessage(message: string, level: LogLevel): string {
// Highlight important parts of the message
let formatted = message;
// Highlight important parts of the message
let formatted = message;
// Highlight file paths
formatted = formatted.replace(
/(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g,
(_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2
);
// Highlight file paths
formatted = formatted.replace(
/(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g,
(_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2
);
// Highlight numbers
formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan"));
// Highlight numbers
formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan"));
// Highlight patterns like /regex/
formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) =>
colorize(pattern, "yellow")
);
// Highlight patterns like /regex/
formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) =>
colorize(pattern, "yellow")
);
// Make error messages bold
if (level === LogLevel.ERROR) {
formatted = colorize(formatted, "bold");
}
// Make error messages bold
if (level === LogLevel.ERROR) {
formatted = colorize(formatted, "bold");
}
return formatted;
return formatted;
}
export function formatLogLine(logLine: LogLine): string {
const timestamp = formatTimestamp(logLine.timestamp);
const level = formatLevel(logLine.level);
const message = formatMessage(logLine.message, logLine.level);
const timestamp = formatTimestamp(logLine.timestamp);
const level = formatLevel(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";
test("NodeFileSystemOperations - read and write files", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
const content = new TextEncoder().encode("Hello, world!");
await fsOps.write("test.txt", content);
try {
const content = new TextEncoder().encode("Hello, world!");
await fsOps.write("test.txt", content);
const readContent = await fsOps.read("test.txt");
assert.equal(new TextDecoder().decode(readContent), "Hello, world!");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
const readContent = await fsOps.read("test.txt");
assert.equal(new TextDecoder().decode(readContent), "Hello, world!");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - create nested directories with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
const content = new TextEncoder().encode("Nested file");
// Always use forward slashes in API
await fsOps.write("dir1/dir2/test.txt", content);
try {
const content = new TextEncoder().encode("Nested file");
// Always use forward slashes in API
await fsOps.write("dir1/dir2/test.txt", content);
const readContent = await fsOps.read("dir1/dir2/test.txt");
assert.equal(new TextDecoder().decode(readContent), "Nested file");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
const readContent = await fsOps.read("dir1/dir2/test.txt");
assert.equal(new TextDecoder().decode(readContent), "Nested file");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - exists with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
assert.equal(await fsOps.exists("test.txt"), false);
try {
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);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
assert.equal(await fsOps.exists("test.txt"), true);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - delete with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
await fsOps.write("test.txt", new TextEncoder().encode("test"));
assert.equal(await fsOps.exists("test.txt"), true);
try {
await fsOps.write("test.txt", new TextEncoder().encode("test"));
assert.equal(await fsOps.exists("test.txt"), true);
await fsOps.delete("test.txt");
assert.equal(await fsOps.exists("test.txt"), false);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
await fsOps.delete("test.txt");
assert.equal(await fsOps.exists("test.txt"), false);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - rename with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
const content = new TextEncoder().encode("test content");
await fsOps.write("old.txt", content);
try {
const content = new TextEncoder().encode("test 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("new.txt"), true);
assert.equal(await fsOps.exists("old.txt"), false);
assert.equal(await fsOps.exists("new.txt"), true);
const readContent = await fsOps.read("new.txt");
assert.equal(new TextDecoder().decode(readContent), "test content");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
const readContent = await fsOps.read("new.txt");
assert.equal(new TextDecoder().decode(readContent), "test content");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - rename to nested path with forward slashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
const content = new TextEncoder().encode("test content");
await fsOps.write("old.txt", content);
try {
const content = new TextEncoder().encode("test 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("dir1/dir2/new.txt"), true);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
assert.equal(await fsOps.exists("old.txt"), false);
assert.equal(await fsOps.exists("dir1/dir2/new.txt"), true);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - getFileSize", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
const content = new TextEncoder().encode("Hello!");
await fsOps.write("test.txt", content);
try {
const content = new TextEncoder().encode("Hello!");
await fsOps.write("test.txt", content);
const size = await fsOps.getFileSize("test.txt");
assert.equal(size, content.length);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
const size = await fsOps.getFileSize("test.txt");
assert.equal(size, content.length);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - atomicUpdateText", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
await fsOps.write("test.txt", new TextEncoder().encode("Hello"));
try {
await fsOps.write("test.txt", new TextEncoder().encode("Hello"));
const result = await fsOps.atomicUpdateText("test.txt", (current) => ({
text: current.text + " World",
cursors: []
}));
const result = await fsOps.atomicUpdateText("test.txt", (current) => ({
text: current.text + " World",
cursors: []
}));
assert.equal(result, "Hello World");
assert.equal(result, "Hello World");
const content = await fsOps.read("test.txt");
assert.equal(new TextDecoder().decode(content), "Hello World");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
const content = await fsOps.read("test.txt");
assert.equal(new TextDecoder().decode(content), "Hello World");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
test("NodeFileSystemOperations - handles paths with forward slashes on all platforms", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
const fsOps = new NodeFileSystemOperations(tempDir);
try {
// API should always accept forward slashes
const testPath = "deep/nested/directory/file.txt";
const content = new TextEncoder().encode("test");
try {
// API should always accept forward slashes
const testPath = "deep/nested/directory/file.txt";
const content = new TextEncoder().encode("test");
await fsOps.write(testPath, content);
assert.equal(await fsOps.exists(testPath), true);
await fsOps.write(testPath, content);
assert.equal(await fsOps.exists(testPath), true);
const readContent = await fsOps.read(testPath);
assert.equal(new TextDecoder().decode(readContent), "test");
const readContent = await fsOps.read(testPath);
assert.equal(new TextDecoder().decode(readContent), "test");
await fsOps.delete(testPath);
assert.equal(await fsOps.exists(testPath), false);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
await fsOps.delete(testPath);
assert.equal(await fsOps.exists(testPath), false);
} finally {
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 * as path from "path";
import type {
FileSystemOperations,
RelativePath,
TextWithCursors
FileSystemOperations,
RelativePath,
TextWithCursors
} from "sync-client";
export class NodeFileSystemOperations implements FileSystemOperations {
public constructor(private readonly basePath: string) {}
public constructor(private readonly basePath: string) {}
public async listFilesRecursively(
directory: RelativePath | undefined
): Promise<RelativePath[]> {
const files: RelativePath[] = [];
await this.walkDirectory(
directory !== undefined ? this.toNativePath(directory) : "",
files
);
return files;
}
public async listFilesRecursively(
directory: RelativePath | undefined
): Promise<RelativePath[]> {
const files: RelativePath[] = [];
await this.walkDirectory(
directory !== undefined ? this.toNativePath(directory) : "",
files
);
return files;
}
public async read(relativePath: RelativePath): Promise<Uint8Array> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
return await fs.readFile(fullPath);
} catch (error) {
throw new Error(
`Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async read(relativePath: RelativePath): Promise<Uint8Array> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
return await fs.readFile(fullPath);
} catch (error) {
throw new Error(
`Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async write(
relativePath: RelativePath,
content: Uint8Array
): Promise<void> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
const dir = path.dirname(fullPath);
public async write(
relativePath: RelativePath,
content: Uint8Array
): Promise<void> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
const dir = path.dirname(fullPath);
try {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(fullPath, content);
} catch (error) {
throw new Error(
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
try {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(fullPath, content);
} catch (error) {
throw new Error(
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async atomicUpdateText(
relativePath: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
public async atomicUpdateText(
relativePath: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
const currentContent = await fs.readFile(fullPath, "utf-8");
const result = updater({ text: currentContent, cursors: [] });
await fs.writeFile(fullPath, result.text, "utf-8");
return result.text;
} catch (error) {
throw new Error(
`Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
try {
const currentContent = await fs.readFile(fullPath, "utf-8");
const result = updater({ text: currentContent, cursors: [] });
await fs.writeFile(fullPath, result.text, "utf-8");
return result.text;
} catch (error) {
throw new Error(
`Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async getFileSize(relativePath: RelativePath): Promise<number> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
const stats = await fs.stat(fullPath);
return stats.size;
} catch (error) {
throw new Error(
`Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async getFileSize(relativePath: RelativePath): Promise<number> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
const stats = await fs.stat(fullPath);
return stats.size;
} catch (error) {
throw new Error(
`Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async exists(relativePath: RelativePath): Promise<boolean> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
await fs.access(fullPath);
return true;
} catch {
return false;
}
}
public async exists(relativePath: RelativePath): Promise<boolean> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
await fs.access(fullPath);
return true;
} catch {
return false;
}
}
public async createDirectory(relativePath: RelativePath): Promise<void> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
await fs.mkdir(fullPath, { recursive: false });
} catch (error) {
throw new Error(
`Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async createDirectory(relativePath: RelativePath): Promise<void> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
await fs.mkdir(fullPath, { recursive: false });
} catch (error) {
throw new Error(
`Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async delete(relativePath: RelativePath): Promise<void> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
await fs.unlink(fullPath);
} catch (error) {
throw new Error(
`Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async delete(relativePath: RelativePath): Promise<void> {
const fullPath = path.join(
this.basePath,
this.toNativePath(relativePath)
);
try {
await fs.unlink(fullPath);
} catch (error) {
throw new Error(
`Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
const oldFullPath = path.join(
this.basePath,
this.toNativePath(oldPath)
);
const newFullPath = path.join(
this.basePath,
this.toNativePath(newPath)
);
const newDir = path.dirname(newFullPath);
public async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
const oldFullPath = path.join(
this.basePath,
this.toNativePath(oldPath)
);
const newFullPath = path.join(
this.basePath,
this.toNativePath(newPath)
);
const newDir = path.dirname(newFullPath);
try {
await fs.mkdir(newDir, { recursive: true });
await fs.rename(oldFullPath, newFullPath);
} catch (error) {
throw new Error(
`Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
try {
await fs.mkdir(newDir, { recursive: true });
await fs.rename(oldFullPath, newFullPath);
} catch (error) {
throw new Error(
`Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async walkDirectory(
relativePath: string,
files: RelativePath[]
): Promise<void> {
const fullPath = path.join(this.basePath, relativePath);
let entries: Dirent[] = [];
private async walkDirectory(
relativePath: string,
files: RelativePath[]
): Promise<void> {
const fullPath = path.join(this.basePath, relativePath);
let entries: Dirent[] = [];
try {
entries = await fs.readdir(fullPath, { withFileTypes: true });
} catch (error) {
throw new Error(
`Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
try {
entries = await fs.readdir(fullPath, { withFileTypes: true });
} catch (error) {
throw new Error(
`Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
for (const entry of entries) {
const entryName = entry.name;
const entryRelativePath = path.join(relativePath, entryName);
for (const entry of entries) {
const entryName = entry.name;
const entryRelativePath = path.join(relativePath, entryName);
if (entry.isDirectory()) {
await this.walkDirectory(entryRelativePath, files);
} else if (entry.isFile()) {
// Always return forward slashes
files.push(this.toUnixPath(entryRelativePath));
}
}
}
if (entry.isDirectory()) {
await this.walkDirectory(entryRelativePath, files);
} else if (entry.isFile()) {
// Always return forward slashes
files.push(this.toUnixPath(entryRelativePath));
}
}
}
/**
* Convert a forward-slash path to native platform path separators
*/
private toNativePath(relativePath: string): string {
if (path.sep === "\\") {
return relativePath.replace(/\//g, "\\");
}
return relativePath;
}
/**
* Convert a forward-slash path to native platform path separators
*/
private toNativePath(relativePath: string): string {
if (path.sep === "\\") {
return relativePath.replace(/\//g, "\\");
}
return relativePath;
}
/**
* Convert a native platform path to forward slashes
*/
private toUnixPath(nativePath: string): string {
if (path.sep === "\\") {
return nativePath.replace(/\\/g, "/");
}
return nativePath;
}
/**
* Convert a native platform path to forward slashes
*/
private toUnixPath(nativePath: string): string {
if (path.sep === "\\") {
return nativePath.replace(/\\/g, "/");
}
return nativePath;
}
}

View file

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

View file

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

View file

@ -0,0 +1 @@

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,16 +2,16 @@ import type { Editor } from "obsidian";
import { utils } from "sync-client";
export interface Selection {
id: number;
start: number;
end: number;
id: number;
start: number;
end: number;
}
export function getSelectionsFromEditor(editor: Editor): Selection[] {
const text = editor.getValue();
return editor.listSelections().map(({ anchor, head }, i) => ({
id: i,
start: utils.lineAndColumnToPosition(text, anchor.line, anchor.ch),
end: utils.lineAndColumnToPosition(text, head.line, head.ch)
}));
const text = editor.getValue();
return editor.listSelections().map(({ anchor, head }, i) => ({
id: i,
start: utils.lineAndColumnToPosition(text, anchor.line, anchor.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";
export class LocalCursorUpdateListener {
private static readonly UPDATE_INTERVAL_MS = 50;
private readonly eventHandle: NodeJS.Timeout;
private static readonly UPDATE_INTERVAL_MS = 50;
private readonly eventHandle: NodeJS.Timeout;
public constructor(
private readonly client: SyncClient,
private readonly workspace: Workspace
) {
this.eventHandle = setInterval(() => {
this.updateAllSelections();
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
}
public constructor(
private readonly client: SyncClient,
private readonly workspace: Workspace
) {
this.eventHandle = setInterval(() => {
this.updateAllSelections();
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
}
public dispose(): void {
clearInterval(this.eventHandle);
}
public dispose(): void {
clearInterval(this.eventHandle);
}
private updateAllSelections(): void {
const currentCursors = this.getAllSelections();
this.client
.updateLocalCursors(currentCursors)
.catch((error: unknown) => {
this.client.logger.error(
`Failed to update local cursors: ${error}`
);
});
}
private updateAllSelections(): void {
const currentCursors = this.getAllSelections();
this.client
.updateLocalCursors(currentCursors)
.catch((error: unknown) => {
this.client.logger.error(
`Failed to update local cursors: ${error}`
);
});
}
private getAllSelections(): Record<string, Selection[]> {
const cursors: Record<string, Selection[]> = {};
this.workspace
.getLeavesOfType("markdown")
.map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView)
.forEach((view) => {
const { file } = view;
if (!file) {
return;
}
cursors[file.path] = getSelectionsFromEditor(view.editor);
});
return cursors;
}
private getAllSelections(): Record<string, Selection[]> {
const cursors: Record<string, Selection[]> = {};
this.workspace
.getLeavesOfType("markdown")
.map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView)
.forEach((view) => {
const { file } = view;
if (!file) {
return;
}
cursors[file.path] = getSelectionsFromEditor(view.editor);
});
return cursors;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,74 +1,74 @@
.logs-view {
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
.verbosity-selector {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: normal;
gap: var(--size-4-2);
margin: var(--size-4-4) var(--size-4-2);
.verbosity-selector {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: normal;
gap: var(--size-4-2);
margin: var(--size-4-4) var(--size-4-2);
h4 {
margin: 0;
}
h4 {
margin: 0;
}
.logs-controls {
display: flex;
align-items: center;
gap: var(--size-4-2);
.logs-controls {
display: flex;
align-items: center;
gap: var(--size-4-2);
button {
display: flex;
align-items: center;
gap: var(--size-2-1);
padding: var(--size-2-2) var(--size-4-2);
cursor: pointer;
}
button {
display: flex;
align-items: center;
gap: var(--size-2-1);
padding: var(--size-2-2) var(--size-4-2);
cursor: pointer;
}
select {
cursor: pointer;
}
}
}
select {
cursor: pointer;
}
}
}
.logs-container {
max-width: 100%;
overflow-y: auto;
.logs-container {
max-width: 100%;
overflow-y: auto;
.log-message {
font: var(--font-monospace);
margin-bottom: var(--size-2-1);
overflow-wrap: break-word;
white-space: pre-wrap;
user-select: all;
.log-message {
font: var(--font-monospace);
margin-bottom: var(--size-2-1);
overflow-wrap: break-word;
white-space: pre-wrap;
user-select: all;
.timestamp {
padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s);
background-color: var(--color-base-30);
font-size: var(--font-ui-small);
font-family: var(--font-monospace);
font-weight: var(--bold-weight);
margin-right: var(--size-4-1);
}
.timestamp {
padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s);
background-color: var(--color-base-30);
font-size: var(--font-ui-small);
font-family: var(--font-monospace);
font-weight: var(--bold-weight);
margin-right: var(--size-4-1);
}
&.DEBUG {
color: var(--color-base-50);
}
&.DEBUG {
color: var(--color-base-50);
}
&.INFO {
color: var(--color-base-100);
}
&.INFO {
color: var(--color-base-100);
}
&.WARNING {
color: rgb(var(--color-yellow-rgb));
}
&.WARNING {
color: rgb(var(--color-yellow-rgb));
}
&.ERROR {
color: rgb(var(--color-red-rgb));
}
}
}
&.ERROR {
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";
export class LogsView extends ItemView {
public static readonly TYPE = "logs-view";
public static readonly ICON = "logs";
public static readonly TYPE = "logs-view";
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 readonly logLineToElement = new Map<LogLine, HTMLElement>();
private minLogLevel: LogLevel = LogLevel.INFO;
private logsContainer: HTMLElement | undefined;
private readonly logLineToElement = new Map<LogLine, HTMLElement>();
private minLogLevel: LogLevel = LogLevel.INFO;
public constructor(
private readonly client: SyncClient,
leaf: WorkspaceLeaf
) {
super(leaf);
this.icon = LogsView.ICON;
this.client.logger.addOnMessageListener(() => {
this.updateView();
});
}
public constructor(
private readonly client: SyncClient,
leaf: WorkspaceLeaf
) {
super(leaf);
this.icon = LogsView.ICON;
this.client.logger.onLogEmitted.add(() => {
this.updateView();
});
}
private static createLogLineElement(
container: HTMLElement,
logLine: LogLine
): HTMLElement {
return container.createDiv(
{
cls: ["log-message", logLine.level]
},
(messageContainer) => {
messageContainer.createEl("span", {
text: LogsView.formatTimestamp(logLine.timestamp),
cls: "timestamp"
});
messageContainer.createEl("span", {
text: logLine.message
});
}
);
}
private static createLogLineElement(
container: HTMLElement,
logLine: LogLine
): HTMLElement {
return container.createDiv(
{
cls: ["log-message", logLine.level]
},
(messageContainer) => {
messageContainer.createEl("span", {
text: LogsView.formatTimestamp(logLine.timestamp),
cls: "timestamp"
});
messageContainer.createEl("span", {
text: logLine.message
});
}
);
}
private static formatTimestamp(timestamp: Date): string {
return timestamp.toTimeString().split(" ")[0];
}
private static formatTimestamp(timestamp: Date): string {
return timestamp.toTimeString().split(" ")[0];
}
public getViewType(): string {
return LogsView.TYPE;
}
public getViewType(): string {
return LogsView.TYPE;
}
public getDisplayText(): string {
return "VaultLink logs";
}
public getDisplayText(): string {
return "VaultLink logs";
}
public async onOpen(): Promise<void> {
const container = this.containerEl.children[1];
container.addClass("logs-view");
public async onOpen(): Promise<void> {
const container = this.containerEl.children[1];
container.addClass("logs-view");
const logLevels = [
{ label: "Debug", value: LogLevel.DEBUG },
{ label: "Info", value: LogLevel.INFO },
{ label: "Warn", value: LogLevel.WARNING },
{ label: "Error", value: LogLevel.ERROR }
];
const logLevels = [
{ label: "Debug", value: LogLevel.DEBUG },
{ label: "Info", value: LogLevel.INFO },
{ label: "Warn", value: LogLevel.WARNING },
{ label: "Error", value: LogLevel.ERROR }
];
container.createDiv(
{
cls: "verbosity-selector"
},
(verbositySection) => {
verbositySection.createEl("h4", {
text: "VaultLink logs"
});
container.createDiv(
{
cls: "verbosity-selector"
},
(verbositySection) => {
verbositySection.createEl("h4", {
text: "VaultLink logs"
});
const controls = verbositySection.createDiv({
cls: "logs-controls"
});
const controls = verbositySection.createDiv({
cls: "logs-controls"
});
const copyButton = controls.createEl("button", {
text: "Copy logs",
cls: "clickable-icon"
});
setIcon(copyButton, "clipboard-copy");
copyButton.addEventListener("click", () => {
this.copyLogsToClipboard();
});
const copyButton = controls.createEl("button", {
text: "Copy logs",
cls: "clickable-icon"
});
setIcon(copyButton, "clipboard-copy");
copyButton.addEventListener("click", () => {
this.copyLogsToClipboard();
});
controls.createEl("select", {}, (dropdown) => {
logLevels.forEach(({ label, value }) =>
dropdown.createEl("option", { text: label, value })
);
controls.createEl("select", {}, (dropdown) => {
logLevels.forEach(({ label, value }) =>
dropdown.createEl("option", { text: label, value })
);
dropdown.value = this.minLogLevel;
dropdown.value = this.minLogLevel;
dropdown.addEventListener("change", () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
this.minLogLevel = dropdown.value as LogLevel;
dropdown.addEventListener("change", () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
this.minLogLevel = dropdown.value as LogLevel;
this.logsContainer?.empty();
this.logLineToElement.clear();
this.updateView();
});
});
}
);
this.logsContainer?.empty();
this.logLineToElement.clear();
this.updateView();
});
});
}
);
this.logsContainer = container.createDiv({ cls: "logs-container" });
this.logsContainer = container.createDiv({ cls: "logs-container" });
this.updateView();
}
this.updateView();
}
private copyLogsToClipboard(): void {
const logs = this.client.logger.getMessages(this.minLogLevel);
private copyLogsToClipboard(): void {
const logs = this.client.logger.getMessages(this.minLogLevel);
if (logs.length === 0) {
new Notice("No logs to copy");
return;
}
if (logs.length === 0) {
new Notice("No logs to copy");
return;
}
const formattedLogs = logs
.map((logLine) => {
const timestamp = logLine.timestamp.toLocaleString();
const level = logLine.level.toUpperCase();
return `[${timestamp}] ${level}: ${logLine.message}`;
})
.join("\n");
const formattedLogs = logs
.map((logLine) => {
const timestamp = logLine.timestamp.toLocaleString();
const level = logLine.level.toUpperCase();
return `[${timestamp}] ${level}: ${logLine.message}`;
})
.join("\n");
navigator.clipboard
.writeText(formattedLogs)
.then(() => {
new Notice(`Copied ${logs.length} log entries to clipboard`);
})
.catch((error: unknown) => {
this.client.logger.error(
`Failed to copy logs to clipboard: ${error}`
);
new Notice("Failed to copy logs to clipboard");
});
}
navigator.clipboard
.writeText(formattedLogs)
.then(() => {
new Notice(`Copied ${logs.length} log entries to clipboard`);
})
.catch((error: unknown) => {
this.client.logger.error(
`Failed to copy logs to clipboard: ${error}`
);
new Notice("Failed to copy logs to clipboard");
});
}
private updateView(): void {
const container = this.logsContainer;
if (container === undefined) {
return;
}
private updateView(): void {
const container = this.logsContainer;
if (container === undefined) {
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) {
// Clear the "No logs available yet" message
container.empty();
}
if (this.logLineToElement.size === 0 && logs.length > 0) {
// Clear the "No logs available yet" message
container.empty();
}
const shouldScroll =
container.scrollTop == 0 ||
container.scrollHeight -
container.clientHeight -
container.scrollTop <
LogsView.MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX;
const shouldScroll =
container.scrollTop == 0 ||
container.scrollHeight -
container.clientHeight -
container.scrollTop <
LogsView.MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX;
logs.forEach((message) => {
if (this.logLineToElement.has(message)) {
return;
}
logs.forEach((message) => {
if (this.logLineToElement.has(message)) {
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);
for (const [logLine, element] of this.logLineToElement) {
if (!newLines.has(logLine)) {
element.remove();
this.logLineToElement.delete(logLine);
}
}
const newLines = new Set(logs);
for (const [logLine, element] of this.logLineToElement) {
if (!newLines.has(logLine)) {
element.remove();
this.logLineToElement.delete(logLine);
}
}
if (logs.length === 0) {
container.empty();
container.createEl("p", {
text: "No logs available yet."
});
} else if (shouldScroll) {
container.scrollTop = container.scrollHeight;
}
}
if (logs.length === 0) {
container.empty();
container.createEl("p", {
text: "No logs available yet."
});
} else if (shouldScroll) {
container.scrollTop = container.scrollHeight;
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,114 +4,114 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const fs = require("fs-extra");
module.exports = (env, argv) => ({
devtool: argv.mode === "development" ? "inline-source-map" : false,
entry: {
index: "./src/vault-link-plugin.ts"
},
watchOptions: {
ignored: "**/node_modules"
},
externals: {
obsidian: "commonjs obsidian",
electron: "commonjs electron",
"@codemirror/autocomplete": "commonjs @codemirror/autocomplete",
"@codemirror/collab": "commonjs @codemirror/collab",
"@codemirror/commands": "commonjs @codemirror/commands",
"@codemirror/language": "commonjs @codemirror/language",
"@codemirror/lint": "commonjs @codemirror/lint",
"@codemirror/search": "commonjs @codemirror/search",
"@codemirror/state": "commonjs @codemirror/state",
"@codemirror/view": "commonjs @codemirror/view"
},
optimization: {
minimizer: [
new TerserPlugin({
terserOptions: {
module: true
}
})
]
},
plugins: [
new MiniCssExtractPlugin({
filename: "styles.css"
}),
{
apply: (compiler) => {
if (argv.mode !== "development") {
return;
}
devtool: argv.mode === "development" ? "inline-source-map" : false,
entry: {
index: "./src/vault-link-plugin.ts"
},
watchOptions: {
ignored: "**/node_modules"
},
externals: {
obsidian: "commonjs obsidian",
electron: "commonjs electron",
"@codemirror/autocomplete": "commonjs @codemirror/autocomplete",
"@codemirror/collab": "commonjs @codemirror/collab",
"@codemirror/commands": "commonjs @codemirror/commands",
"@codemirror/language": "commonjs @codemirror/language",
"@codemirror/lint": "commonjs @codemirror/lint",
"@codemirror/search": "commonjs @codemirror/search",
"@codemirror/state": "commonjs @codemirror/state",
"@codemirror/view": "commonjs @codemirror/view"
},
optimization: {
minimizer: [
new TerserPlugin({
terserOptions: {
module: true
}
})
]
},
plugins: [
new MiniCssExtractPlugin({
filename: "styles.css"
}),
{
apply: (compiler) => {
if (argv.mode !== "development") {
return;
}
compiler.hooks.done.tap("Copy Files Plugin", (stats) => {
const source = path.resolve(__dirname, "dist");
const destinations = [
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link",
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
];
destinations.forEach((destination) => {
fs.copy(source, destination)
.then(() =>
console.log(
"Files copied successfully after build!"
)
)
.catch((err) =>
console.error("Error copying files:", err)
);
compiler.hooks.done.tap("Copy Files Plugin", (stats) => {
const source = path.resolve(__dirname, "dist");
const destinations = [
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link",
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
];
destinations.forEach((destination) => {
fs.copy(source, destination)
.then(() =>
console.log(
"Files copied successfully after build!"
)
)
.catch((err) =>
console.error("Error copying files:", err)
);
fs.createFile(path.join(destination, ".hotreload"));
});
});
}
}
],
module: {
rules: [
{
test: /\.json$/i,
type: "asset/resource",
generator: {
filename: "[name][ext]"
}
},
{
test: /\.scss$/i,
use: [
MiniCssExtractPlugin.loader,
"css-loader",
"resolve-url-loader",
{
loader: "sass-loader",
options: {
sourceMap: true // required by resolve-url-loader
}
}
]
},
{
test: /\.ts$/,
use: ["ts-loader"]
}
]
},
resolve: {
extensions: [
".ts",
".js" // required for development
],
alias: {
root: __dirname,
src: path.resolve(__dirname, "src")
}
},
output: {
clean: true,
filename: "main.js",
library: {
type: "commonjs" // required for Obsidian
},
path: path.resolve(__dirname, "dist"),
publicPath: ""
}
fs.createFile(path.join(destination, ".hotreload"));
});
});
}
}
],
module: {
rules: [
{
test: /\.json$/i,
type: "asset/resource",
generator: {
filename: "[name][ext]"
}
},
{
test: /\.scss$/i,
use: [
MiniCssExtractPlugin.loader,
"css-loader",
"resolve-url-loader",
{
loader: "sass-loader",
options: {
sourceMap: true // required by resolve-url-loader
}
}
]
},
{
test: /\.ts$/,
use: ["ts-loader"]
}
]
},
resolve: {
extensions: [
".ts",
".js" // required for development
],
alias: {
root: __dirname,
src: path.resolve(__dirname, "src")
}
},
output: {
clean: true,
filename: "main.js",
library: {
type: "commonjs" // required for Obsidian
},
path: path.resolve(__dirname, "dist"),
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",
"private": true,
"workspaces": [
"sync-client",
"obsidian-plugin",
"test-client",
"local-client-cli"
],
"prettier": {
"trailingComma": "none",
"tabWidth": 4,
"useTabs": true,
"endOfLine": "lf"
},
"scripts": {
"build": "npm run build --workspaces",
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
"test": "npm run test --workspaces",
"lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"",
"update": "ncu -u -ws"
},
"devDependencies": {
"concurrently": "^9.2.1",
"eslint": "9.38.0",
"eslint-plugin-unused-imports": "^4.1.4",
"npm-check-updates": "^19.1.1",
"prettier": "^3.6.2",
"typescript-eslint": "8.41.0"
}
"name": "my-workspace",
"private": true,
"workspaces": [
"sync-client",
"obsidian-plugin",
"test-client",
"local-client-cli"
],
"prettier": {
"trailingComma": "none",
"tabWidth": 4,
"useTabs": false,
"endOfLine": "lf"
},
"scripts": {
"build": "npm run build --workspaces",
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
"test": "npm run test --workspaces",
"lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"",
"update": "ncu -u -ws"
},
"devDependencies": {
"concurrently": "^9.2.1",
"eclint": "^2.8.1",
"eslint": "9.38.0",
"eslint-plugin-unused-imports": "^4.1.4",
"npm-check-updates": "^19.1.1",
"prettier": "^3.6.2",
"typescript-eslint": "8.41.0"
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

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.
*/
export class SafeFileSystemOperations implements FileSystemOperations {
private readonly locks: Locks<RelativePath>;
private readonly locks: Locks<RelativePath>;
public constructor(
private readonly fs: FileSystemOperations,
private readonly logger: Logger
) {
this.locks = new Locks(logger);
}
public constructor(
private readonly fs: FileSystemOperations,
private readonly logger: Logger
) {
this.locks = new Locks(logger);
}
public async listFilesRecursively(
root: RelativePath | undefined
): Promise<RelativePath[]> {
this.logger.debug("Listing all files");
const result = await this.fs.listFilesRecursively(root);
this.logger.debug(`Listed ${result.length} files`);
return result;
}
public async listFilesRecursively(
root: RelativePath | undefined
): Promise<RelativePath[]> {
this.logger.debug("Listing all files");
const result = await this.fs.listFilesRecursively(root);
this.logger.debug(`Listed ${result.length} files`);
return result;
}
public async read(path: RelativePath): Promise<Uint8Array> {
this.logger.debug(`Reading file '${path}'`);
return this.safeOperation(
path,
async () =>
this.locks.withLock(path, async () => this.fs.read(path)),
"read"
);
}
public async read(path: RelativePath): Promise<Uint8Array> {
this.logger.debug(`Reading file '${path}'`);
return this.safeOperation(
path,
async () =>
this.locks.withLock(path, async () => this.fs.read(path)),
"read"
);
}
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
this.logger.debug(`Writing to file '${path}'`);
return this.locks.withLock(path, async () =>
this.fs.write(path, content)
);
}
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
this.logger.debug(`Writing to file '${path}'`);
return this.locks.withLock(path, async () =>
this.fs.write(path, content)
);
}
public async atomicUpdateText(
path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
this.logger.debug(`Atomically updating file '${path}'`);
return this.safeOperation(
path,
async () =>
this.locks.withLock(path, async () =>
this.fs.atomicUpdateText(path, updater)
),
"atomicUpdateText"
);
}
public async atomicUpdateText(
path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
this.logger.debug(`Atomically updating file '${path}'`);
return this.safeOperation(
path,
async () =>
this.locks.withLock(path, async () =>
this.fs.atomicUpdateText(path, updater)
),
"atomicUpdateText"
);
}
public async getFileSize(path: RelativePath): Promise<number> {
// Logging this would be too noisy
return this.safeOperation(
path,
async () =>
this.locks.withLock(path, async () =>
this.fs.getFileSize(path)
),
"getFileSize"
);
}
public async getFileSize(path: RelativePath): Promise<number> {
// Logging this would be too noisy
return this.safeOperation(
path,
async () =>
this.locks.withLock(path, async () =>
this.fs.getFileSize(path)
),
"getFileSize"
);
}
public async exists(
path: RelativePath,
skipLock = false
): Promise<boolean> {
this.logger.debug(`Checking if file '${path}' exists`);
if (skipLock) {
return this.fs.exists(path);
} else {
return this.locks.withLock(path, async () => this.fs.exists(path));
}
}
public async exists(
path: RelativePath,
skipLock = false
): Promise<boolean> {
this.logger.debug(`Checking if file '${path}' exists`);
if (skipLock) {
return this.fs.exists(path);
} else {
return this.locks.withLock(path, async () => this.fs.exists(path));
}
}
public async createDirectory(path: RelativePath): Promise<void> {
this.logger.debug(`Creating directory '${path}'`);
return this.locks.withLock(path, async () =>
this.fs.createDirectory(path)
);
}
public async createDirectory(path: RelativePath): Promise<void> {
this.logger.debug(`Creating directory '${path}'`);
return this.locks.withLock(path, async () =>
this.fs.createDirectory(path)
);
}
public async delete(path: RelativePath): Promise<void> {
this.logger.debug(`Deleting file '${path}'`);
return this.locks.withLock(path, async () => this.fs.delete(path));
}
public async delete(path: RelativePath): Promise<void> {
this.logger.debug(`Deleting file '${path}'`);
return this.locks.withLock(path, async () => this.fs.delete(path));
}
public async rename(
oldPath: RelativePath,
newPath: RelativePath,
skipLock = false
): Promise<void> {
this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`);
return this.safeOperation(
oldPath,
async () => {
if (skipLock) {
return this.fs.rename(oldPath, newPath);
} else {
return this.locks.withLock([oldPath, newPath], async () =>
this.fs.rename(oldPath, newPath)
);
}
},
"rename"
);
}
public async rename(
oldPath: RelativePath,
newPath: RelativePath,
skipLock = false
): Promise<void> {
this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`);
return this.safeOperation(
oldPath,
async () => {
if (skipLock) {
return this.fs.rename(oldPath, newPath);
} else {
return this.locks.withLock([oldPath, newPath], async () =>
this.fs.rename(oldPath, newPath)
);
}
},
"rename"
);
}
public tryLock(path: RelativePath): boolean {
return this.locks.tryLock(path);
}
public tryLock(path: RelativePath): boolean {
return this.locks.tryLock(path);
}
public async waitForLock(path: RelativePath): Promise<void> {
return this.locks.waitForLock(path);
}
public async waitForLock(path: RelativePath): Promise<void> {
return this.locks.waitForLock(path);
}
public unlock(path: RelativePath): void {
this.locks.unlock(path);
}
public unlock(path: RelativePath): void {
this.locks.unlock(path);
}
public reset(): void {
this.locks.reset();
}
public reset(): void {
this.locks.reset();
}
/**
* 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
* a FileNotFoundError if it doesn't.
*/
private async safeOperation<T>(
path: RelativePath,
operation: () => Promise<T>,
operationName: string
): Promise<T> {
if (!(await this.fs.exists(path))) {
throw new FileNotFoundError(
`File not found before trying to ${operationName}`,
path
);
}
/**
* 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
* a FileNotFoundError if it doesn't.
*/
private async safeOperation<T>(
path: RelativePath,
operation: () => Promise<T>,
operationName: string
): Promise<T> {
if (!(await this.fs.exists(path))) {
throw new FileNotFoundError(
`File not found before trying to ${operationName}`,
path
);
}
try {
return await operation();
} catch (error) {
// 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
// recreated while `operation` is running.
if (await this.fs.exists(path)) {
throw error;
} else {
throw new FileNotFoundError(
`File not found when trying to ${operationName}`,
path
);
}
}
}
try {
return await operation();
} catch (error) {
// 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
// recreated while `operation` is running.
if (await this.fs.exists(path)) {
throw error;
} else {
throw new FileNotFoundError(
`File not found when trying to ${operationName}`,
path
);
}
}
}
}

View file

@ -5,17 +5,18 @@ import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory"
import { getRandomColor } from "./utils/get-random-color";
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
import { removeFromArray } from "./utils/remove-from-array";
export {
SyncType,
SyncStatus,
type HistoryStats,
type HistoryEntry,
type SyncDetails,
type SyncCreateDetails,
type SyncUpdateDetails,
type SyncMovedDetails,
type SyncDeleteDetails
SyncType,
SyncStatus,
type HistoryStats,
type HistoryEntry,
type SyncDetails,
type SyncCreateDetails,
type SyncUpdateDetails,
type SyncMovedDetails,
type SyncDeleteDetails
} from "./tracing/sync-history";
export { Logger, LogLevel, LogLine } from "./tracing/logger";
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 const debugging = {
slowFetchFactory,
slowWebSocketFactory,
logToConsole
slowFetchFactory,
slowWebSocketFactory,
logToConsole
};
export { globsToRegexes } from "./utils/globs-to-regexes";
export const utils = {
getRandomColor,
positionToLineAndColumn,
lineAndColumnToPosition,
awaitAll
getRandomColor,
positionToLineAndColumn,
lineAndColumnToPosition,
awaitAll,
removeFromArray
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
import type { DocumentWithCursors } from "./DocumentWithCursors";
export interface ClientCursors {
userName: string;
deviceId: string;
documentsWithCursors: DocumentWithCursors[];
userName: string;
deviceId: string;
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.
export interface CreateDocumentVersion {
/**
* 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,
* the server will generate one. If the client provides a document id
* it must not already exist in the database.
*/
document_id: string | null;
relative_path: string;
content: number[];
/**
* 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,
* the server will generate one. If the client provides a document id
* it must not already exist in the database.
*/
document_id: string | null;
relative_path: string;
content: number[];
}

View file

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

View file

@ -2,5 +2,5 @@
import type { ClientCursors } from "./ClientCursors";
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.
export interface CursorSpan {
start: number;
end: number;
start: 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.
export interface DeleteDocumentVersion {
relativePath: string;
relativePath: string;
}

View file

@ -6,5 +6,5 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
* Response to an update document request.
*/
export type DocumentUpdateResponse =
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
| ({ type: "MergingUpdate" } & DocumentVersion);
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
| ({ 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.
export interface DocumentVersion {
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
contentBase64: string;
isDeleted: boolean;
userId: string;
deviceId: string;
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
contentBase64: string;
isDeleted: boolean;
userId: 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.
export interface DocumentVersionWithoutContent {
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
isDeleted: boolean;
userId: string;
deviceId: string;
contentSize: number;
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
isDeleted: boolean;
userId: string;
deviceId: string;
contentSize: number;
}

View file

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

View file

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

View file

@ -4,22 +4,22 @@
* Response to a ping request.
*/
export interface PingResponse {
/**
* Semantic version of the server.
*/
serverVersion: string;
/**
* Whether the client is authenticated based on the sent Authorization
* header.
*/
isAuthenticated: boolean;
/**
* List of file extensions that are allowed to be merged.
*/
mergeableFileExtensions: string[];
/**
* API version ensuring backwards & forwards compatibility between the client
* and server.
*/
supportedApiVersion: number;
/**
* Semantic version of the server.
*/
serverVersion: string;
/**
* Whether the client is authenticated based on the sent Authorization
* header.
*/
isAuthenticated: boolean;
/**
* List of file extensions that are allowed to be merged.
*/
mergeableFileExtensions: string[];
/**
* API version ensuring backwards & forwards compatibility between the client
* and server.
*/
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.
export interface SerializedError {
errorType: string;
message: string;
causes: string[];
errorType: string;
message: 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.
export interface UpdateDocumentVersion {
parent_version_id: bigint;
relative_path: string;
content: number[];
parent_version_id: bigint;
relative_path: string;
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.
export interface UpdateTextDocumentVersion {
parentVersionId: number;
relativePath: string;
content: (number | string)[];
parentVersionId: number;
relativePath: string;
content: (number | string)[];
}

View file

@ -3,5 +3,5 @@ import type { CursorPositionFromClient } from "./CursorPositionFromClient";
import type { WebSocketHandshake } from "./WebSocketHandshake";
export type WebSocketClientMessage =
| ({ type: "handshake" } & WebSocketHandshake)
| ({ type: "cursorPositions" } & CursorPositionFromClient);
| ({ type: "handshake" } & WebSocketHandshake)
| ({ 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.
export interface WebSocketHandshake {
token: string;
deviceId: string;
lastSeenVaultUpdateId: number | null;
token: string;
deviceId: string;
lastSeenVaultUpdateId: number | null;
}

View file

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

View file

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

View file

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

View file

@ -6,296 +6,260 @@ import type { CursorPositionFromClient } from "./types/CursorPositionFromClient"
import type { ClientCursors } from "./types/ClientCursors";
import { createPromise } from "../utils/create-promise";
import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate";
import { awaitAll } from "../utils/await-all";
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 {
private readonly webSocketStatusChangeListeners: ((
isConnected: boolean
) => unknown)[] = [];
public readonly onWebSocketStatusChanged = new EventListeners<
(isConnected: boolean) => unknown
>();
private readonly remoteVaultUpdateListeners: ((
update: WebSocketVaultUpdate
) => Promise<void>)[] = [];
public readonly onRemoteVaultUpdateReceived = new EventListeners<
(update: WebSocketVaultUpdate) => Promise<void>
>();
private readonly remoteCursorsUpdateListeners: ((
cursors: ClientCursors[]
) => Promise<void>)[] = [];
public readonly onRemoteCursorsUpdateReceived = new EventListeners<
(cursors: ClientCursors[]) => Promise<void>
>();
private isStopped = true;
private resolveDisconnectingPromise: null | (() => unknown) = null;
private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined;
private isStopped = true;
private resolveDisconnectingPromise: null | (() => unknown) = null;
private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined;
private readonly outstandingPromises: Promise<unknown>[] = [];
private readonly outstandingPromises: Promise<unknown>[] = [];
private webSocket: WebSocket | undefined;
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket;
private webSocket: WebSocket | undefined;
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket;
public constructor(
private readonly deviceId: string,
private readonly logger: Logger,
private readonly settings: Settings,
webSocketImplementation?: typeof globalThis.WebSocket
) {
if (webSocketImplementation) {
this.webSocketFactoryImplementation = webSocketImplementation;
} else {
if (
typeof globalThis !== "undefined" &&
typeof globalThis.WebSocket === "undefined"
) {
// eslint-disable-next-line
this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js
} else {
this.webSocketFactoryImplementation = WebSocket;
}
}
}
public constructor(
private readonly deviceId: string,
private readonly logger: Logger,
private readonly settings: Settings,
webSocketImplementation?: typeof globalThis.WebSocket
) {
if (webSocketImplementation) {
this.webSocketFactoryImplementation = webSocketImplementation;
} else {
if (
typeof globalThis !== "undefined" &&
typeof globalThis.WebSocket === "undefined"
) {
// eslint-disable-next-line
this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js
} else {
this.webSocketFactoryImplementation = WebSocket;
}
}
}
public get isWebSocketConnected(): boolean {
return (
this.webSocket?.readyState ===
this.webSocketFactoryImplementation.OPEN
);
}
public get isWebSocketConnected(): boolean {
return (
this.webSocket?.readyState ===
this.webSocketFactoryImplementation.OPEN
);
}
public addWebSocketStatusChangeListener(
listener: (isConnected: boolean) => unknown
): void {
this.webSocketStatusChangeListeners.push(listener);
}
public start(): void {
this.isStopped = false;
this.initializeWebSocket();
}
public addRemoteCursorsUpdateListener(
listener: (cursors: ClientCursors[]) => Promise<void>
): void {
this.remoteCursorsUpdateListeners.push(listener);
}
public async stop(): Promise<void> {
const [promise, resolve] = createPromise();
this.resolveDisconnectingPromise = resolve;
public addRemoteVaultUpdateListener(
listener: (update: WebSocketVaultUpdate) => Promise<void>
): void {
this.remoteVaultUpdateListeners.push(listener);
}
this.isStopped = true;
public start(): void {
this.isStopped = false;
this.initializeWebSocket();
}
if (this.reconnectTimeoutId !== undefined) {
clearTimeout(this.reconnectTimeoutId);
this.reconnectTimeoutId = undefined;
}
public async stop(): Promise<void> {
const [promise, resolve] = createPromise();
this.resolveDisconnectingPromise = resolve;
this.webSocket?.close(1000, "WebSocketManager has been stopped");
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) {
clearTimeout(this.reconnectTimeoutId);
this.reconnectTimeoutId = undefined;
}
try {
while (this.isWebSocketConnected) {
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
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);
});
public async waitUntilFinished(): Promise<void> {
await awaitAll(this.outstandingPromises);
}
try {
while (this.isWebSocketConnected) {
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);
}
}
public sendHandshakeMessage(
message: WebSocketClientMessage & { type: "handshake" }
): void {
const { webSocket } = this;
if (!webSocket) {
throw new Error(
"WebSocket is not connected, cannot send handshake message"
);
}
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> {
await awaitAll(this.outstandingPromises);
}
public updateLocalCursors(cursorPositions: CursorPositionFromClient): void {
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(
message: WebSocketClientMessage & { type: "handshake" }
): void {
const { webSocket } = this;
if (!webSocket) {
throw new Error(
"WebSocket is not connected, cannot send handshake message"
);
}
const message: WebSocketClientMessage = {
type: "cursorPositions",
...cursorPositions
};
try {
webSocket.send(JSON.stringify(message));
} catch (error) {
this.logger.error(
`Failed to send handshake message: ${String(error)}`
);
throw error;
}
}
try {
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)}`
);
}
}
public updateLocalCursors(cursorPositions: CursorPositionFromClient): void {
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;
}
private initializeWebSocket(): void {
// 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 message: WebSocketClientMessage = {
type: "cursorPositions",
...cursorPositions
};
const wsUri = new URL(this.settings.getSettings().remoteUri);
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
wsUri.pathname = `/vaults/${this.settings.getSettings().vaultName}/ws`;
try {
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)}`
);
}
}
this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`);
private initializeWebSocket(): void {
// 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}`
);
}
}
this.webSocket = new this.webSocketFactoryImplementation(wsUri);
const wsUri = new URL(this.settings.getSettings().remoteUri);
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
wsUri.pathname = `/vaults/${this.settings.getSettings().vaultName}/ws`;
this.webSocket.onopen = (): void => {
this.logger.info("WebSocket connection opened");
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 => {
this.logger.info("WebSocket connection opened");
this.webSocketStatusChangeListeners.forEach((listener) =>
listener(true)
);
};
void this.outstandingPromises.push(messageHandlingPromise); // ignore the returned promise
} catch (error) {
this.logger.error(
`Error parsing WebSocket message: ${String(error)}`
);
}
};
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.onclose = (event): void => {
this.logger.warn(
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
);
this.onWebSocketStatusChanged.trigger(false);
// Track the message handling promise
const messageHandlingPromise = this.handleWebSocketMessage(
message
)
.catch((error: unknown) => {
this.logger.error(
`Error handling WebSocket message: ${String(error)}`
);
})
.finally(() => {
const index = this.outstandingPromises.indexOf(
messageHandlingPromise
);
if (index !== -1) {
void this.outstandingPromises.splice(index, 1); // ignore the returned promise
}
});
if (this.isStopped) {
this.resolveDisconnectingPromise?.();
this.resolveDisconnectingPromise = null;
} else {
this.reconnectTimeoutId = setTimeout(() => {
this.reconnectTimeoutId = undefined;
this.initializeWebSocket();
}, this.settings.getSettings().webSocketRetryIntervalMs);
}
};
}
void this.outstandingPromises.push(messageHandlingPromise); // ignore the returned promise
} catch (error) {
this.logger.error(
`Error parsing WebSocket message: ${String(error)}`
);
}
};
private async handleWebSocketMessage(
message: WebSocketServerMessage
): Promise<void> {
if (message.type === "vaultUpdate") {
await this.onRemoteVaultUpdateReceived.triggerAsync(message);
this.webSocket.onclose = (event): void => {
this.logger.warn(
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
);
this.webSocketStatusChangeListeners.forEach((listener) =>
listener(false)
);
// 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)}`
);
if (this.isStopped) {
this.resolveDisconnectingPromise?.();
this.resolveDisconnectingPromise = null;
} else {
this.reconnectTimeoutId = setTimeout(() => {
this.reconnectTimeoutId = undefined;
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)}`
);
}
}
await this.onRemoteCursorsUpdateReceived.triggerAsync(
message.clients
);
} 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 { DIFF_CACHE_SIZE_MB } from "./consts";
import { ServerConfig } from "./services/server-config";
import type { EventListeners } from "./utils/data-structures/event-listeners";
export class SyncClient {
private hasStartedOfflineSync = false;
private hasFinishedOfflineSync = false;
private hasStarted = false;
private hasBeenDestroyed = false;
private unloadTelemetry?: () => void;
private constructor(
private readonly history: SyncHistory,
private readonly settings: Settings,
private readonly database: Database,
private readonly syncer: Syncer,
private readonly webSocketManager: WebSocketManager,
public readonly logger: Logger,
private readonly fetchController: FetchController,
private readonly cursorTracker: CursorTracker,
private readonly fileChangeNotifier: FileChangeNotifier,
private readonly contentCache: FixedSizeDocumentCache,
private readonly fileOperations: FileOperations,
private readonly serverConfig: ServerConfig,
private readonly persistence: PersistenceProvider<
Partial<{
settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>;
}>
>
) {}
public get documentCount(): number {
return this.database.length;
}
public get isWebSocketConnected(): boolean {
return this.webSocketManager.isWebSocketConnected;
}
public static async create({
fs,
persistence,
fetch,
webSocket,
nativeLineEndings = "\n"
}: {
fs: FileSystemOperations;
persistence: PersistenceProvider<
Partial<{
settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>;
}>
>;
fetch?: typeof globalThis.fetch;
webSocket?: typeof globalThis.WebSocket;
nativeLineEndings?: string;
}): Promise<SyncClient> {
const logger = new Logger();
const deviceId = createClientId();
logger.info(`Creating SyncClient with client id ${deviceId}`);
const history = new SyncHistory(logger);
let state = (await persistence.load()) ?? {
settings: undefined,
database: undefined
};
const settings = new Settings(
logger,
state.settings,
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
// and (2) settings changes are infrequent enough that rate-limiting is not necessary
await persistence.save(state);
}
);
const rateLimitedSave = rateLimit(
persistence.save,
() => settings.getSettings().minimumSaveIntervalMs
);
const database = new Database(
logger,
state.database,
async (data): Promise<void> => {
state = { ...state, database: data };
await rateLimitedSave(state);
}
);
const fetchController = new FetchController(
settings.getSettings().isSyncEnabled,
logger
);
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) {
fetchController.canFetch = newSettings.isSyncEnabled;
}
});
const syncService = new SyncService(
deviceId,
fetchController,
settings,
logger,
fetch
);
const serverConfig = new ServerConfig(syncService);
const fileOperations = new FileOperations(
logger,
database,
fs,
serverConfig,
nativeLineEndings
);
const contentCache = new FixedSizeDocumentCache(
1024 * 1024 * DIFF_CACHE_SIZE_MB
);
const unrestrictedSyncer = new UnrestrictedSyncer(
logger,
database,
settings,
syncService,
fileOperations,
history,
contentCache,
serverConfig
);
const webSocketManager = new WebSocketManager(
deviceId,
logger,
settings,
webSocket
);
const syncer = new Syncer(
deviceId,
logger,
database,
settings,
syncService,
webSocketManager,
fileOperations,
unrestrictedSyncer
);
const fileChangeNotifier = new FileChangeNotifier();
const cursorTracker = new CursorTracker(
database,
webSocketManager,
fileOperations,
fileChangeNotifier
);
const client = new SyncClient(
history,
settings,
database,
syncer,
webSocketManager,
logger,
fetchController,
cursorTracker,
fileChangeNotifier,
contentCache,
fileOperations,
serverConfig,
persistence
);
logger.info("SyncClient created successfully");
return client;
}
public async start(): Promise<void> {
this.checkIfDestroyed("start");
if (this.hasStarted) {
throw new Error("SyncClient has already been started");
}
this.hasStarted = true;
if (
!this.unloadTelemetry &&
this.settings.getSettings().enableTelemetry
) {
this.unloadTelemetry = setUpTelemetry();
}
this.logger.addOnMessageListener((log): void => {
if (log.level === LogLevel.ERROR && Sentry.isInitialized()) {
Sentry.captureMessage(log.message);
}
});
this.settings.addOnSettingsChangeListener(
this.onSettingsChange.bind(this)
);
if (this.settings.getSettings().isSyncEnabled) {
this.logger.info("Starting SyncClient");
await this.startSyncing();
this.logger.info("SyncClient has successfully started");
}
}
/**
* Reload settings from disk overriding current in-memory settings.
* Missing values will be filled in from DEFAULT_SETTINGS rather than
* retaining current in-memory settings.
*/
public async reloadSettings(): Promise<void> {
this.checkIfDestroyed("reloadSettings");
const state = (await this.persistence.load()) ?? {
settings: undefined
};
const settings = {
...DEFAULT_SETTINGS,
...(state.settings ?? {})
};
await this.setSettings(settings);
}
public async checkConnection(): Promise<NetworkConnectionStatus> {
this.checkIfDestroyed("checkConnection");
const server = await this.serverConfig.checkConnection(true);
return {
isSuccessful: server.isSuccessful,
serverMessage: server.message,
isWebSocketConnected: this.webSocketManager.isWebSocketConnected
};
}
public getHistoryEntries(): readonly HistoryEntry[] {
return this.history.entries;
}
public addSyncHistoryUpdateListener(
listener: (stats: HistoryStats) => unknown
): void {
this.checkIfDestroyed("addSyncHistoryUpdateListener");
this.history.addSyncHistoryUpdateListener(listener);
}
/**
* 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.
*/
public async reset(): Promise<void> {
this.checkIfDestroyed("reset");
this.logger.info(
"Stopping SyncClient to apply changed connection settings"
);
await this.pause();
// clear all local state
this.logger.info("Resetting SyncClient's local state");
this.database.reset();
await this.database.save(); // ensure the new database reads as empty
this.resetInMemoryState();
this.hasStartedOfflineSync = false;
this.hasFinishedOfflineSync = false;
this.serverConfig.reset();
await this.startSyncing();
}
public getSettings(): SyncSettings {
return this.settings.getSettings();
}
public async setSetting<T extends keyof SyncSettings>(
key: T,
value: SyncSettings[T]
): Promise<void> {
this.checkIfDestroyed("setSetting");
await this.settings.setSetting(key, value);
}
public async setSettings(value: Partial<SyncSettings>): Promise<void> {
this.checkIfDestroyed("setSettings");
await this.settings.setSettings(value);
}
public addOnSettingsChangeListener(
listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown
): void {
this.checkIfDestroyed("addOnSettingsChangeListener");
this.settings.addOnSettingsChangeListener(listener);
}
public addRemainingSyncOperationsListener(
listener: (remainingOperations: number) => unknown
): void {
this.checkIfDestroyed("addRemainingSyncOperationsListener");
this.syncer.addRemainingOperationsListener(listener);
}
public addWebSocketStatusChangeListener(listener: () => unknown): void {
this.checkIfDestroyed("addWebSocketStatusChangeListener");
this.webSocketManager.addWebSocketStatusChangeListener(listener);
}
public async syncLocallyCreatedFile(
relativePath: RelativePath
): Promise<void> {
this.checkIfDestroyed("syncLocallyCreatedFile");
this.fileChangeNotifier.notifyOfFileChange(relativePath);
return this.syncer.syncLocallyCreatedFile(relativePath);
}
public async syncLocallyDeletedFile(
relativePath: RelativePath
): Promise<void> {
this.checkIfDestroyed("syncLocallyDeletedFile");
this.fileChangeNotifier.notifyOfFileChange(relativePath);
return this.syncer.syncLocallyDeletedFile(relativePath);
}
public async syncLocallyUpdatedFile({
oldPath,
relativePath
}: {
oldPath?: RelativePath;
relativePath: RelativePath;
}): Promise<void> {
this.checkIfDestroyed("syncLocallyUpdatedFile");
this.fileChangeNotifier.notifyOfFileChange(relativePath);
return this.syncer.syncLocallyUpdatedFile({
oldPath,
relativePath
});
}
public getDocumentSyncingStatus(
relativePath: RelativePath
): DocumentSyncStatus {
this.checkIfDestroyed("getDocumentSyncingStatus");
if (!this.settings.getSettings().isSyncEnabled) {
return DocumentSyncStatus.SYNCING_IS_DISABLED;
}
if (!this.syncer.isFirstSyncComplete || !this.hasFinishedOfflineSync) {
return DocumentSyncStatus.SYNCING;
}
const document =
this.database.getLatestDocumentByRelativePath(relativePath);
if (document === undefined) {
return DocumentSyncStatus.SYNCING;
}
return document.updates.length > 0
? DocumentSyncStatus.SYNCING
: DocumentSyncStatus.UP_TO_DATE;
}
public async updateLocalCursors(
documentToCursors: Record<RelativePath, CursorSpan[]>
): Promise<void> {
this.checkIfDestroyed("updateLocalCursors");
await this.cursorTracker.sendLocalCursorsToServer(documentToCursors);
}
public addRemoteCursorsUpdateListener(
listener: (cursors: MaybeOutdatedClientCursors[]) => unknown
): void {
this.checkIfDestroyed("addRemoteCursorsUpdateListener");
this.cursorTracker.addRemoteCursorsUpdateListener(listener);
}
public async waitUntilFinished(): Promise<void> {
this.checkIfDestroyed("waitUntilIdle");
await this.syncer.waitUntilFinished();
await this.webSocketManager.waitUntilFinished();
await this.database.save(); // flush all changes to disk
}
/**
* Completely destroy the SyncClient, cancelling all in-progress operations.
* After calling this method, the SyncClient cannot be used again.
*/
public async destroy(): Promise<void> {
this.checkIfDestroyed("destroy");
// cancel everything that's in progress
await this.pause();
this.hasBeenDestroyed = true;
this.resetInMemoryState();
this.logger.info("SyncClient has been successfully disposed");
this.unloadTelemetry?.();
}
private async startSyncing(): Promise<void> {
this.checkIfDestroyed("startSyncing");
this.fetchController.finishReset();
await this.serverConfig.initialize();
this.webSocketManager.start();
if (!this.hasStartedOfflineSync) {
this.hasStartedOfflineSync = true;
await this.syncer.scheduleSyncForOfflineChanges();
}
this.hasFinishedOfflineSync = true;
}
private async pause(): Promise<void> {
this.fetchController.startReset();
await this.webSocketManager.stop();
await this.waitUntilFinished();
}
private resetInMemoryState(): void {
this.history.reset();
this.contentCache.reset();
// don't reset the logger
this.cursorTracker.reset();
this.syncer.reset();
this.fileOperations.reset();
}
private async onSettingsChange(
newSettings: SyncSettings,
oldSettings: SyncSettings
): Promise<void> {
this.checkIfDestroyed("onSettingsChange");
if (
newSettings.vaultName !== oldSettings.vaultName ||
newSettings.remoteUri !== oldSettings.remoteUri
) {
await this.reset();
}
if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) {
if (newSettings.isSyncEnabled) {
await this.startSyncing();
} else {
await this.pause();
}
}
if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) {
this.contentCache.resize(newSettings.diffCacheSizeMB * 1024 * 1024);
}
if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) {
if (newSettings.enableTelemetry) {
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}`
);
}
}
private hasStartedOfflineSync = false;
private hasFinishedOfflineSync = false;
private hasStarted = false;
private hasBeenDestroyed = false;
private unloadTelemetry?: () => void;
private constructor(
private readonly history: SyncHistory,
private readonly settings: Settings,
private readonly database: Database,
private readonly syncer: Syncer,
private readonly webSocketManager: WebSocketManager,
public readonly logger: Logger,
private readonly fetchController: FetchController,
private readonly cursorTracker: CursorTracker,
private readonly fileChangeNotifier: FileChangeNotifier,
private readonly contentCache: FixedSizeDocumentCache,
private readonly fileOperations: FileOperations,
private readonly serverConfig: ServerConfig,
private readonly persistence: PersistenceProvider<
Partial<{
settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>;
}>
>
) {}
public get documentCount(): number {
return this.database.length;
}
public get isWebSocketConnected(): boolean {
return this.webSocketManager.isWebSocketConnected;
}
public get onSyncHistoryUpdated(): EventListeners<
(stats: HistoryStats) => unknown
> {
this.checkIfDestroyed("onSyncHistoryUpdated getter");
return this.history.onHistoryUpdated;
}
public get onSettingsChanged(): EventListeners<
(newSettings: SyncSettings, oldSettings: SyncSettings) => unknown
> {
this.checkIfDestroyed("onSettingsChanged getter");
return this.settings.onSettingsChanged;
}
public get onRemainingOperationsCountChanged(): EventListeners<
(remainingOperationsCount: number) => unknown
> {
this.checkIfDestroyed("onRemainingOperationsCountChanged getter");
return this.syncer.onRemainingOperationsCountChanged;
}
public get onWebSocketStatusChanged(): EventListeners<
(isConnected: boolean) => unknown
> {
this.checkIfDestroyed("onWebSocketStatusChanged getter");
return this.webSocketManager.onWebSocketStatusChanged;
}
public get onRemoteCursorsUpdated(): EventListeners<
(cursors: MaybeOutdatedClientCursors[]) => unknown
> {
this.checkIfDestroyed("onRemoteCursorsUpdated getter");
return this.cursorTracker.onRemoteCursorsUpdated;
}
public static async create({
fs,
persistence,
fetch,
webSocket,
nativeLineEndings = "\n"
}: {
fs: FileSystemOperations;
persistence: PersistenceProvider<
Partial<{
settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>;
}>
>;
fetch?: typeof globalThis.fetch;
webSocket?: typeof globalThis.WebSocket;
nativeLineEndings?: string;
}): Promise<SyncClient> {
const logger = new Logger();
const deviceId = createClientId();
logger.info(`Creating SyncClient with client id ${deviceId}`);
const history = new SyncHistory(logger);
let state = (await persistence.load()) ?? {
settings: undefined,
database: undefined
};
const settings = new Settings(
logger,
state.settings,
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
// and (2) settings changes are infrequent enough that rate-limiting is not necessary
await persistence.save(state);
}
);
const rateLimitedSave = rateLimit(
persistence.save,
() => settings.getSettings().minimumSaveIntervalMs
);
const database = new Database(
logger,
state.database,
async (data): Promise<void> => {
state = { ...state, database: data };
await rateLimitedSave(state);
}
);
const fetchController = new FetchController(
settings.getSettings().isSyncEnabled,
logger
);
settings.onSettingsChanged.add((newSettings, oldSettings) => {
if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) {
fetchController.canFetch = newSettings.isSyncEnabled;
}
});
const syncService = new SyncService(
deviceId,
fetchController,
settings,
logger,
fetch
);
const serverConfig = new ServerConfig(syncService);
const fileOperations = new FileOperations(
logger,
database,
fs,
serverConfig,
nativeLineEndings
);
const contentCache = new FixedSizeDocumentCache(
1024 * 1024 * DIFF_CACHE_SIZE_MB
);
const unrestrictedSyncer = new UnrestrictedSyncer(
logger,
database,
settings,
syncService,
fileOperations,
history,
contentCache,
serverConfig
);
const webSocketManager = new WebSocketManager(
deviceId,
logger,
settings,
webSocket
);
const syncer = new Syncer(
deviceId,
logger,
database,
settings,
syncService,
webSocketManager,
fileOperations,
unrestrictedSyncer
);
const fileChangeNotifier = new FileChangeNotifier();
const cursorTracker = new CursorTracker(
database,
webSocketManager,
fileOperations,
fileChangeNotifier
);
const client = new SyncClient(
history,
settings,
database,
syncer,
webSocketManager,
logger,
fetchController,
cursorTracker,
fileChangeNotifier,
contentCache,
fileOperations,
serverConfig,
persistence
);
logger.info("SyncClient created successfully");
return client;
}
public async start(): Promise<void> {
this.checkIfDestroyed("start");
if (this.hasStarted) {
throw new Error("SyncClient has already been started");
}
this.hasStarted = true;
if (
!this.unloadTelemetry &&
this.settings.getSettings().enableTelemetry
) {
this.unloadTelemetry = setUpTelemetry();
}
this.logger.onLogEmitted.add((log): void => {
if (log.level === LogLevel.ERROR && Sentry.isInitialized()) {
Sentry.captureMessage(log.message);
}
});
this.settings.onSettingsChanged.add(this.onSettingsChange.bind(this));
if (this.settings.getSettings().isSyncEnabled) {
this.logger.info("Starting SyncClient");
await this.startSyncing();
this.logger.info("SyncClient has successfully started");
}
}
/**
* Reload settings from disk overriding current in-memory settings.
* Missing values will be filled in from DEFAULT_SETTINGS rather than
* retaining current in-memory settings.
*/
public async reloadSettings(): Promise<void> {
this.checkIfDestroyed("reloadSettings");
const state = (await this.persistence.load()) ?? {
settings: undefined
};
const settings = {
...DEFAULT_SETTINGS,
...(state.settings ?? {})
};
await this.setSettings(settings);
}
public async checkConnection(): Promise<NetworkConnectionStatus> {
this.checkIfDestroyed("checkConnection");
const server = await this.serverConfig.checkConnection(true);
return {
isSuccessful: server.isSuccessful,
serverMessage: server.message,
isWebSocketConnected: this.webSocketManager.isWebSocketConnected
};
}
public getHistoryEntries(): readonly HistoryEntry[] {
return this.history.entries;
}
/**
* 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.
*/
public async reset(): Promise<void> {
this.checkIfDestroyed("reset");
this.logger.info(
"Stopping SyncClient to apply changed connection settings"
);
await this.pause();
// clear all local state
this.logger.info("Resetting SyncClient's local state");
this.database.reset();
await this.database.save(); // ensure the new database reads as empty
this.resetInMemoryState();
this.hasStartedOfflineSync = false;
this.hasFinishedOfflineSync = false;
this.serverConfig.reset();
await this.startSyncing();
}
public getSettings(): SyncSettings {
return this.settings.getSettings();
}
public async setSetting<T extends keyof SyncSettings>(
key: T,
value: SyncSettings[T]
): Promise<void> {
this.checkIfDestroyed("setSetting");
await this.settings.setSetting(key, value);
}
public async setSettings(value: Partial<SyncSettings>): Promise<void> {
this.checkIfDestroyed("setSettings");
await this.settings.setSettings(value);
}
public async syncLocallyCreatedFile(
relativePath: RelativePath
): Promise<void> {
this.checkIfDestroyed("syncLocallyCreatedFile");
this.fileChangeNotifier.notifyOfFileChange(relativePath);
return this.syncer.syncLocallyCreatedFile(relativePath);
}
public async syncLocallyDeletedFile(
relativePath: RelativePath
): Promise<void> {
this.checkIfDestroyed("syncLocallyDeletedFile");
this.fileChangeNotifier.notifyOfFileChange(relativePath);
return this.syncer.syncLocallyDeletedFile(relativePath);
}
public async syncLocallyUpdatedFile({
oldPath,
relativePath
}: {
oldPath?: RelativePath;
relativePath: RelativePath;
}): Promise<void> {
this.checkIfDestroyed("syncLocallyUpdatedFile");
this.fileChangeNotifier.notifyOfFileChange(relativePath);
return this.syncer.syncLocallyUpdatedFile({
oldPath,
relativePath
});
}
public getDocumentSyncingStatus(
relativePath: RelativePath
): DocumentSyncStatus {
this.checkIfDestroyed("getDocumentSyncingStatus");
if (!this.settings.getSettings().isSyncEnabled) {
return DocumentSyncStatus.SYNCING_IS_DISABLED;
}
if (!this.syncer.isFirstSyncComplete || !this.hasFinishedOfflineSync) {
return DocumentSyncStatus.SYNCING;
}
const document =
this.database.getLatestDocumentByRelativePath(relativePath);
if (document === undefined) {
return DocumentSyncStatus.SYNCING;
}
return document.updates.length > 0
? DocumentSyncStatus.SYNCING
: DocumentSyncStatus.UP_TO_DATE;
}
public async updateLocalCursors(
documentToCursors: Record<RelativePath, CursorSpan[]>
): Promise<void> {
this.checkIfDestroyed("updateLocalCursors");
await this.cursorTracker.sendLocalCursorsToServer(documentToCursors);
}
public getTrackedFilePaths(): RelativePath[] {
this.checkIfDestroyed("getTrackedFilePaths");
return this.database.resolvedDocuments
.filter((doc) => !doc.isDeleted && doc.metadata !== undefined)
.map((doc) => doc.relativePath);
}
public async getAllVaultFiles(): Promise<RelativePath[]> {
this.checkIfDestroyed("getAllVaultFiles");
return this.fileOperations.listFilesRecursively(undefined);
}
public async waitUntilFinished(): Promise<void> {
this.checkIfDestroyed("waitUntilIdle");
await this.syncer.waitUntilFinished();
await this.webSocketManager.waitUntilFinished();
await this.database.save(); // flush all changes to disk
}
/**
* Completely destroy the SyncClient, cancelling all in-progress operations.
* After calling this method, the SyncClient cannot be used again.
*/
public async destroy(): Promise<void> {
this.checkIfDestroyed("destroy");
// cancel everything that's in progress
await this.pause();
this.hasBeenDestroyed = true;
this.resetInMemoryState();
this.logger.info("SyncClient has been successfully disposed");
this.unloadTelemetry?.();
}
private async startSyncing(): Promise<void> {
this.checkIfDestroyed("startSyncing");
this.fetchController.finishReset();
await this.serverConfig.initialize();
this.webSocketManager.start();
if (!this.hasStartedOfflineSync) {
this.hasStartedOfflineSync = true;
await this.syncer.scheduleSyncForOfflineChanges();
}
this.hasFinishedOfflineSync = true;
}
private async pause(): Promise<void> {
this.fetchController.startReset();
await this.webSocketManager.stop();
await this.waitUntilFinished();
}
private resetInMemoryState(): void {
this.history.reset();
this.contentCache.reset();
// don't reset the logger
this.cursorTracker.reset();
this.syncer.reset();
this.fileOperations.reset();
}
private async onSettingsChange(
newSettings: SyncSettings,
oldSettings: SyncSettings
): Promise<void> {
this.checkIfDestroyed("onSettingsChange");
if (
newSettings.vaultName !== oldSettings.vaultName ||
newSettings.remoteUri !== oldSettings.remoteUri
) {
await this.reset();
}
if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) {
if (newSettings.isSyncEnabled) {
await this.startSyncing();
} else {
await this.pause();
}
}
if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) {
this.contentCache.resize(newSettings.diffCacheSizeMB * 1024 * 1024);
}
if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) {
if (newSettings.enableTelemetry) {
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 type { FileChangeNotifier } from "./file-change-notifier";
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
// 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
// not from the future.
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 & {
upToDateness: DocumentUpToDateness;
})[] = [];
private readonly updateLock = new Lock();
private lastLocalCursorState: DocumentWithCursors[] = [];
private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] =
[];
private knownRemoteCursors: (ClientCursors & {
upToDateness: DocumentUpToDateness;
})[] = [];
public constructor(
private readonly database: Database,
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)
);
private lastLocalCursorState: DocumentWithCursors[] = [];
private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] =
[];
for (const cursor of clientCursors.filter((client) =>
client.documentsWithCursors.every(
(doc) => doc.vault_update_id != null
)
)) {
updatedKnownRemoteCursors.push({
...cursor,
upToDateness:
await this.getDocumentsUpToDateness(cursor)
});
}
public constructor(
private readonly database: Database,
private readonly webSocketManager: WebSocketManager,
private readonly fileOperations: FileOperations,
private readonly fileChangeNotifier: FileChangeNotifier
) {
this.webSocketManager.onRemoteCursorsUpdateReceived.add(
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)
);
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.updateLock.withLock(async () => {
for (const clientCursor of this.knownRemoteCursors) {
if (
clientCursor.documentsWithCursors.some(
(document) =>
document.relative_path === relativePath
)
) {
clientCursor.upToDateness =
await this.getDocumentsUpToDateness(clientCursor);
}
}
})
);
}
this.knownRemoteCursors = updatedKnownRemoteCursors;
});
/// Update the local cursors for the given documents.
/// 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[] = [];
this.onRemoteCursorsUpdated.trigger(
this.getRelevantAndPruneKnownClientCursors()
);
}
);
for (const [relativePath, cursors] of Object.entries(
documentToCursors
)) {
const record =
this.database.getLatestDocumentByRelativePath(relativePath);
this.fileChangeNotifier.onFileChanged.add(async (relativePath) =>
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);
}
}
})
);
}
if (!record) {
continue; // Let's wait for the file to be created before sending cursors
}
/// Update the local cursors for the given documents.
/// 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) {
continue; // this is a new document, no need to sync the cursors
}
for (const [relativePath, cursors] of Object.entries(
documentToCursors
)) {
const record =
this.database.getLatestDocumentByRelativePath(relativePath);
documentsWithCursors.push({
relative_path: relativePath,
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 (!record) {
continue; // Let's wait for the file to be created before sending cursors
}
if (
JSON.stringify(this.lastLocalCursorState) ===
JSON.stringify(documentsWithCursors)
) {
// Caching step to avoid reading the edited files all the time
return;
}
this.lastLocalCursorState = documentsWithCursors;
if (!record.metadata) {
continue; // this is a new document, no need to sync the cursors
}
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;
}
}
documentsWithCursors.push({
relative_path: relativePath,
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 (
JSON.stringify(this.lastLocalCursorStateWithoutDirtyDocuments) ===
JSON.stringify(documentsWithCursors)
) {
return;
}
if (
JSON.stringify(this.lastLocalCursorState) ===
JSON.stringify(documentsWithCursors)
) {
// 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
// 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())
);
});
}
this.lastLocalCursorStateWithoutDirtyDocuments = documentsWithCursors;
public reset(): void {
this.knownRemoteCursors = [];
this.lastLocalCursorState = [];
this.lastLocalCursorStateWithoutDirtyDocuments = [];
this.updateLock.reset();
}
this.webSocketManager.updateLocalCursors({ documentsWithCursors });
}
private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] {
const result: MaybeOutdatedClientCursors[] = [];
const included = new Set<string>();
public reset(): void {
this.knownRemoteCursors = [];
this.lastLocalCursorState = [];
this.lastLocalCursorStateWithoutDirtyDocuments = [];
this.updateLock.reset();
}
const relevantCursors = [];
for (const clientCursors of [...this.knownRemoteCursors].reverse()) {
if (included.has(clientCursors.deviceId)) {
continue;
}
private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] {
const result: MaybeOutdatedClientCursors[] = [];
const included = new Set<string>();
if (clientCursors.upToDateness === DocumentUpToDateness.Later) {
continue;
}
const relevantCursors = [];
for (const clientCursors of [...this.knownRemoteCursors].reverse()) {
if (included.has(clientCursors.deviceId)) {
continue;
}
result.push({
...clientCursors,
isOutdated:
clientCursors.upToDateness === DocumentUpToDateness.Prior
});
if (clientCursors.upToDateness === DocumentUpToDateness.Later) {
continue;
}
included.add(clientCursors.deviceId);
relevantCursors.unshift(clientCursors); // to reverse order back to normal
}
result.push({
...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.
// 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));
}
return result;
}
if (
results.every((result) => result === DocumentUpToDateness.UpToDate)
) {
return DocumentUpToDateness.UpToDate;
}
// We store up-to-dateness on a per-client basis to simplify the implementation.
// 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 (
results.every(
(result) =>
result === DocumentUpToDateness.UpToDate ||
result === DocumentUpToDateness.Prior
)
) {
return DocumentUpToDateness.Prior;
}
if (
results.every((result) => result === DocumentUpToDateness.UpToDate)
) {
return DocumentUpToDateness.UpToDate;
}
return DocumentUpToDateness.Later;
}
if (
results.every(
(result) =>
result === DocumentUpToDateness.UpToDate ||
result === DocumentUpToDateness.Prior
)
) {
return DocumentUpToDateness.Prior;
}
private async getDocumentUpToDateness(
document: DocumentWithCursors
): Promise<DocumentUpToDateness> {
const record = this.database.getLatestDocumentByRelativePath(
document.relative_path
);
return DocumentUpToDateness.Later;
}
if (!record) {
// the document of the cursor must be from the future
return DocumentUpToDateness.Later;
}
private async getDocumentUpToDateness(
document: DocumentWithCursors
): Promise<DocumentUpToDateness> {
const record = this.database.getLatestDocumentByRelativePath(
document.relative_path
);
if (
(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;
}
if (!record) {
// the document of the cursor must be from the future
return DocumentUpToDateness.Later;
}
const currentContent = await this.fileOperations.read(
document.relative_path
);
if (
(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(
document.relative_path
)?.metadata?.hash === hash(currentContent)
? DocumentUpToDateness.UpToDate
: DocumentUpToDateness.Prior;
}
const currentContent = await this.fileOperations.read(
document.relative_path
);
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 { EventListeners } from "../utils/data-structures/event-listeners";
export class FileChangeNotifier {
private readonly listeners: ((filePath: RelativePath) => unknown)[] = [];
public readonly onFileChanged = new EventListeners<
(filePath: RelativePath) => unknown
>();
public addFileChangeListener(
listener: (filePath: RelativePath) => unknown
): 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));
}
public notifyOfFileChange(filePath: RelativePath): void {
this.onFileChanged.trigger(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 { EventListeners } from "../utils/data-structures/event-listeners";
export enum LogLevel {
DEBUG = "DEBUG",
INFO = "INFO",
WARNING = "WARNING",
ERROR = "ERROR"
DEBUG = "DEBUG",
INFO = "INFO",
WARNING = "WARNING",
ERROR = "ERROR"
}
const LOG_LEVEL_ORDER = {
[LogLevel.DEBUG]: 0,
[LogLevel.INFO]: 1,
[LogLevel.WARNING]: 2,
[LogLevel.ERROR]: 3
[LogLevel.DEBUG]: 0,
[LogLevel.INFO]: 1,
[LogLevel.WARNING]: 2,
[LogLevel.ERROR]: 3
};
export class LogLine {
public timestamp = new Date();
public constructor(
public level: LogLevel,
public message: string
) {}
public timestamp = new Date();
public constructor(
public level: LogLevel,
public message: string
) {}
}
export class Logger {
private readonly messages: LogLine[] = [];
private readonly onMessageListeners: ((message: LogLine) => unknown)[] = [];
public readonly onLogEmitted = new EventListeners<
(message: LogLine) => unknown
>();
public constructor(
...onMessageListeners: ((message: LogLine) => unknown)[]
) {
this.onMessageListeners = onMessageListeners;
}
private readonly messages: LogLine[] = [];
public debug(message: string): void {
this.pushMessage(message, LogLevel.DEBUG);
}
public debug(message: string): void {
this.pushMessage(message, LogLevel.DEBUG);
}
public info(message: string): void {
this.pushMessage(message, LogLevel.INFO);
}
public info(message: string): void {
this.pushMessage(message, LogLevel.INFO);
}
public warn(message: string): void {
this.pushMessage(message, LogLevel.WARNING);
}
public warn(message: string): void {
this.pushMessage(message, LogLevel.WARNING);
}
public error(message: string): void {
this.pushMessage(message, LogLevel.ERROR);
}
public error(message: string): void {
this.pushMessage(message, LogLevel.ERROR);
}
public getMessages(mininumSeverity: LogLevel): LogLine[] {
return this.messages.filter(
(message) =>
LOG_LEVEL_ORDER[message.level] >=
LOG_LEVEL_ORDER[mininumSeverity]
);
}
public getMessages(mininumSeverity: LogLevel): LogLine[] {
return this.messages.filter(
(message) =>
LOG_LEVEL_ORDER[message.level] >=
LOG_LEVEL_ORDER[mininumSeverity]
);
}
public addOnMessageListener(listener: (message: LogLine) => unknown): void {
this.onMessageListeners.push(listener);
}
public reset(): void {
this.messages.length = 0;
this.debug("Logger has been reset");
}
public removeOnMessageListener(
listener: (message: LogLine) => unknown
): void {
const index = this.onMessageListeners.indexOf(listener);
if (index !== -1) {
this.onMessageListeners.splice(index, 1);
}
}
private pushMessage(message: string, level: LogLevel): void {
const logLine = new LogLine(level, message);
this.messages.push(logLine);
public reset(): void {
this.messages.length = 0;
this.debug("Logger has been reset");
}
while (this.messages.length > MAX_LOG_MESSAGE_COUNT) {
this.messages.shift();
}
private pushMessage(message: string, level: LogLevel): void {
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);
});
}
this.onLogEmitted.trigger(logLine);
}
}

View file

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

View file

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

View file

@ -1,5 +1,5 @@
export enum DocumentUpToDateness {
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.
Later = "Later" // The cursors are from a future version of a document, there's no way we can accuratly show them locally.
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.
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";
export interface MaybeOutdatedClientCursors extends ClientCursors {
isOutdated: boolean;
isOutdated: boolean;
}

View file

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

View file

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

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