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

@ -3,7 +3,7 @@ import { defineConfig } from "vitepress"
export default defineConfig({
title: "VaultLink",
description: "Self-hosted real-time synchronisation for Obsidian",
base: "/vault-link/",
base: "/",
themeConfig: {
logo: "/logo.svg",
nav: [
@ -56,5 +56,5 @@ export default defineConfig({
provider: "local"
}
},
head: [["link", { rel: "icon", type: "image/svg+xml", href: "/vault-link/logo.svg" }]]
head: [["link", { rel: "icon", type: "image/svg+xml", href: "/logo.svg" }]]
})

View file

@ -125,7 +125,7 @@ sequenceDiagram
```
┌─────────┐
│ Client │
└───┬────┘
└───┬─-───┘
│ 1. Detect file change
├─► 2. Read file content
@ -142,7 +142,7 @@ sequenceDiagram
┌─────────┐
│ Server │
└───┬────┘
└───┬────-
│ 4. Validate message
├─► 5. Check permissions
@ -163,7 +163,7 @@ sequenceDiagram
```
┌─────────┐
│ Server │
└───┬────┘
└───┬─-───┘
│ 1. File updated by another client
├─► 2. Broadcast notification
@ -176,7 +176,7 @@ sequenceDiagram
┌─────────┐
│ Client │
└───┬────┘
└───┬─-───┘
│ 3. Receive notification
├─► 4. Request file download
@ -189,7 +189,7 @@ sequenceDiagram
┌─────────┐
│ Server │
└───┬────┘
└───┬─=───┘
│ 5. Retrieve from database
└─► 6. Send file content
@ -203,7 +203,7 @@ sequenceDiagram
┌─────────┐
│ Client │
└────┬───
└───-─┬───┘
│ 7. Write to filesystem
└─► 8. Update local metadata

View file

@ -55,6 +55,25 @@ export default [
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",
{

View file

@ -9,10 +9,11 @@
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "tsx --test src/args.test.ts src/node-filesystem.test.ts"
"test": "tsx --test 'src/**/*.test.ts'"
},
"dependencies": {
"commander": "^14.0.2"
"commander": "^14.0.2",
"watcher": "^2.3.1"
},
"devDependencies": {
"@types/node": "^24.8.1",

View file

@ -39,6 +39,10 @@ async function main(): Promise<void> {
const args = parseArgs(process.argv);
const absolutePath = path.resolve(args.localPath);
if (!fsSync.existsSync(absolutePath)) {
fsSync.mkdirSync(absolutePath, { recursive: true });
}
try {
const stats = await fs.stat(absolutePath);
if (!stats.isDirectory()) {
@ -153,7 +157,7 @@ async function main(): Promise<void> {
}
// Add colored log formatter with level filtering
client.logger.addOnMessageListener((logLine) => {
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));
@ -164,14 +168,14 @@ async function main(): Promise<void> {
const fileWatcher = new FileWatcher(absolutePath, client);
client.addWebSocketStatusChangeListener(() => {
client.onWebSocketStatusChanged.add(() => {
const isConnected = client.isWebSocketConnected;
client.logger.info(
`WebSocket status changed: ${isConnected ? "connected" : "disconnected"}`
);
});
client.addRemainingSyncOperationsListener((remaining) => {
client.onRemainingOperationsCountChanged.add((remaining) => {
if (remaining === 0) {
client.logger.info("All sync operations completed");
} else {

View file

@ -1,9 +1,9 @@
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 watcher: Watcher | undefined;
private isRunning = false;
public constructor(
@ -18,25 +18,31 @@ export class FileWatcher {
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.watcher.on("unlink", (filePath: string) => {
this.handleDelete(this.toRelativePath(filePath));
});
this.watcher.on("rename", (oldPath: string, newPath: string) => {
this.handleRename(
this.toRelativePath(oldPath),
this.toRelativePath(newPath)
);
});
this.client.logger.info("File watcher started");
}
@ -50,44 +56,53 @@ export class FileWatcher {
this.client.logger.info("File watcher stopped");
}
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)}`
);
});
}
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)}`
`Failed to sync updated file ${relativePath}: ${this.formatError(err)}`
);
});
}
private handleRenameOrDelete(relativePath: RelativePath): void {
const fullPath = path.join(this.basePath, relativePath);
fs.access(fullPath, fs.constants.F_OK, (accessError) => {
if (accessError) {
private handleDelete(relativePath: RelativePath): void {
this.client
.syncLocallyDeletedFile(relativePath)
.catch((deleteErr: unknown) => {
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync deleted file ${relativePath}: ${deleteErr instanceof Error ? deleteErr.message : String(deleteErr)}`
`Failed to sync deleted file ${relativePath}: ${this.formatError(err)}`
);
});
} else {
fs.stat(fullPath, (statErr, stats) => {
if (statErr !== null || !stats.isFile()) {
return;
}
private handleRename(oldPath: RelativePath, newPath: RelativePath): void {
this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`);
this.client
.syncLocallyCreatedFile(relativePath)
.catch((createErr: unknown) => {
.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
})
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync created file ${relativePath}: ${createErr instanceof Error ? createErr.message : String(createErr)}`
`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);
}
/**
@ -99,4 +114,8 @@ export class FileWatcher {
}
return nativePath;
}
private formatError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
}

View file

@ -18,5 +18,7 @@
"declarationMap": true,
"sourceMap": true
},
"exclude": ["dist"]
"exclude": [
"dist"
]
}

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

@ -136,10 +136,7 @@ export default class VaultLinkPlugin extends Plugin {
...(IS_DEBUG_BUILD
? {
fetch: debugging.slowFetchFactory(1),
webSocket: debugging.slowWebSocketFactory(
1,
new Logger()
)
webSocket: debugging.slowWebSocketFactory(1, new Logger())
}
: {})
});
@ -174,7 +171,7 @@ export default class VaultLinkPlugin extends Plugin {
this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]);
client.addRemoteCursorsUpdateListener((cursors) => {
client.onRemoteCursorsUpdated.add((cursors) => {
RemoteCursorsPluginValue.setCursors(cursors, this.app);
renderCursorsInFileExplorer(cursors, this.app);
});

View file

@ -24,7 +24,7 @@ export class HistoryView extends ItemView {
super(leaf);
this.icon = HistoryView.ICON;
this.client.addSyncHistoryUpdateListener(async () =>
this.client.onSyncHistoryUpdated.add(async () =>
this.updateView().catch((error: unknown) => {
this.client.logger.error(
`Failed to update history view: ${error}`

View file

@ -21,7 +21,7 @@ export class LogsView extends ItemView {
) {
super(leaf);
this.icon = LogsView.ICON;
this.client.logger.addOnMessageListener(() => {
this.client.logger.onLogEmitted.add(() => {
this.updateView();
});
}

View file

@ -58,6 +58,40 @@
height: 75px;
}
.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);
h4 {
margin-top: 0;
margin-bottom: var(--size-4-2);
color: var(--text-normal);
}
.ignored-files-list {
max-height: 300px;
overflow-y: auto;
margin: 0;
padding-left: var(--size-4-4);
li {
font-family: var(--font-monospace);
font-size: var(--font-ui-small);
color: var(--text-muted);
padding: var(--size-2-1) 0;
}
}
p {
margin: 0;
color: var(--text-muted);
font-style: italic;
}
}
.applying-changes-overlay {
position: absolute;
top: 50%;

View file

@ -4,6 +4,7 @@ import type { App } from "obsidian";
import { Notice, PluginSettingTab, Setting } from "obsidian";
import type VaultLinkPlugin from "src/vault-link-plugin";
import type { SyncClient, SyncSettings } from "sync-client";
import { globsToRegexes } from "sync-client";
import { HistoryView } from "../history/history-view";
import { LogsView } from "../logs/logs-view";
import type { StatusDescription } from "../status-description/status-description";
@ -41,8 +42,7 @@ export class SyncSettingsTab extends PluginSettingTab {
this.editedToken = this.syncClient.getSettings().token;
this.editedVaultName = this.syncClient.getSettings().vaultName;
this.syncClient.addOnSettingsChangeListener(
(newSettings, oldSettings) => {
this.syncClient.onSettingsChanged.add((newSettings, oldSettings) => {
let hasChanged = false;
if (newSettings.remoteUri !== oldSettings.remoteUri) {
@ -63,8 +63,7 @@ export class SyncSettingsTab extends PluginSettingTab {
if (hasChanged) {
this.display();
}
}
);
});
}
private get isApplyingChanges(): boolean {
@ -353,6 +352,55 @@ export class SyncSettingsTab extends PluginSettingTab {
})
);
new Setting(containerEl)
.setName("Update tracked files")
.setDesc(
"Apply current ignore patterns to already tracked files. Files that now match ignore patterns will be deleted from sync, and files that no longer match will be added to sync."
)
.addButton((button) =>
button
.setButtonText("Update tracked files")
.setDisabled(this.isApplyingChanges)
.setTooltip(
this.isApplyingChanges
? "Waiting for applying changes to finish..."
: "Update tracked files based on current ignore patterns"
)
.onClick(() => {
void (async (): Promise<void> => {
await this.updateTrackedFilesBasedOnIgnorePatterns();
})();
})
);
const ignoredFilesContainer = containerEl.createDiv({
cls: "ignored-files-container"
});
let isIgnoredFilesVisible = false;
new Setting(containerEl)
.setName("Show ignored files")
.setDesc(
"Display a list of all files currently ignored by the patterns above."
)
.addButton((button) =>
button.setButtonText("Show ignored files").onClick(() => {
void (async (): Promise<void> => {
if (isIgnoredFilesVisible) {
ignoredFilesContainer.empty();
isIgnoredFilesVisible = false;
button.setButtonText("Show ignored files");
} else {
await this.displayIgnoredFiles(
ignoredFilesContainer
);
isIgnoredFilesVisible = true;
button.setButtonText("Hide ignored files");
}
})();
})
);
new Setting(containerEl)
.setName("Sync concurrency")
.setDesc(
@ -564,4 +612,136 @@ export class SyncSettingsTab extends PluginSettingTab {
return [titleContainer, updateTitle];
}
private async updateTrackedFilesBasedOnIgnorePatterns(): Promise<void> {
this.isApplyingChanges = true;
try {
const ignorePatterns: RegExp[] = globsToRegexes(
this.syncClient.getSettings().ignorePatterns,
this.syncClient.logger
);
const matchesIgnorePattern = (path: string): boolean => {
return ignorePatterns.some((pattern) => pattern.test(path));
};
const trackedFiles: string[] =
this.syncClient.getTrackedFilePaths();
const allVaultFiles: string[] =
await this.syncClient.getAllVaultFiles();
const filesToDelete: string[] = trackedFiles.filter((path) =>
matchesIgnorePattern(path)
);
const filesToCreate: string[] = allVaultFiles.filter(
(path) =>
!matchesIgnorePattern(path) && !trackedFiles.includes(path)
);
if (filesToDelete.length === 0 && filesToCreate.length === 0) {
new Notice("No files need to be updated");
return;
}
const confirmMessageParts: string[] = [
`This will update ${filesToDelete.length + filesToCreate.length} file(s):`
];
if (filesToDelete.length > 0) {
confirmMessageParts.push(
`- Delete ${filesToDelete.length} file(s) from sync (now ignored)`
);
}
if (filesToCreate.length > 0) {
confirmMessageParts.push(
`- Add ${filesToCreate.length} file(s) to sync (no longer ignored)`
);
}
confirmMessageParts.push("", "Do you want to continue?");
const confirmMessage = confirmMessageParts.join("\n");
const confirmed = confirm(confirmMessage);
if (!confirmed) {
new Notice("Update cancelled");
return;
}
new Notice(
`Updating ${filesToDelete.length + filesToCreate.length} file(s)...`
);
for (const path of filesToDelete) {
await this.syncClient.syncLocallyDeletedFile(path);
}
for (const path of filesToCreate) {
await this.syncClient.syncLocallyCreatedFile(path);
}
new Notice(
`Successfully updated ${filesToDelete.length + filesToCreate.length} file(s)`
);
} catch (error) {
new Notice(`Error updating tracked files: ${String(error)}`);
this.syncClient.logger.error(
`Error updating tracked files: ${String(error)}`
);
} finally {
this.isApplyingChanges = false;
}
}
private async displayIgnoredFiles(container: HTMLElement): Promise<void> {
container.empty();
try {
const ignorePatterns: RegExp[] = globsToRegexes(
this.syncClient.getSettings().ignorePatterns,
this.syncClient.logger
);
if (ignorePatterns.length === 0) {
container.createEl("p", {
text: "No ignore patterns configured"
});
return;
}
const allVaultFiles: string[] =
await this.syncClient.getAllVaultFiles();
const ignoredFiles: string[] = allVaultFiles.filter((path) =>
ignorePatterns.some((pattern) => pattern.test(path))
);
if (ignoredFiles.length === 0) {
container.createEl("p", {
text: "No files are currently ignored"
});
return;
}
container.createEl("h4", {
text: `Ignored files (${ignoredFiles.length})`
});
const fileList = container.createEl("ul", {
cls: "ignored-files-list"
});
const sortedIgnoredFiles = [...ignoredFiles].sort();
for (const path of sortedIgnoredFiles) {
fileList.createEl("li", { text: path });
}
} catch (error) {
container.createEl("p", {
text: `Error loading ignored files: ${String(error)}`
});
this.syncClient.logger.error(
`Error loading ignored files: ${String(error)}`
);
}
}
}

View file

@ -14,19 +14,19 @@ export class StatusBar {
private readonly syncClient: SyncClient
) {
this.statusBarItem = plugin.addStatusBarItem();
this.syncClient.addSyncHistoryUpdateListener((status) => {
this.syncClient.onSyncHistoryUpdated.add((status) => {
this.lastHistoryStats = status;
this.updateStatus();
});
this.syncClient.addRemainingSyncOperationsListener(
this.syncClient.onRemainingOperationsCountChanged.add(
(remainingOperations) => {
this.lastRemaining = remainingOperations;
this.updateStatus();
}
);
this.syncClient.addOnSettingsChangeListener(() => {
this.syncClient.onSettingsChanged.add(() => {
this.updateStatus();
});
}

View file

@ -5,34 +5,35 @@ import type {
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 statusChangeListeners: (() => unknown)[] = [];
private readonly statusChangeListeners: (() => unknown)[] = [];
public constructor(private readonly syncClient: SyncClient) {
void this.updateConnectionState();
syncClient.addSyncHistoryUpdateListener((status) => {
syncClient.onSyncHistoryUpdated.add((status) => {
this.lastHistoryStats = status;
this.updateDescription();
});
this.syncClient.addRemainingSyncOperationsListener(
this.syncClient.onRemainingOperationsCountChanged.add(
(remainingOperations) => {
this.lastRemaining = remainingOperations;
this.updateDescription();
}
);
this.syncClient.addWebSocketStatusChangeListener(async () =>
this.syncClient.onWebSocketStatusChanged.add(async () =>
this.updateConnectionState()
);
this.syncClient.addOnSettingsChangeListener(async () =>
this.syncClient.onSettingsChanged.add(async () =>
this.updateConnectionState()
);
}
@ -46,9 +47,7 @@ export class StatusDescription {
this.statusChangeListeners.push(listener);
}
public removeStatusChangeListener(listener: () => unknown): void {
this.statusChangeListeners = this.statusChangeListeners.filter(
(l) => l !== listener
);
utils.removeFromArray(this.statusChangeListeners, listener);
}
public renderStatusDescription(container: HTMLElement): void {

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,7 @@
"prettier": {
"trailingComma": "none",
"tabWidth": 4,
"useTabs": true,
"useTabs": false,
"endOfLine": "lf"
},
"scripts": {
@ -22,6 +22,7 @@
},
"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",

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

@ -5,6 +5,7 @@ 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,
@ -39,9 +40,12 @@ export const debugging = {
logToConsole
};
export { globsToRegexes } from "./utils/globs-to-regexes";
export const utils = {
getRandomColor,
positionToLineAndColumn,
lineAndColumnToPosition,
awaitAll
awaitAll,
removeFromArray
};

View file

@ -2,6 +2,7 @@ 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;
@ -93,6 +94,7 @@ export class Database {
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, [
@ -151,12 +153,12 @@ export class Database {
return;
}
entry.updates = entry.updates.filter((update) => update !== promise);
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);
removeFromArray(this.documents, find);
this.saveInTheBackground();
}

View file

@ -1,6 +1,6 @@
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;
@ -33,14 +33,13 @@ export const DEFAULT_SETTINGS: SyncSettings = {
};
export class Settings {
public readonly onSettingsChanged = new EventListeners<
(newSettings: SyncSettings, oldSettings: SyncSettings) => unknown
>();
private settings: SyncSettings;
private readonly lock: Lock = new Lock();
private readonly onSettingsChangeHandlers: ((
newSettings: SyncSettings,
oldSettings: SyncSettings
) => unknown)[] = [];
public constructor(
private readonly logger: Logger,
initialState: Partial<SyncSettings> | undefined,
@ -60,21 +59,6 @@ export class Settings {
return this.settings;
}
public addOnSettingsChangeListener(
listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown
): void {
this.onSettingsChangeHandlers.push(listener);
}
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 setSetting<T extends keyof SyncSettings>(
key: T,
value: SyncSettings[T]
@ -95,14 +79,9 @@ export class Settings {
...value
};
await awaitAll(
this.onSettingsChangeHandlers
.map((handler) => {
return handler(this.settings, oldSettings);
})
.filter((result): result is Promise<unknown> => {
return result instanceof Promise;
})
await this.onSettingsChanged.triggerAsync(
this.settings,
oldSettings
);
await this.save();

View file

@ -122,7 +122,7 @@ describe("WebSocketManager", () => {
MockWebSocket as unknown as typeof WebSocket
);
manager.addRemoteVaultUpdateListener(async () => {
manager.onRemoteVaultUpdateReceived.add(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
manager.start();
@ -152,7 +152,7 @@ describe("WebSocketManager", () => {
MockWebSocket as unknown as typeof WebSocket
);
manager.addRemoteCursorsUpdateListener(async () => {
manager.onRemoteCursorsUpdateReceived.add(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
manager.start();
@ -227,7 +227,7 @@ describe("WebSocketManager", () => {
);
let statusChangeCount = 0;
manager.addWebSocketStatusChangeListener(() => {
manager.onWebSocketStatusChanged.add(() => {
statusChangeCount++;
});
@ -269,7 +269,7 @@ describe("WebSocketManager", () => {
resolveListener = resolve;
});
manager.addRemoteVaultUpdateListener(async () => {
manager.onRemoteVaultUpdateReceived.add(async () => {
await listenerPromise;
});

View file

@ -6,21 +6,23 @@ 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;
@ -59,24 +61,6 @@ export class WebSocketManager {
);
}
public addWebSocketStatusChangeListener(
listener: (isConnected: boolean) => unknown
): void {
this.webSocketStatusChangeListeners.push(listener);
}
public addRemoteCursorsUpdateListener(
listener: (cursors: ClientCursors[]) => Promise<void>
): void {
this.remoteCursorsUpdateListeners.push(listener);
}
public addRemoteVaultUpdateListener(
listener: (update: WebSocketVaultUpdate) => Promise<void>
): void {
this.remoteVaultUpdateListeners.push(listener);
}
public start(): void {
this.isStopped = false;
this.initializeWebSocket();
@ -205,9 +189,7 @@ export class WebSocketManager {
this.webSocket.onopen = (): void => {
this.logger.info("WebSocket connection opened");
this.webSocketStatusChangeListeners.forEach((listener) =>
listener(true)
);
this.onWebSocketStatusChanged.trigger(true);
};
this.webSocket.onmessage = (event): void => {
@ -227,12 +209,10 @@ export class WebSocketManager {
);
})
.finally(() => {
const index = this.outstandingPromises.indexOf(
removeFromArray(
this.outstandingPromises,
messageHandlingPromise
);
if (index !== -1) {
void this.outstandingPromises.splice(index, 1); // ignore the returned promise
}
});
void this.outstandingPromises.push(messageHandlingPromise); // ignore the returned promise
@ -247,9 +227,7 @@ export class WebSocketManager {
this.logger.warn(
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
);
this.webSocketStatusChangeListeners.forEach((listener) =>
listener(false)
);
this.onWebSocketStatusChanged.trigger(false);
if (this.isStopped) {
this.resolveDisconnectingPromise?.();
@ -267,15 +245,7 @@ export class WebSocketManager {
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)}`
);
});
})
);
await this.onRemoteVaultUpdateReceived.triggerAsync(message);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (message.type === "cursorPositions") {
@ -283,14 +253,8 @@ export class WebSocketManager {
`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)}`
);
});
})
await this.onRemoteCursorsUpdateReceived.triggerAsync(
message.clients
);
} else {
this.logger.warn(

View file

@ -26,6 +26,7 @@ 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;
@ -62,6 +63,42 @@ export class SyncClient {
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,
@ -122,7 +159,7 @@ export class SyncClient {
settings.getSettings().isSyncEnabled,
logger
);
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
settings.onSettingsChanged.add((newSettings, oldSettings) => {
if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) {
fetchController.canFetch = newSettings.isSyncEnabled;
}
@ -221,15 +258,13 @@ export class SyncClient {
this.unloadTelemetry = setUpTelemetry();
}
this.logger.addOnMessageListener((log): void => {
this.logger.onLogEmitted.add((log): void => {
if (log.level === LogLevel.ERROR && Sentry.isInitialized()) {
Sentry.captureMessage(log.message);
}
});
this.settings.addOnSettingsChangeListener(
this.onSettingsChange.bind(this)
);
this.settings.onSettingsChanged.add(this.onSettingsChange.bind(this));
if (this.settings.getSettings().isSyncEnabled) {
this.logger.info("Starting SyncClient");
@ -273,14 +308,6 @@ export class SyncClient {
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.
@ -325,28 +352,6 @@ export class SyncClient {
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> {
@ -412,12 +417,18 @@ export class SyncClient {
await this.cursorTracker.sendLocalCursorsToServer(documentToCursors);
}
public addRemoteCursorsUpdateListener(
listener: (cursors: MaybeOutdatedClientCursors[]) => unknown
): void {
this.checkIfDestroyed("addRemoteCursorsUpdateListener");
public getTrackedFilePaths(): RelativePath[] {
this.checkIfDestroyed("getTrackedFilePaths");
this.cursorTracker.addRemoteCursorsUpdateListener(listener);
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> {

View file

@ -9,12 +9,19 @@ 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 {
// 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 readonly updateLock = new Lock();
private knownRemoteCursors: (ClientCursors & {
@ -31,7 +38,7 @@ export class CursorTracker {
private readonly fileOperations: FileOperations,
private readonly fileChangeNotifier: FileChangeNotifier
) {
this.webSocketManager.addRemoteCursorsUpdateListener(
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
@ -58,10 +65,14 @@ export class CursorTracker {
this.knownRemoteCursors = updatedKnownRemoteCursors;
});
this.onRemoteCursorsUpdated.trigger(
this.getRelevantAndPruneKnownClientCursors()
);
}
);
this.fileChangeNotifier.addFileChangeListener(async (relativePath) =>
this.fileChangeNotifier.onFileChanged.add(async (relativePath) =>
this.updateLock.withLock(async () => {
for (const clientCursor of this.knownRemoteCursors) {
if (
@ -144,19 +155,6 @@ export class CursorTracker {
this.webSocketManager.updateLocalCursors({ documentsWithCursors });
}
// 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())
);
});
}
public reset(): void {
this.knownRemoteCursors = [];
this.lastLocalCursorState = [];

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 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 readonly onFileChanged = new EventListeners<
(filePath: RelativePath) => unknown
>();
public notifyOfFileChange(filePath: RelativePath): void {
this.listeners.forEach((listener) => listener(filePath));
this.onFileChanged.trigger(filePath);
}
}

View file

@ -21,18 +21,21 @@ import type { WebSocketVaultUpdate } from "../services/types/WebSocketVaultUpdat
import type { WebSocketManager } from "../services/websocket-manager";
import type { WebSocketClientMessage } from "../services/types/WebSocketClientMessage";
import { awaitAll } from "../utils/await-all";
import { EventListeners } from "../utils/data-structures/event-listeners";
export class Syncer {
public readonly onRemainingOperationsCountChanged = new EventListeners<
(remainingOperations: number) => unknown
>();
private readonly remoteDocumentsLock: Locks<DocumentId>;
private readonly remainingOperationsListeners: ((
remainingOperations: number
) => unknown)[] = [];
// FIFO to limit the number of concurrent sync operations
private readonly syncQueue: PQueue;
private _isFirstSyncComplete = false;
private runningScheduleSyncForOfflineChanges: Promise<void> | undefined;
private previousRemainingOperationsCount = 0;
public constructor(
private readonly deviceId: string,
@ -50,27 +53,28 @@ export class Syncer {
this.remoteDocumentsLock = new Locks<DocumentId>(this.logger);
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
settings.onSettingsChanged.add((newSettings, oldSettings) => {
if (newSettings.syncConcurrency !== oldSettings.syncConcurrency) {
this.syncQueue.concurrency = newSettings.syncConcurrency;
}
});
this.syncQueue.on("active", () => {
this.remainingOperationsListeners.forEach((listener) => {
listener(this.syncQueue.size);
});
if (this.previousRemainingOperationsCount !== this.syncQueue.size) {
this.previousRemainingOperationsCount = this.syncQueue.size;
this.onRemainingOperationsCountChanged.trigger(
this.syncQueue.size
);
}
});
this.webSocketManager.addWebSocketStatusChangeListener(
(isConnected) => {
this.webSocketManager.onWebSocketStatusChanged.add((isConnected) => {
if (isConnected) {
// The JS WebSocket API doesn't support setting headers, so we have to send the token as a message
this.sendHandshakeMessage();
}
}
);
this.webSocketManager.addRemoteVaultUpdateListener(
});
this.webSocketManager.onRemoteVaultUpdateReceived.add(
this.syncRemotelyUpdatedFile.bind(this)
);
}
@ -79,12 +83,6 @@ export class Syncer {
return this._isFirstSyncComplete;
}
public addRemainingOperationsListener(
listener: (remainingOperations: number) => unknown
): void {
this.remainingOperationsListeners.push(listener);
}
public async syncLocallyCreatedFile(
relativePath: RelativePath
): Promise<void> {
@ -444,11 +442,13 @@ export class Syncer {
);
if (originalFile !== undefined) {
// `originalFile` hasn't been deleted but it got moved instead
/* eslint-disable no-restricted-syntax -- Comparing by property, not direct equality */
locallyPossiblyDeletedFiles =
locallyPossiblyDeletedFiles.filter(
(item) =>
item.relativePath !== originalFile.relativePath
);
/* eslint-enable no-restricted-syntax */
this.logger.debug(
`Document '${originalFile.relativePath}' was not found under its current path in the database but was found under a different path (${relativePath}), scheduling sync to move it`

View file

@ -52,7 +52,7 @@ export class UnrestrictedSyncer {
this.logger
);
this.settings.addOnSettingsChangeListener((newSettings) => {
this.settings.onSettingsChanged.add((newSettings) => {
this.ignorePatterns = globsToRegexes(
newSettings.ignorePatterns,
this.logger

View file

@ -1,4 +1,5 @@
import { MAX_LOG_MESSAGE_COUNT } from "../consts";
import { EventListeners } from "../utils/data-structures/event-listeners";
export enum LogLevel {
DEBUG = "DEBUG",
@ -23,14 +24,11 @@ export class LogLine {
}
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);
@ -56,19 +54,6 @@ export class Logger {
);
}
public addOnMessageListener(listener: (message: LogLine) => unknown): void {
this.onMessageListeners.push(listener);
}
public removeOnMessageListener(
listener: (message: LogLine) => unknown
): void {
const index = this.onMessageListeners.indexOf(listener);
if (index !== -1) {
this.onMessageListeners.splice(index, 1);
}
}
public reset(): void {
this.messages.length = 0;
this.debug("Logger has been reset");
@ -82,8 +67,6 @@ export class Logger {
this.messages.shift();
}
this.onMessageListeners.forEach((listener) => {
listener(logLine);
});
this.onLogEmitted.trigger(logLine);
}
}

View file

@ -4,6 +4,8 @@ import {
} 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;
@ -68,11 +70,11 @@ export interface HistoryStats {
}
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,
@ -99,7 +101,7 @@ export class SyncHistory {
const candidate = this.findSimilarRecentUpdateEntry(historyEntry);
if (candidate !== undefined) {
this._entries = this._entries.filter((e) => e !== candidate);
removeFromArray(this._entries, candidate);
}
// Insert the entry at the beginning
@ -112,31 +114,13 @@ export class SyncHistory {
this.updateSuccessCount(historyEntry);
}
public addSyncHistoryUpdateListener(
listener: (stats: HistoryStats) => unknown
): void {
this.syncHistoryUpdateListeners.push(listener);
listener({ ...this.status });
}
public removeSyncHistoryUpdateListener(
listener: (stats: HistoryStats) => unknown
): void {
const index = this.syncHistoryUpdateListeners.indexOf(listener);
if (index !== -1) {
this.syncHistoryUpdateListeners.splice(index, 1);
}
}
public reset(): void {
this._entries.length = 0;
this.status = {
success: 0,
error: 0
};
this.syncHistoryUpdateListeners.forEach((listener) => {
listener(this.status);
});
this.onHistoryUpdated.trigger(this.status);
}
private findSimilarRecentUpdateEntry(
@ -178,8 +162,6 @@ export class SyncHistory {
break;
}
this.syncHistoryUpdateListeners.forEach((listener) => {
listener(this.status);
});
this.onHistoryUpdated.trigger(this.status);
}
}

View file

@ -0,0 +1,150 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { EventListeners } from "./event-listeners";
describe("EventListeners", () => {
it("should add & remove listeners", () => {
const listeners = new EventListeners<() => void>();
// eslint-disable-next-line @typescript-eslint/no-empty-function
const listener = (): void => {};
listeners.add(listener);
assert.strictEqual(listeners.count, 1);
const removed = listeners.remove(listener);
assert.strictEqual(removed, true);
assert.strictEqual(listeners.count, 0);
});
it("should remove listeners using unsubscribe function", () => {
const listeners = new EventListeners<() => void>();
// eslint-disable-next-line @typescript-eslint/no-empty-function
const listener = (): void => {};
const unsubscribe = listeners.add(listener);
unsubscribe();
assert.strictEqual(listeners.count, 0);
});
it("should return false when removing non-existent listener", () => {
const listeners = new EventListeners<() => void>();
// eslint-disable-next-line @typescript-eslint/no-empty-function
const listener = (): void => {};
const removed = listeners.remove(listener);
assert.strictEqual(removed, false);
});
it("should handle multiple listeners", () => {
const listeners = new EventListeners<() => void>();
// eslint-disable-next-line @typescript-eslint/no-empty-function
const listener1 = (): void => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const listener2 = (): void => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const listener3 = (): void => {};
listeners.add(listener1);
listeners.add(listener2);
listeners.add(listener3);
assert.strictEqual(listeners.count, 3);
listeners.remove(listener2);
assert.strictEqual(listeners.count, 2);
});
it("should trigger all listeners synchronously", () => {
const listeners = new EventListeners<(value: string) => void>();
const calls: string[] = [];
listeners.add((value) => calls.push(`listener1-${value}`));
listeners.add((value) => calls.push(`listener2-${value}`));
listeners.trigger("test");
assert.deepStrictEqual(calls, ["listener1-test", "listener2-test"]);
});
it("should trigger listeners with multiple arguments", () => {
const listeners = new EventListeners<
(a: number, b: string, c: boolean) => void
>();
const calls: [number, string, boolean][] = [];
listeners.add((a, b, c) => calls.push([a, b, c]));
listeners.trigger(42, "hello", true);
assert.deepStrictEqual(calls, [[42, "hello", true]]);
});
it("should not trigger removed listeners", () => {
const listeners = new EventListeners<() => void>();
let count1 = 0;
let count2 = 0;
const listener1 = (): void => {
count1++;
};
const listener2 = (): void => {
count2++;
};
listeners.add(listener1);
const unsubscribe = listeners.add(listener2);
unsubscribe();
listeners.trigger();
assert.strictEqual(count1, 1);
assert.strictEqual(count2, 0);
});
it("should trigger all listeners and await promises", async () => {
const listeners = new EventListeners<
(value: string) => Promise<void> | void
>();
const results: string[] = [];
listeners.add(async (value) => {
await new Promise((resolve) => setTimeout(resolve, 10));
results.push(`async1-${value}`);
});
listeners.add((value) => {
results.push(`sync-${value}`);
});
listeners.add(async (value) => {
await new Promise((resolve) => setTimeout(resolve, 5));
results.push(`async2-${value}`);
});
await listeners.triggerAsync("test");
assert.ok(results.includes("async1-test"));
assert.ok(results.includes("sync-test"));
assert.ok(results.includes("async2-test"));
assert.strictEqual(results.length, 3);
});
it("should not trigger cleared listeners", () => {
const listeners = new EventListeners<() => void>();
let called = false;
const listener = (): void => {
called = true;
};
listeners.add(listener);
listeners.clear();
assert.strictEqual(listeners.count, 0);
listeners.trigger();
assert.strictEqual(called, false);
});
});

View file

@ -0,0 +1,71 @@
import { removeFromArray } from "../remove-from-array";
import { awaitAll } from "../await-all";
/**
* A utility class for managing event listeners with type-safe add/remove operations.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class EventListeners<TListener extends (...args: any[]) => any> {
private readonly listeners: TListener[] = [];
public get count(): number {
return this.listeners.length;
}
/**
* Adds a new listener to the collection.
*
* @param listener The listener callback to add
* @returns An unsubscribe function that removes this listener when called
*/
public add(listener: TListener): () => void {
this.listeners.push(listener);
return () => this.remove(listener);
}
/**
* Removes a listener from the collection.
*
* @param listener The listener callback to remove
* @returns true if the listener was found and removed, false otherwise
*/
public remove(listener: TListener): boolean {
return removeFromArray(this.listeners, listener);
}
/**
* Triggers all listeners synchronously with the provided arguments.
* Any returned promises are ignored. Use triggerAsync() to await them.
*
* @param args The arguments to pass to each listener
*/
public trigger(...args: Parameters<TListener>): void {
this.listeners.forEach((listener) => {
listener(...args);
});
}
/**
* Triggers all listeners and awaits any promises they return.
* Synchronous listeners are called immediately, and any async listeners
* are awaited in parallel.
*
* @param args The arguments to pass to each listener
*/
public async triggerAsync(...args: Parameters<TListener>): Promise<void> {
await awaitAll(
this.listeners
.map((listener) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return listener(...args);
})
.filter((result): result is Promise<unknown> => {
return result instanceof Promise;
})
);
}
public clear(): void {
this.listeners.length = 0;
}
}

View file

@ -3,7 +3,7 @@ import type { LogLine } from "../../tracing/logger";
import { LogLevel } from "../../tracing/logger";
export function logToConsole(client: SyncClient): void {
client.logger.addOnMessageListener((logLine: LogLine) => {
client.logger.onLogEmitted.add((logLine: LogLine) => {
const formatted = `${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`;
switch (logLine.level) {

View file

@ -2,7 +2,8 @@ import { makeRe } from "minimatch";
import type { Logger } from "../tracing/logger";
export function globsToRegexes(globs: string[], logger: Logger): RegExp[] {
return globs
return (
globs
.map((pattern) => {
const result = makeRe(pattern, {
dot: true
@ -14,5 +15,7 @@ export function globsToRegexes(globs: string[], logger: Logger): RegExp[] {
}
return result;
})
.filter((pattern) => pattern !== false);
// eslint-disable-next-line no-restricted-syntax -- Filtering out false values, not removing a specific item
.filter((pattern) => pattern !== false)
);
}

View file

@ -0,0 +1,17 @@
/**
* Efficiently removes a specific item from an array by modifying it in place.
* This is more efficient than using `.filter(item => item !== toRemove)` as it avoids creating a new array
*
* @param array The array to modify
* @param item The item to remove
* @returns true if the item was found and removed, false otherwise
*/
export function removeFromArray<T>(array: T[], item: T): boolean {
const index = array.indexOf(item);
if (index !== -1) {
// eslint-disable-next-line no-restricted-syntax -- This is the implementation of the helper itself
array.splice(index, 1);
return true;
}
return false;
}

View file

@ -8,7 +8,7 @@
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "tsx --test src/**/*.test.ts"
"test": "tsx --test 'src/**/*.test.ts'"
},
"devDependencies": {
"@types/node": "^24.8.1",

View file

@ -15,7 +15,7 @@ export class MockAgent extends MockClient {
private readonly pendingActions: Promise<unknown>[] = [];
// The renamed file finding algorithm isn't too smart so we can't both update and rename the same file
private doNotTouchWhileOffline: string[] = [];
private readonly doNotTouchWhileOffline: string[] = [];
public constructor(
initialSettings: Partial<SyncSettings>,
@ -42,7 +42,7 @@ export class MockAgent extends MockClient {
"Connection check failed"
);
this.client.logger.addOnMessageListener((logLine: LogLine) => {
this.client.logger.onLogEmitted.add((logLine: LogLine) => {
const state = this.client.getSettings().isSyncEnabled
? "(online) "
: "(offline)";
@ -54,9 +54,9 @@ export class MockAgent extends MockClient {
);
if (historyEntry) {
this.doNotTouchWhileOffline =
this.doNotTouchWhileOffline.filter(
(file) => file !== historyEntry[1]
utils.removeFromArray(
this.doNotTouchWhileOffline,
historyEntry[1]
);
}
switch (logLine.level) {

View file

@ -2,7 +2,6 @@
set -e
# Parse arguments
FIX_MODE=false
if [[ "$1" == "--fix" ]]; then
FIX_MODE=true
@ -32,10 +31,29 @@ if [[ "$FIX_MODE" == true ]]; then
else
npm ci
fi
npm run build
npm run test
npm run lint
cd ../docs
if [[ "$FIX_MODE" == true ]]; then
npm install
npm run format
npm run spell
else
npm ci
npm run format:check
npm run spell:check
fi
cd ..
# Use git ls-files to only check tracked files, respecting .gitignore
# We always run in fix mode and then check with git status
git ls-files | xargs npx eclint fix
if [[ "$FIX_MODE" == false ]] && [[ $(git status --porcelain) ]]; then
git status --porcelain
echo "Failing CI because the working directory is not clean after linting"
@ -44,8 +62,4 @@ fi
cd ..
if [[ "$FIX_MODE" == true ]]; then
$0
else
echo "Success"
fi
echo "Success"

View file

@ -109,4 +109,3 @@ while true; do
sleep 0.2
done

View file

@ -11,5 +11,6 @@ cd -
cp -r sync-server/bindings/* frontend/sync-client/src/services/types/
cd frontend
npm run lint || npx prettier --write sync-client/src/services/types/*.ts
npm run lint
git ls-files | xargs npx eclint fix
cd -

307
sync-server/Cargo.lock generated
View file

@ -17,6 +17,12 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "adler32"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
[[package]]
name = "aho-corasick"
version = "1.1.3"
@ -114,7 +120,7 @@ checksum = "3f934833b4b7233644e5848f235df3f57ed8c80f1528a26c3dfa13d2147fa056"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
@ -224,7 +230,7 @@ checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
@ -256,7 +262,7 @@ dependencies = [
"heck",
"proc-macro-error",
"quote",
"syn 2.0.90",
"syn 2.0.111",
"ubyte",
]
@ -341,6 +347,8 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f34d93e62b03caf570cccc334cbc6c2fceca82f39211051345108adcba3eebdc"
dependencies = [
"jobserver",
"libc",
"shlex",
]
@ -375,16 +383,6 @@ dependencies = [
"clap_derive",
]
[[package]]
name = "clap-verbosity-flag"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeab6a5cdfc795a05538422012f20a5496f050223c91be4e5420bfd13c641fb1"
dependencies = [
"clap",
"log",
]
[[package]]
name = "clap_builder"
version = "4.5.38"
@ -406,7 +404,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
@ -442,6 +440,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core2"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
dependencies = [
"memchr",
]
[[package]]
name = "cpufeatures"
version = "0.2.16"
@ -466,6 +473,15 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.11"
@ -512,7 +528,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
@ -523,9 +539,15 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
dependencies = [
"darling_core",
"quote",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
name = "dary_heap"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04"
[[package]]
name = "data-encoding"
version = "2.6.0"
@ -563,7 +585,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
@ -657,6 +679,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
version = "1.2.1"
@ -733,7 +761,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
@ -813,7 +841,18 @@ checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
]
[[package]]
@ -822,7 +861,7 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown",
"hashbrown 0.15.2",
]
[[package]]
@ -1123,7 +1162,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
@ -1153,6 +1192,43 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "include-flate"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01b7cb6ca682a621e7cda1c358c9724b53a7b4409be9be1dd443b7f3a26f998"
dependencies = [
"include-flate-codegen",
"include-flate-compress",
"libflate",
"zstd",
]
[[package]]
name = "include-flate-codegen"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f49bf5274aebe468d6e6eba14a977eaf1efa481dc173f361020de70c1c48050"
dependencies = [
"include-flate-compress",
"libflate",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.111",
"zstd",
]
[[package]]
name = "include-flate-compress"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eae6a40e716bcd5931f5dbb79cd921512a4f647e2e9413fded3171fca3824dbc"
dependencies = [
"libflate",
"zstd",
]
[[package]]
name = "indexmap"
version = "2.7.0"
@ -1160,7 +1236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.15.2",
]
[[package]]
@ -1175,6 +1251,16 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.2",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.76"
@ -1200,6 +1286,30 @@ version = "0.2.174"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
[[package]]
name = "libflate"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3248b8d211bd23a104a42d81b4fa8bb8ac4a3b75e7a43d85d2c9ccb6179cd74"
dependencies = [
"adler32",
"core2",
"crc32fast",
"dary_heap",
"libflate_lz77",
]
[[package]]
name = "libflate_lz77"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a599cb10a9cd92b1300debcef28da8f70b935ec937f44fcd1b70a7c986a11c5c"
dependencies = [
"core2",
"hashbrown 0.16.1",
"rle-decode-fast",
]
[[package]]
name = "libm"
version = "0.2.11"
@ -1282,6 +1392,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.0"
@ -1508,18 +1628,18 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.92"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0"
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.37"
version = "1.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
dependencies = [
"proc-macro2",
]
@ -1638,6 +1758,12 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "rle-decode-fast"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422"
[[package]]
name = "rsa"
version = "0.9.7"
@ -1658,6 +1784,41 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rust-embed"
version = "8.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "947d7f3fad52b283d261c4c99a084937e2fe492248cb9a68a8435a861b8798ca"
dependencies = [
"include-flate",
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fa2c8c9e8711e10f9c4fd2d64317ef13feaab820a4c51541f1a8c8e2e851ab2"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.111",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b161f275cb337fe0a44d924a5f4df0ed69c2c39519858f931ce61c779d3475"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "rustc-demangle"
version = "0.1.24"
@ -1689,6 +1850,15 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "sanitize-filename"
version = "0.6.0"
@ -1731,7 +1901,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
@ -1915,7 +2085,7 @@ dependencies = [
"futures-intrusive",
"futures-io",
"futures-util",
"hashbrown",
"hashbrown 0.15.2",
"hashlink",
"indexmap",
"log",
@ -1944,7 +2114,7 @@ dependencies = [
"quote",
"sqlx-core",
"sqlx-macros-core",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
@ -1967,7 +2137,7 @@ dependencies = [
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
"syn 2.0.90",
"syn 2.0.111",
"tokio",
"url",
]
@ -2122,9 +2292,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.90"
version = "2.0.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31"
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
dependencies = [
"proc-macro2",
"quote",
@ -2143,13 +2313,14 @@ dependencies = [
"bimap",
"chrono",
"clap",
"clap-verbosity-flag",
"futures",
"humantime-serde",
"log",
"mime_guess",
"rand 0.9.0",
"reconcile-text",
"regex",
"rust-embed",
"sanitize-filename",
"serde",
"serde_json",
@ -2178,7 +2349,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
@ -2229,7 +2400,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
@ -2240,7 +2411,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
@ -2303,7 +2474,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
@ -2395,7 +2566,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
@ -2458,7 +2629,7 @@ checksum = "0e9d8656589772eeec2cf7a8264d9cda40fb28b9bc53118ceb9e8c07f8f38730"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
"termcolor",
]
@ -2492,6 +2663,12 @@ version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea"
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]]
name = "unicode-bidi"
version = "0.3.17"
@ -2588,6 +2765,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
@ -2630,7 +2817,7 @@ dependencies = [
"log",
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
"wasm-bindgen-shared",
]
@ -2652,7 +2839,7 @@ checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@ -2901,7 +3088,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
"synstructure",
]
@ -2932,7 +3119,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
@ -2943,7 +3130,7 @@ checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
@ -2963,7 +3150,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
"synstructure",
]
@ -2992,5 +3179,33 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.90",
"syn 2.0.111",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]

View file

@ -30,11 +30,12 @@ clap = { version = "4.5.38", features = ["derive"] }
futures = "0.3.31"
serde_yaml = "0.9.34"
serde_json = "1.0.140"
clap-verbosity-flag = "3.0.3"
bimap = "0.6.3"
ts-rs = { version = "10.1", features = ["uuid-impl", "chrono-impl"] }
base64 = "0.22.1"
reconcile-text = { version = "0.8.0", features = ["serde"] }
rust-embed = { version = "8.5.0", features = ["compression"] }
mime_guess = "2.0.5"
[profile.release]
codegen-units = 1

View file

@ -1,5 +1,77 @@
// generated by `sqlx migrate build-script`
use std::path::PathBuf;
use std::process::Command;
fn main() {
// trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations");
println!("cargo:rerun-if-changed=../docs");
let docs_dir = PathBuf::from("../docs");
let dist_dir = docs_dir.join(".vitepress").join("dist");
// Check if npm is available
let npm_check = if cfg!(target_os = "windows") {
Command::new("cmd")
.args(["/C", "npm", "--version"])
.output()
} else {
Command::new("npm").arg("--version").output()
};
match npm_check {
Ok(output) if output.status.success() => {
println!("cargo:warning=Building VitePress documentation...");
// Install dependencies if node_modules doesn't exist
if !docs_dir.join("node_modules").exists() {
println!("cargo:warning=Installing npm dependencies...");
let npm_install = if cfg!(target_os = "windows") {
Command::new("cmd")
.args(["/C", "npm", "install"])
.current_dir(&docs_dir)
.status()
} else {
Command::new("npm")
.arg("install")
.current_dir(&docs_dir)
.status()
};
if let Err(e) = npm_install {
println!("cargo:warning=Failed to install npm dependencies: {e}");
println!("cargo:warning=Docs will not be embedded in the binary");
return;
}
}
// Build the VitePress docs
let build_result = if cfg!(target_os = "windows") {
Command::new("cmd")
.args(["/C", "npm", "run", "build"])
.current_dir(&docs_dir)
.status()
} else {
Command::new("npm")
.args(["run", "build"])
.current_dir(&docs_dir)
.status()
};
match build_result {
Ok(status) if status.success() => {
println!("cargo:warning=VitePress docs built successfully at {dist_dir:?}");
}
Ok(status) => {
println!("cargo:warning=VitePress build failed with status: {status}");
println!("cargo:warning=Docs will not be embedded in the binary");
}
Err(e) => {
println!("cargo:warning=Failed to build VitePress docs: {e}");
println!("cargo:warning=Docs will not be embedded in the binary");
}
}
}
_ => {
println!("cargo:warning=npm not found - skipping VitePress build");
println!("cargo:warning=Docs will not be embedded in the binary");
}
}
}

View file

@ -30,3 +30,4 @@ users:
logging:
log_directory: logs
log_rotation: 7days
log_level: info

View file

@ -57,6 +57,7 @@ impl Database {
let mut connection_pools = std::collections::HashMap::new();
info!("Applying pending database migrations");
let mut entries = tokio::fs::read_dir(&config.databases_directory_path).await?;
while let Some(entry) = entries.next_entry().await? {
if !entry.file_name().to_string_lossy().ends_with(".sqlite") {
@ -319,7 +320,7 @@ impl Database {
device_id,
has_been_merged
from latest_document_versions
where relative_path = ?
where relative_path = ? and is_deleted = false
order by vault_update_id desc -- `latest_document_versions` only contains a single latest version of each document, however,
-- multiple documents can have the same `relative_path`, if they have been deleted. That's
-- why we only care about the latest version of the document with the given relative path.

View file

@ -1,7 +1,6 @@
use std::ffi::OsString;
use clap::Parser;
use clap_verbosity_flag::{InfoLevel, Verbosity};
use crate::cli::color_when::ColorWhen;
@ -12,9 +11,6 @@ pub struct Args {
#[arg(index = 1)]
pub config_path: Option<OsString>,
#[command(flatten)]
pub verbose: Verbosity<InfoLevel>,
#[arg(
long,
value_name = "WHEN",

View file

@ -3,7 +3,10 @@ use std::time::Duration;
use log::debug;
use serde::{Deserialize, Serialize};
use crate::consts::{DEFAULT_LOG_DIRECTORY, DEFAULT_LOG_ROTATION_INTERVAL};
use crate::{
consts::{DEFAULT_LOG_DIRECTORY, DEFAULT_LOG_LEVEL, DEFAULT_LOG_ROTATION_INTERVAL},
utils::log_level::LogLevel,
};
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LoggingConfig {
@ -12,6 +15,9 @@ pub struct LoggingConfig {
#[serde(default = "default_log_rotation", with = "humantime_serde")]
pub log_rotation: Duration,
#[serde(default = "default_log_level")]
pub log_level: LogLevel,
}
impl Default for LoggingConfig {
@ -19,6 +25,7 @@ impl Default for LoggingConfig {
Self {
log_directory: default_log_directory(),
log_rotation: default_log_rotation(),
log_level: default_log_level(),
}
}
}
@ -32,3 +39,8 @@ fn default_log_rotation() -> Duration {
debug!("Using default log rotation: {DEFAULT_LOG_ROTATION_INTERVAL:?}");
DEFAULT_LOG_ROTATION_INTERVAL
}
fn default_log_level() -> LogLevel {
debug!("Using default log level: Info");
DEFAULT_LOG_LEVEL
}

View file

@ -1,5 +1,7 @@
use std::time::Duration;
use crate::utils::log_level::LogLevel;
pub const DEFAULT_CONFIG_PATH: &str = "config.yml";
pub const DEFAULT_DATABASES_DIRECTORY_PATH: &str = "databases";
@ -14,6 +16,7 @@ pub const DEFAULT_MAX_CLIENTS_PER_VAULT: usize = 256;
pub const DEFAULT_LOG_DIRECTORY: &str = "logs";
pub const DEFAULT_LOG_ROTATION_INTERVAL: Duration = Duration::from_secs(60 * 60 * 24); // 1 day
pub const DEFAULT_LOG_LEVEL: LogLevel = LogLevel::Info;
pub const DEFAULT_MERGEABLE_FILE_EXTENSIONS: &[&str] = &["md", "txt"];

View file

@ -60,14 +60,7 @@ fn set_up_logging(
args: &Args,
logging_config: &config::logging_config::LoggingConfig,
) -> Result<(), SyncServerError> {
let level_filter = match args.verbose.log_level_filter() {
// We don't want to allow disabling all logging
log::LevelFilter::Off | log::LevelFilter::Error => tracing::Level::ERROR,
log::LevelFilter::Warn => tracing::Level::WARN,
log::LevelFilter::Info => tracing::Level::INFO,
log::LevelFilter::Debug => tracing::Level::DEBUG,
log::LevelFilter::Trace => tracing::Level::TRACE,
};
let level_filter = logging_config.log_level.as_tracing_level();
let env_filter = EnvFilter::builder()
.with_default_directive(level_filter.into())
@ -77,7 +70,7 @@ fn set_up_logging(
let use_colors = args.color.use_colors();
let is_debug_mode = args.verbose.log_level_filter() >= log::LevelFilter::Debug;
let is_debug_mode = logging_config.log_level.is_debug_or_trace();
let file_appender = RotatingFileWriter::new(
&logging_config.log_directory,

View file

@ -6,10 +6,10 @@ mod fetch_document_version;
mod fetch_document_version_content;
mod fetch_latest_document_version;
mod fetch_latest_documents;
mod index;
mod ping;
mod requests;
mod responses;
mod static_files;
mod update_document;
mod websocket;
@ -53,9 +53,10 @@ pub async fn create_server(config: Config) -> Result<()> {
let app = Router::new()
.nest("/", get_authed_routes(app_state.clone()))
.route("/", get(index::index))
.route("/", get(static_files::serve_index))
.route("/vaults/:vault_id/ping", get(ping::ping))
.route("/vaults/:vault_id/ws", get(websocket::websocket_handler))
.route("/*path", get(static_files::serve_static_file))
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(
app_state.config.server.max_body_size_mb * 1024 * 1024,

View file

@ -1,7 +0,0 @@
use axum::response::{Html, IntoResponse};
pub async fn index() -> impl IntoResponse {
const HTML_CONTENT: &str = include_str!("./assets/index.html");
let html_content = HTML_CONTENT;
Html(html_content)
}

View file

@ -0,0 +1,55 @@
use axum::{
extract::Path,
http::{StatusCode, header},
response::{Html, IntoResponse, Response},
};
use rust_embed::Embed;
#[derive(Embed)]
#[folder = "../docs/.vitepress/dist"]
pub struct DocsAssets;
pub async fn serve_static_file(Path(path): Path<String>) -> Response {
let path = if path.is_empty() { "index.html" } else { &path };
match DocsAssets::get(path) {
Some(content) => {
let mime_type = mime_guess::from_path(path).first_or_octet_stream();
let body = content.data.into_owned();
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime_type.as_ref())
.header(header::CACHE_CONTROL, "public, max-age=3600")
.body(body.into())
.unwrap()
}
None => {
// For SPA routing, if file not found, try serving index.html
match DocsAssets::get("index.html") {
Some(content) => {
Html(String::from_utf8_lossy(&content.data).to_string()).into_response()
}
None => (StatusCode::NOT_FOUND, "Documentation not found").into_response(),
}
}
}
}
pub async fn serve_index() -> Response {
match DocsAssets::get("index.html") {
Some(content) => Html(String::from_utf8_lossy(&content.data).to_string())
.into_response(),
None => {
Html(r"
<html>
<head><title>VaultLink Server</title></head>
<body>
<h1>VaultLink Sync Server</h1>
<p>Documentation not available. The server was compiled without embedded docs.</p>
</body>
</html>
").into_response()
}
}
}

View file

@ -2,6 +2,7 @@ pub mod dedup_paths;
pub mod find_first_available_path;
pub mod is_binary;
pub mod is_file_type_mergable;
pub mod log_level;
pub mod normalize;
pub mod rotating_file_writer;
pub mod sanitize_path;

View file

@ -0,0 +1,27 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
Error,
Warn,
Info,
Debug,
Trace,
}
impl LogLevel {
pub fn as_tracing_level(self) -> tracing::Level {
match self {
Self::Error => tracing::Level::ERROR,
Self::Warn => tracing::Level::WARN,
Self::Info => tracing::Level::INFO,
Self::Debug => tracing::Level::DEBUG,
Self::Trace => tracing::Level::TRACE,
}
}
pub fn is_debug_or_trace(self) -> bool {
matches!(self, Self::Debug | Self::Trace)
}
}

View file

@ -173,6 +173,7 @@ mod tests {
#[test]
fn test_write_creates_log_file_and_directory() {
let temp_dir = std::env::temp_dir().join("test_write_creates_log_file_and_directory");
let _ = fs::remove_dir_all(&temp_dir);
let mut writer =
RotatingFileWriter::new(&temp_dir, "test", Duration::from_secs(3600)).unwrap();
@ -195,6 +196,7 @@ mod tests {
#[test]
fn test_rotation_after_duration() {
let temp_dir = std::env::temp_dir().join("test_rotation_after_duration");
let _ = fs::remove_dir_all(&temp_dir);
// Use a very short rotation duration
// Note: We need to wait at least 1 second between rotations since
@ -227,6 +229,7 @@ mod tests {
fn test_calculate_next_rotation_time_no_existing_logs() {
let temp_dir =
std::env::temp_dir().join("test_calculate_next_rotation_time_no_existing_logs");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
@ -248,6 +251,7 @@ mod tests {
fn test_calculate_next_rotation_time_with_existing_log() {
let temp_dir =
std::env::temp_dir().join("test_calculate_next_rotation_time_with_existing_log");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
@ -286,6 +290,7 @@ mod tests {
#[test]
fn test_picks_latest_log_file() {
let temp_dir = std::env::temp_dir().join("test_picks_latest_log_file");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
@ -320,6 +325,7 @@ mod tests {
#[test]
fn test_ignores_malformed_filenames() {
let temp_dir = std::env::temp_dir().join("test_ignores_malformed_filenames");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();