Apply editorconfig
This commit is contained in:
parent
ad3191957a
commit
b05e415acf
119 changed files with 17155 additions and 14368 deletions
|
|
@ -3,92 +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."
|
||||
}
|
||||
],
|
||||
"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
|
||||
}
|
||||
}
|
||||
})
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,229 @@ 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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("");
|
||||
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("");
|
||||
|
||||
const dataDir = path.join(absolutePath, ".vaultlink");
|
||||
const dataFile = path.join(dataDir, "sync-data.json");
|
||||
const dataDir = path.join(absolutePath, ".vaultlink");
|
||||
const dataFile = path.join(dataDir, "sync-data.json");
|
||||
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
|
||||
const fileSystem = new NodeFileSystemOperations(absolutePath);
|
||||
const fileSystem = new NodeFileSystemOperations(absolutePath);
|
||||
|
||||
const ignorePatterns = [
|
||||
...(args.ignorePatterns ?? []),
|
||||
".vaultlink/**",
|
||||
".git/**"
|
||||
];
|
||||
const ignorePatterns = [
|
||||
...(args.ignorePatterns ?? []),
|
||||
".vaultlink/**",
|
||||
".git/**"
|
||||
];
|
||||
|
||||
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 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 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 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"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
});
|
||||
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"
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
});
|
||||
// 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));
|
||||
}
|
||||
});
|
||||
|
||||
client.logger.info("Starting sync client");
|
||||
client.logger.info("Starting sync client");
|
||||
|
||||
const fileWatcher = new FileWatcher(absolutePath, client);
|
||||
const fileWatcher = new FileWatcher(absolutePath, client);
|
||||
|
||||
client.addWebSocketStatusChangeListener(() => {
|
||||
const isConnected = client.isWebSocketConnected;
|
||||
client.logger.info(
|
||||
`WebSocket status changed: ${isConnected ? "connected" : "disconnected"}`
|
||||
);
|
||||
});
|
||||
client.addWebSocketStatusChangeListener(() => {
|
||||
const isConnected = client.isWebSocketConnected;
|
||||
client.logger.info(
|
||||
`WebSocket status changed: ${isConnected ? "connected" : "disconnected"}`
|
||||
);
|
||||
});
|
||||
|
||||
client.addRemainingSyncOperationsListener((remaining) => {
|
||||
if (remaining === 0) {
|
||||
client.logger.info("All sync operations completed");
|
||||
} else {
|
||||
client.logger.info(`${remaining} sync operations remaining`);
|
||||
}
|
||||
});
|
||||
client.addRemainingSyncOperationsListener((remaining) => {
|
||||
if (remaining === 0) {
|
||||
client.logger.info("All sync operations completed");
|
||||
} else {
|
||||
client.logger.info(`${remaining} sync operations remaining`);
|
||||
}
|
||||
});
|
||||
|
||||
const gracefulShutdown = async (signal: string): Promise<void> => {
|
||||
console.log(
|
||||
colorize(
|
||||
`\n${signal} received. Shutting down gracefully...`,
|
||||
"yellow"
|
||||
)
|
||||
);
|
||||
const gracefulShutdown = async (signal: string): Promise<void> => {
|
||||
console.log(
|
||||
colorize(
|
||||
`\n${signal} received. Shutting down gracefully...`,
|
||||
"yellow"
|
||||
)
|
||||
);
|
||||
|
||||
fileWatcher.stop();
|
||||
await client.waitUntilFinished();
|
||||
await client.destroy();
|
||||
console.log(colorize("Shutdown complete", "green"));
|
||||
process.exit(0);
|
||||
};
|
||||
fileWatcher.stop();
|
||||
await client.waitUntilFinished();
|
||||
await client.destroy();
|
||||
console.log(colorize("Shutdown complete", "green"));
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void gracefulShutdown("SIGINT");
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void gracefulShutdown("SIGTERM");
|
||||
});
|
||||
process.on("SIGINT", () => {
|
||||
void gracefulShutdown("SIGINT");
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void gracefulShutdown("SIGTERM");
|
||||
});
|
||||
|
||||
try {
|
||||
const connectionStatus = await client.checkConnection();
|
||||
if (!connectionStatus.isSuccessful) {
|
||||
console.error(
|
||||
colorize(
|
||||
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`,
|
||||
"red"
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
const connectionStatus = await client.checkConnection();
|
||||
if (!connectionStatus.isSuccessful) {
|
||||
console.error(
|
||||
colorize(
|
||||
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`,
|
||||
"red"
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`${colorize("✓", "green")} Server connection successful`);
|
||||
console.log(colorize("Press Ctrl+C to stop", "dim"));
|
||||
console.log("");
|
||||
console.log(`${colorize("✓", "green")} Server connection successful`);
|
||||
console.log(colorize("Press Ctrl+C to stop", "dim"));
|
||||
console.log("");
|
||||
|
||||
await client.start();
|
||||
fileWatcher.start();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
colorize(
|
||||
`Fatal error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"red"
|
||||
)
|
||||
);
|
||||
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);
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -85,8 +85,3 @@ If you have multiple URLs, you can also do:
|
|||
## API Documentation
|
||||
|
||||
See https://github.com/obsidianmd/obsidian-api
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,4 +12,4 @@
|
|||
font-size: var(--font-smallest);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue