Compare commits
7 commits
asch/split
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d99e249fa5 | |||
| 6647a4e632 | |||
| 201f9aeaee | |||
| 682dc74497 | |||
| 40fbd42b92 | |||
| 0e3132f96c | |||
| 4482e0155f |
207 changed files with 7147 additions and 15870 deletions
27
.github/dependabot.yml
vendored
27
.github/dependabot.yml
vendored
|
|
@ -1,27 +0,0 @@
|
||||||
# To get started with Dependabot version updates, you'll need to specify which
|
|
||||||
# package ecosystems to update and where the package manifests are located.
|
|
||||||
# Please see the documentation for all configuration options:
|
|
||||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
|
||||||
|
|
||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directories: ["/frontend", "/docs"]
|
|
||||||
schedule:
|
|
||||||
interval: "daily"
|
|
||||||
|
|
||||||
- package-ecosystem: "docker"
|
|
||||||
directories: ["**"]
|
|
||||||
schedule:
|
|
||||||
interval: "daily"
|
|
||||||
|
|
||||||
- package-ecosystem: "cargo"
|
|
||||||
directories: ["**"]
|
|
||||||
schedule:
|
|
||||||
interval: "daily"
|
|
||||||
|
|
||||||
# Disable this for security reasons
|
|
||||||
# - package-ecosystem: "github-actions"
|
|
||||||
# directories: ["**"]
|
|
||||||
# schedule:
|
|
||||||
# interval: "daily"
|
|
||||||
36
.github/workflows/check.yml
vendored
36
.github/workflows/check.yml
vendored
|
|
@ -1,36 +0,0 @@
|
||||||
name: Check
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
pull_request:
|
|
||||||
branches: ["main"]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
RUSTFLAGS: "-Dwarnings"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: self-hosted
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup Node.js environment
|
|
||||||
uses: actions/setup-node@v4.2.0
|
|
||||||
with:
|
|
||||||
node-version: "25.x"
|
|
||||||
check-latest: true
|
|
||||||
|
|
||||||
- name: Setup Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
toolchain: "1.92.0"
|
|
||||||
components: clippy, rustfmt
|
|
||||||
|
|
||||||
- name: Lint & test
|
|
||||||
run: scripts/check.sh
|
|
||||||
58
.github/workflows/deploy-docs.yml
vendored
58
.github/workflows/deploy-docs.yml
vendored
|
|
@ -1,58 +0,0 @@
|
||||||
name: Deploy Documentation
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- "docs/**"
|
|
||||||
- ".github/workflows/deploy-docs.yml"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: pages
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: self-hosted
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup Node.js environment
|
|
||||||
uses: actions/setup-node@v4.2.0
|
|
||||||
with:
|
|
||||||
node-version: "25.x"
|
|
||||||
check-latest: true
|
|
||||||
|
|
||||||
- name: Setup Pages
|
|
||||||
uses: actions/configure-pages@v4
|
|
||||||
|
|
||||||
- name: Build docs
|
|
||||||
run: scripts/build-docs.sh
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-pages-artifact@v3
|
|
||||||
with:
|
|
||||||
path: docs/.vitepress/dist
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
needs: build
|
|
||||||
runs-on: self-hosted
|
|
||||||
name: Deploy
|
|
||||||
steps:
|
|
||||||
- name: Deploy to GitHub Pages
|
|
||||||
id: deployment
|
|
||||||
uses: actions/deploy-pages@v4
|
|
||||||
72
.github/workflows/e2e.yml
vendored
72
.github/workflows/e2e.yml
vendored
|
|
@ -1,72 +0,0 @@
|
||||||
name: E2E tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
pull_request:
|
|
||||||
branches: ["main"]
|
|
||||||
schedule:
|
|
||||||
- cron: "0 * * * *"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: e2e-tests
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
RUSTFLAGS: "-Dwarnings"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: self-hosted
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup Node.js environment
|
|
||||||
uses: actions/setup-node@v4.2.0
|
|
||||||
with:
|
|
||||||
node-version: "25.x"
|
|
||||||
check-latest: true
|
|
||||||
|
|
||||||
- name: Setup Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
toolchain: "1.92.0"
|
|
||||||
components: clippy, rustfmt
|
|
||||||
|
|
||||||
- name: Setup rust
|
|
||||||
run: |
|
|
||||||
which sqlx || cargo install sqlx-cli
|
|
||||||
cd sync-server
|
|
||||||
sqlx database create --database-url sqlite://db.sqlite3
|
|
||||||
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
|
||||||
|
|
||||||
- name: E2E tests
|
|
||||||
run: |
|
|
||||||
cd sync-server
|
|
||||||
cargo run config-e2e.yml --color never &
|
|
||||||
SERVER_PID=$!
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
scripts/e2e.sh 8
|
|
||||||
EXIT_CODE=$?
|
|
||||||
|
|
||||||
kill $SERVER_PID 2>/dev/null || true
|
|
||||||
wait $SERVER_PID 2>/dev/null || true
|
|
||||||
|
|
||||||
exit $EXIT_CODE
|
|
||||||
|
|
||||||
- name: Upload e2e logs
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: e2e-logs
|
|
||||||
path: logs/
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Cleanup
|
|
||||||
if: always()
|
|
||||||
run: scripts/clean-up.sh
|
|
||||||
67
.github/workflows/publish-cli-docker.yml
vendored
67
.github/workflows/publish-cli-docker.yml
vendored
|
|
@ -1,67 +0,0 @@
|
||||||
name: Publish CLI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
tags: ["*"]
|
|
||||||
pull_request:
|
|
||||||
branches: ["main"]
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
IMAGE_NAME: ${{ github.repository }}-cli
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish-docker:
|
|
||||||
runs-on: self-hosted
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Install cosign
|
|
||||||
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
|
|
||||||
with:
|
|
||||||
cosign-release: "v2.2.4"
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
|
||||||
|
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
|
||||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract Docker metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
id: build-and-push
|
|
||||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
|
||||||
with:
|
|
||||||
context: frontend
|
|
||||||
file: frontend/local-client-cli/Dockerfile
|
|
||||||
platforms: linux/amd64
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: Sign the published Docker image
|
|
||||||
env:
|
|
||||||
TAGS: ${{ steps.meta.outputs.tags }}
|
|
||||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
|
||||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
|
||||||
59
.github/workflows/publish-plugin.yml
vendored
59
.github/workflows/publish-plugin.yml
vendored
|
|
@ -1,59 +0,0 @@
|
||||||
name: Publish Obsidian plugin
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ["*"]
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish-plugin:
|
|
||||||
runs-on: self-hosted
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup Node.js environment
|
|
||||||
uses: actions/setup-node@v4.2.0
|
|
||||||
with:
|
|
||||||
node-version: "25.x"
|
|
||||||
check-latest: true
|
|
||||||
|
|
||||||
- name: Build plugin
|
|
||||||
run: |
|
|
||||||
cd frontend
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
- name: Setup Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
toolchain: "1.92.0"
|
|
||||||
components: clippy, rustfmt
|
|
||||||
|
|
||||||
- name: Install cross-compilation tools
|
|
||||||
run: |
|
|
||||||
apt update
|
|
||||||
apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64
|
|
||||||
|
|
||||||
- name: Build Linux and Windows binaries
|
|
||||||
run: ./scripts/build-sync-server-binaries.sh
|
|
||||||
|
|
||||||
- name: Create release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
tag="${GITHUB_REF#refs/tags/}"
|
|
||||||
|
|
||||||
mkdir -p release
|
|
||||||
cp frontend/obsidian-plugin/dist/* release/
|
|
||||||
cp sync-server/artifacts/sync-server-* release/
|
|
||||||
cd release
|
|
||||||
|
|
||||||
gh release create "$tag" \
|
|
||||||
--title="$tag" \
|
|
||||||
--draft \
|
|
||||||
*
|
|
||||||
92
.github/workflows/publish-server-docker.yml
vendored
92
.github/workflows/publish-server-docker.yml
vendored
|
|
@ -1,92 +0,0 @@
|
||||||
name: Publish server Docker image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
tags: ["*"]
|
|
||||||
pull_request:
|
|
||||||
branches: ["main"]
|
|
||||||
|
|
||||||
env:
|
|
||||||
# Use docker.io for Docker Hub if empty
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
# github.repository as <account>/<repo>
|
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish-docker:
|
|
||||||
runs-on: self-hosted
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
# This is used to complete the identity challenge
|
|
||||||
# with sigstore/fulcio.
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# Install the cosign tool
|
|
||||||
# https://github.com/sigstore/cosign-installer
|
|
||||||
- name: Install cosign
|
|
||||||
if: github.ref_type == 'tag'
|
|
||||||
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
|
|
||||||
with:
|
|
||||||
cosign-release: "v2.2.4"
|
|
||||||
|
|
||||||
# Set up BuildKit Docker container builder to be able to build
|
|
||||||
# multi-platform images and export cache
|
|
||||||
# https://github.com/docker/setup-buildx-action
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
|
||||||
|
|
||||||
# Login against a Docker registry
|
|
||||||
# https://github.com/docker/login-action
|
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
|
||||||
if: github.ref_type == 'tag'
|
|
||||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
# Extract metadata (tags, labels) for Docker
|
|
||||||
# https://github.com/docker/metadata-action
|
|
||||||
- name: Extract Docker metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
|
|
||||||
# Build and push Docker image with Buildx
|
|
||||||
# https://github.com/docker/build-push-action
|
|
||||||
- name: Build and push Docker image
|
|
||||||
id: build-and-push
|
|
||||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
|
||||||
with:
|
|
||||||
context: sync-server
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: ${{ github.ref_type == 'tag' }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
# Sign the resulting Docker image digest.
|
|
||||||
# This will only write to the public Rekor transparency log when the Docker
|
|
||||||
# repository is public to avoid leaking data. If you would like to publish
|
|
||||||
# transparency data even for private images, pass --force to cosign below.
|
|
||||||
# https://github.com/sigstore/cosign
|
|
||||||
- name: Sign the published Docker image
|
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
|
||||||
env:
|
|
||||||
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
|
|
||||||
TAGS: ${{ steps.meta.outputs.tags }}
|
|
||||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
|
||||||
# This step uses the identity token to provision an ephemeral certificate
|
|
||||||
# against the sigstore community Fulcio instance.
|
|
||||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
|
||||||
|
|
@ -10,7 +10,7 @@ Each test is a `TestDefinition`: a client count and an ordered list of steps. Th
|
||||||
|
|
||||||
Tests that don't pause the server share a single server process (vault-name isolation). Tests that use `pause-server`/`resume-server` (SIGSTOP/SIGCONT) each get a dedicated server, since SIGSTOP freezes the entire process.
|
Tests that don't pause the server share a single server process (vault-name isolation). Tests that use `pause-server`/`resume-server` (SIGSTOP/SIGCONT) each get a dedicated server, since SIGSTOP freezes the entire process.
|
||||||
|
|
||||||
All tests run in parallel up to a concurrency limit.
|
The runner executes two sequential phases: regular tests on the shared server, then pause-server tests on dedicated servers. Within each phase tests run in parallel up to a concurrency limit.
|
||||||
|
|
||||||
## Step types
|
## Step types
|
||||||
|
|
||||||
|
|
@ -19,12 +19,15 @@ Clients always start with syncing disabled.
|
||||||
**File operations** (per-client, fire-and-forget — sync is enqueued but not awaited):
|
**File operations** (per-client, fire-and-forget — sync is enqueued but not awaited):
|
||||||
|
|
||||||
- `create`, `update`, `rename`, `delete`
|
- `create`, `update`, `rename`, `delete`
|
||||||
|
- `rename-next-write` — arm a deferred rename that fires the next time the given path is written. Lets a test race a user-rename against an in-flight remote create that's about to land at the same path.
|
||||||
|
|
||||||
**Sync control:**
|
**Sync control:**
|
||||||
|
|
||||||
- `sync` — wait for a specific client or all clients to finish pending operations
|
- `sync` — wait for a specific client or all clients to finish pending operations
|
||||||
- `barrier` — retry until all clients converge to identical file state (60s timeout)
|
- `barrier` — retry until all clients converge to identical file state (60s timeout)
|
||||||
- `enable-sync` / `disable-sync` — simulate going online/offline
|
- `enable-sync` / `disable-sync` — simulate going online/offline
|
||||||
|
- `reset` — reset a client's tracked sync state (keeps disk files); equivalent to a forced re-handshake on next enable
|
||||||
|
- `sleep` — wall-clock pause; use sparingly, prefer `barrier` / `sync`
|
||||||
|
|
||||||
**WebSocket control** (per-client):
|
**WebSocket control** (per-client):
|
||||||
|
|
||||||
|
|
@ -33,6 +36,12 @@ Clients always start with syncing disabled.
|
||||||
**Server control:**
|
**Server control:**
|
||||||
|
|
||||||
- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process
|
- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process
|
||||||
|
- `resume-server-until-history-then-pause` — resume the server, wait until a specific client observes a matching history entry (`CREATE`/`UPDATE`/`DELETE` for a path), then re-pause. Used to land exactly one operation across the wire.
|
||||||
|
|
||||||
|
**Fault injection** (per-client):
|
||||||
|
|
||||||
|
- `drop-next-create-response` — arm a one-shot interceptor that lets the next `POST /documents` reach the server (commit happens) but throws `SyncResetError` before the client sees the response, simulating connection loss after server commit.
|
||||||
|
- `wait-for-dropped-create-response` — wait until the armed drop has fired.
|
||||||
|
|
||||||
**Assertions:**
|
**Assertions:**
|
||||||
|
|
||||||
|
|
@ -72,7 +81,9 @@ export const myScenarioTest: TestDefinition = {
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => s.assertFileCount(1).assertContent("A.md", "hello")
|
verify: (s) => {
|
||||||
|
s.assertFileCount(1).assertContent("A.md", "hello");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
@ -81,14 +92,18 @@ export const myScenarioTest: TestDefinition = {
|
||||||
The `verify` callback receives an `AssertableState` object with chainable assertion methods:
|
The `verify` callback receives an `AssertableState` object with chainable assertion methods:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
s.assertFileCount(n) // exact file count
|
s.assertFileCount(n); // exact file count
|
||||||
s.assertFileExists("path") // file must exist
|
s.assertFileExists("path"); // file must exist
|
||||||
s.assertFileNotExists("path") // file must not exist
|
s.assertFileNotExists("path"); // file must not exist
|
||||||
s.assertContent("path", "expected") // exact content match
|
s.assertContent("path", "expected"); // exact content match
|
||||||
s.assertContains("path", "a", "b") // all substrings present
|
s.assertContains("path", "a", "b"); // all substrings present in file
|
||||||
s.assertAnyFileContains("text") // substring in any file
|
s.assertContainsAny("path", "a", "b"); // at least one substring present
|
||||||
s.assertContentInAtMostOneFile("text") // no duplicate content
|
s.assertAnyFileContains("text"); // substring present in some file
|
||||||
s.ifFileExists("path", (s) => ...) // conditional assertion
|
s.assertNoFileContains("text"); // substring absent from every file
|
||||||
|
s.assertSubstringCount("path", "x", 3); // substring appears exactly N times
|
||||||
|
s.assertContentInAtMostOneFile("text"); // no duplicate content
|
||||||
|
s.ifFileExists("path", (s) => { /* … */ }); // conditional block
|
||||||
|
s.getContent("path"); // raw content (or "" if missing)
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Register it in `src/test-registry.ts`:
|
2. Register it in `src/test-registry.ts`:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"test": "npm run build && node dist/cli.js"
|
"test": "npm run build && node dist/cli.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"commander": "^14.0.2",
|
||||||
"@types/node": "^25.0.2",
|
"@types/node": "^25.0.2",
|
||||||
"sync-client": "file:../sync-client",
|
"sync-client": "file:../sync-client",
|
||||||
"ts-loader": "^9.5.4",
|
"ts-loader": "^9.5.4",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { ServerManager } from "./server-manager";
|
||||||
import { PrefixedLogger } from "./prefixed-logger";
|
import { PrefixedLogger } from "./prefixed-logger";
|
||||||
import { TESTS } from "./test-registry";
|
import { TESTS } from "./test-registry";
|
||||||
import type { TestDefinition, TestResult } from "./test-definition";
|
import type { TestDefinition, TestResult } from "./test-definition";
|
||||||
import { parseConcurrency } from "./parse-concurrency";
|
import { parseArgs } from "./parse-args";
|
||||||
import { runWithConcurrency } from "./run-with-concurrency";
|
import { runWithConcurrency } from "./run-with-concurrency";
|
||||||
import { TOKEN, SERVER_BINARY_PATH, CONFIG_PATH } from "./consts";
|
import { TOKEN, SERVER_BINARY_PATH, CONFIG_PATH } from "./consts";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
|
@ -29,7 +29,31 @@ serverManager.installSignalHandlers();
|
||||||
|
|
||||||
function testUsesPauseServer(test: TestDefinition): boolean {
|
function testUsesPauseServer(test: TestDefinition): boolean {
|
||||||
return test.steps.some(
|
return test.steps.some(
|
||||||
(step) => step.type === "pause-server" || step.type === "resume-server"
|
(step) =>
|
||||||
|
step.type === "pause-server" ||
|
||||||
|
step.type === "resume-server" ||
|
||||||
|
step.type === "resume-server-until-history-then-pause"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk up from the CLI binary's location until we find a directory
|
||||||
|
* containing `sync-server/` and `frontend/`.
|
||||||
|
*/
|
||||||
|
function findProjectRoot(): string {
|
||||||
|
let dir = path.dirname(__filename);
|
||||||
|
const root = path.parse(dir).root;
|
||||||
|
while (dir !== root) {
|
||||||
|
if (
|
||||||
|
fs.existsSync(path.join(dir, "sync-server")) &&
|
||||||
|
fs.existsSync(path.join(dir, "frontend"))
|
||||||
|
) {
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
dir = path.dirname(dir);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Could not locate project root (no ancestor of ${__filename} contains both 'sync-server' and 'frontend')`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,15 +124,7 @@ async function runDedicatedServerTest(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const cwd = process.cwd();
|
const projectRoot = findProjectRoot();
|
||||||
let projectRoot = cwd;
|
|
||||||
|
|
||||||
if (cwd.endsWith("frontend/deterministic-tests")) {
|
|
||||||
projectRoot = path.resolve(cwd, "../..");
|
|
||||||
} else if (cwd.endsWith("frontend")) {
|
|
||||||
projectRoot = path.resolve(cwd, "..");
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverPath = path.join(projectRoot, SERVER_BINARY_PATH);
|
const serverPath = path.join(projectRoot, SERVER_BINARY_PATH);
|
||||||
if (!fs.existsSync(serverPath)) {
|
if (!fs.existsSync(serverPath)) {
|
||||||
logger.error(`Server binary not found at: ${serverPath}`);
|
logger.error(`Server binary not found at: ${serverPath}`);
|
||||||
|
|
@ -121,8 +137,7 @@ async function main(): Promise<void> {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterArg = process.argv.find((a) => a.startsWith("--filter="));
|
const { filter, concurrency } = parseArgs(process.argv);
|
||||||
const filter = filterArg?.slice("--filter=".length);
|
|
||||||
|
|
||||||
const testsToRun: [string, TestDefinition][] = [];
|
const testsToRun: [string, TestDefinition][] = [];
|
||||||
for (const [key, test] of Object.entries(TESTS)) {
|
for (const [key, test] of Object.entries(TESTS)) {
|
||||||
|
|
@ -147,7 +162,6 @@ async function main(): Promise<void> {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const concurrency = parseConcurrency();
|
|
||||||
const regularTests = testsToRun.filter(([, t]) => !testUsesPauseServer(t));
|
const regularTests = testsToRun.filter(([, t]) => !testUsesPauseServer(t));
|
||||||
const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t));
|
const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,7 @@ export const IS_SYNC_ENABLED_BY_DEFAULT = false;
|
||||||
export const WAIT_TIMEOUT_MS = 60_000;
|
export const WAIT_TIMEOUT_MS = 60_000;
|
||||||
export const WEBSOCKET_CONNECT_TIMEOUT_MS = 10_000;
|
export const WEBSOCKET_CONNECT_TIMEOUT_MS = 10_000;
|
||||||
export const WEBSOCKET_POLL_INTERVAL_MS = 50;
|
export const WEBSOCKET_POLL_INTERVAL_MS = 50;
|
||||||
|
|
||||||
|
export const SERVER_READY_POLL_INTERVAL_MS = 100;
|
||||||
|
export const SERVER_READY_MAX_ATTEMPTS = 50;
|
||||||
|
export const SERVER_START_MAX_ATTEMPTS = 5;
|
||||||
|
|
|
||||||
|
|
@ -37,15 +37,15 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
private readonly wsFactory = new ManagedWebSocketFactory();
|
private readonly wsFactory = new ManagedWebSocketFactory();
|
||||||
private nextWriteRename:
|
private nextWriteRename:
|
||||||
| {
|
| {
|
||||||
oldPath: RelativePath;
|
oldPath: RelativePath;
|
||||||
newPath: RelativePath;
|
newPath: RelativePath;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
private nextCreateResponseDrop:
|
private nextCreateResponseDrop:
|
||||||
| {
|
| {
|
||||||
dropped: Promise<void>;
|
dropped: Promise<void>;
|
||||||
resolveDropped: () => void;
|
resolveDropped: () => void;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
|
@ -82,10 +82,10 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
this.logger(`${prefix} WARN: ${line.message}`);
|
this.logger(`${prefix} WARN: ${line.message}`);
|
||||||
break;
|
break;
|
||||||
case LogLevel.INFO:
|
case LogLevel.INFO:
|
||||||
this.logger(`${prefix} ${line.message}`);
|
this.logger(`${prefix} INFO: ${line.message}`);
|
||||||
break;
|
break;
|
||||||
case LogLevel.DEBUG:
|
case LogLevel.DEBUG:
|
||||||
// Skip debug logs to reduce noise
|
this.logger(`${prefix} DEBUG: ${line.message}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -271,8 +271,18 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
this.log(`Cleanup waitUntilFinished failed: ${error}`);
|
this.log(`Cleanup waitUntilFinished failed: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Surface any background sync errors that arrived after the last
|
||||||
|
// waitForSync (e.g. between the final assert-consistent and here).
|
||||||
|
// Without this, regressions that fault the engine during the very
|
||||||
|
// last step of a test would be silently swallowed.
|
||||||
|
const pendingErrors = this.syncErrors.splice(0);
|
||||||
await this.client.destroy();
|
await this.client.destroy();
|
||||||
this.log("Cleanup complete");
|
this.log("Cleanup complete");
|
||||||
|
if (pendingErrors.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Client ${this.clientId} had ${pendingErrors.length} background sync error(s) during cleanup:\n${pendingErrors.map((e) => e.message).join("\n")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async read(path: RelativePath): Promise<Uint8Array> {
|
public override async read(path: RelativePath): Promise<Uint8Array> {
|
||||||
|
|
@ -312,6 +322,10 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// The rename consumed `path`. Skip the post-update enqueue below
|
||||||
|
// — it would send a syncLocallyUpdatedFile for a path that no
|
||||||
|
// longer exists.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.isSyncEnabled) {
|
if (!this.isSyncEnabled) {
|
||||||
|
|
@ -435,6 +449,11 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
DeterministicAgent.isCreateDocumentRequest(input, init)
|
DeterministicAgent.isCreateDocumentRequest(input, init)
|
||||||
) {
|
) {
|
||||||
this.nextCreateResponseDrop = undefined;
|
this.nextCreateResponseDrop = undefined;
|
||||||
|
try {
|
||||||
|
await response.body?.cancel();
|
||||||
|
} catch {
|
||||||
|
// Best-effort — body may already be consumed/closed.
|
||||||
|
}
|
||||||
drop.resolveDropped();
|
drop.resolveDropped();
|
||||||
throw new SyncResetError();
|
throw new SyncResetError();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,11 +139,21 @@ class ManagedWebSocket implements WebSocket {
|
||||||
}
|
}
|
||||||
|
|
||||||
public resume(): void {
|
public resume(): void {
|
||||||
this.paused = false;
|
// Drain buffered messages BEFORE flipping `paused` to false.
|
||||||
const messages = this.bufferedMessages.splice(0);
|
// If `externalOnMessage` is async (its return type is `unknown`),
|
||||||
for (const msg of messages) {
|
// dispatch yields control between buffered messages, and a fresh
|
||||||
this.externalOnMessage?.(msg);
|
// live `ws.onmessage` event firing during that yield would jump
|
||||||
|
// ahead of unprocessed buffered messages — silently reordering
|
||||||
|
// events relative to the wire. Keeping `paused = true` during the
|
||||||
|
// drain forces the live handler to keep buffering, so we splice
|
||||||
|
// those late arrivals onto the tail and dispatch them in order.
|
||||||
|
while (this.bufferedMessages.length > 0) {
|
||||||
|
const messages = this.bufferedMessages.splice(0);
|
||||||
|
for (const msg of messages) {
|
||||||
|
this.externalOnMessage?.(msg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
this.paused = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
|
public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
|
||||||
|
|
@ -157,6 +167,17 @@ class ManagedWebSocket implements WebSocket {
|
||||||
public addEventListener(
|
public addEventListener(
|
||||||
...args: Parameters<WebSocket["addEventListener"]>
|
...args: Parameters<WebSocket["addEventListener"]>
|
||||||
): void {
|
): void {
|
||||||
|
// Only the `.onmessage` setter routes through the pause buffer.
|
||||||
|
// If sync-client ever attaches "message" listeners via
|
||||||
|
// addEventListener instead, those messages would bypass pause/resume
|
||||||
|
// and deterministic tests would silently lose their fault injection.
|
||||||
|
if (args[0] === "message") {
|
||||||
|
throw new Error(
|
||||||
|
"ManagedWebSocket: addEventListener('message') bypasses the " +
|
||||||
|
"pause buffer. Use the .onmessage setter instead, or " +
|
||||||
|
"extend ManagedWebSocket to route message listeners."
|
||||||
|
);
|
||||||
|
}
|
||||||
this.ws.addEventListener(...args);
|
this.ws.addEventListener(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,6 +197,11 @@ class ManagedWebSocket implements WebSocket {
|
||||||
* for pause/resume control from the test harness
|
* for pause/resume control from the test harness
|
||||||
*/
|
*/
|
||||||
export class ManagedWebSocketFactory {
|
export class ManagedWebSocketFactory {
|
||||||
|
// Append-only: closed sockets stay tracked. Bounded per test (one
|
||||||
|
// factory per agent, each test discards its agents on cleanup), so
|
||||||
|
// not a real leak — but iterating over closed instances on
|
||||||
|
// pause/resume is a deliberate no-op since their `.onmessage` is
|
||||||
|
// already detached.
|
||||||
private readonly instances: ManagedWebSocket[] = [];
|
private readonly instances: ManagedWebSocket[] = [];
|
||||||
// Sticky pause state: applied to current instances on `pause()` AND
|
// Sticky pause state: applied to current instances on `pause()` AND
|
||||||
// to any new instance created later (e.g. WS reconnect after a
|
// to any new instance created later (e.g. WS reconnect after a
|
||||||
|
|
|
||||||
43
frontend/deterministic-tests/src/parse-args.ts
Normal file
43
frontend/deterministic-tests/src/parse-args.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import * as os from "node:os";
|
||||||
|
import { Command, InvalidArgumentError } from "commander";
|
||||||
|
|
||||||
|
export interface CliArgs {
|
||||||
|
filter: string | undefined;
|
||||||
|
concurrency: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(value: string): number {
|
||||||
|
const n = parseInt(value, 10);
|
||||||
|
if (isNaN(n) || n <= 0) {
|
||||||
|
throw new InvalidArgumentError("must be a positive integer");
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseArgs(argv: string[]): CliArgs {
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program
|
||||||
|
.name("deterministic-tests")
|
||||||
|
.description("Scripted multi-client sync tests against a real server")
|
||||||
|
.option(
|
||||||
|
"-f, --filter <substring>",
|
||||||
|
"Run only tests whose name contains this substring"
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"-j, --concurrency <number>",
|
||||||
|
"Number of tests to run in parallel",
|
||||||
|
parsePositiveInt,
|
||||||
|
os.cpus().length
|
||||||
|
);
|
||||||
|
|
||||||
|
program.parse(argv);
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
const opts = program.opts();
|
||||||
|
const filter = opts.filter as string | undefined;
|
||||||
|
const concurrency = opts.concurrency as number;
|
||||||
|
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
|
||||||
|
return { filter, concurrency };
|
||||||
|
}
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import * as os from "node:os";
|
|
||||||
|
|
||||||
export function parseConcurrency(): number {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
|
||||||
if (
|
|
||||||
(args[i] === "--concurrency" || args[i] === "-j") &&
|
|
||||||
i + 1 < args.length
|
|
||||||
) {
|
|
||||||
const n = parseInt(args[i + 1], 10);
|
|
||||||
if (!isNaN(n) && n > 0) {
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return os.cpus().length;
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,12 @@ import * as path from "node:path";
|
||||||
import { sleep } from "./utils/sleep";
|
import { sleep } from "./utils/sleep";
|
||||||
import { findFreePort } from "./utils/find-free-port";
|
import { findFreePort } from "./utils/find-free-port";
|
||||||
import type { Logger } from "sync-client";
|
import type { Logger } from "sync-client";
|
||||||
import { STOP_TIMEOUT_MS } from "./consts";
|
import {
|
||||||
|
STOP_TIMEOUT_MS,
|
||||||
|
SERVER_READY_POLL_INTERVAL_MS,
|
||||||
|
SERVER_READY_MAX_ATTEMPTS,
|
||||||
|
SERVER_START_MAX_ATTEMPTS
|
||||||
|
} from "./consts";
|
||||||
|
|
||||||
export class ServerControl {
|
export class ServerControl {
|
||||||
private process: ChildProcess | null = null;
|
private process: ChildProcess | null = null;
|
||||||
|
|
@ -38,10 +43,32 @@ export class ServerControl {
|
||||||
throw new Error("Server is already running");
|
throw new Error("Server is already running");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retry on bind failure: findFreePort closes its probe before we
|
||||||
|
// spawn, so under heavy parallelism another process can grab the
|
||||||
|
// same port. Each attempt picks a fresh port.
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 1; attempt <= SERVER_START_MAX_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
await this.startOnce();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
this.logger.warn(
|
||||||
|
`Server start attempt ${attempt}/${SERVER_START_MAX_ATTEMPTS} failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
// startOnce already cleaned up its child + tempdir on failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Server failed to start after ${SERVER_START_MAX_ATTEMPTS} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
||||||
|
{ cause: lastError instanceof Error ? lastError : undefined }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startOnce(): Promise<void> {
|
||||||
const reservation = await findFreePort();
|
const reservation = await findFreePort();
|
||||||
this._port = reservation.port;
|
this._port = reservation.port;
|
||||||
// Prefer tmpfs (/host/tmp) over disk-backed /tmp for faster SQLite I/O
|
const tmpBase = os.tmpdir();
|
||||||
const tmpBase = fs.existsSync("/host/tmp") ? "/host/tmp" : os.tmpdir();
|
|
||||||
this.tempDir = fs.mkdtempSync(path.join(tmpBase, "vault-link-test-"));
|
this.tempDir = fs.mkdtempSync(path.join(tmpBase, "vault-link-test-"));
|
||||||
const tempConfigPath = path.join(this.tempDir, "config.yml");
|
const tempConfigPath = path.join(this.tempDir, "config.yml");
|
||||||
const dbDir = path.join(this.tempDir, "databases");
|
const dbDir = path.join(this.tempDir, "databases");
|
||||||
|
|
@ -101,7 +128,9 @@ export class ServerControl {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async waitForReady(maxAttempts = 50): Promise<void> {
|
public async waitForReady(
|
||||||
|
maxAttempts: number = SERVER_READY_MAX_ATTEMPTS
|
||||||
|
): Promise<void> {
|
||||||
const pingUrl = `${this.remoteUri}/vaults/test/ping`;
|
const pingUrl = `${this.remoteUri}/vaults/test/ping`;
|
||||||
for (let i = 0; i < maxAttempts; i++) {
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
if (this.process?.exitCode !== null) {
|
if (this.process?.exitCode !== null) {
|
||||||
|
|
@ -118,7 +147,7 @@ export class ServerControl {
|
||||||
} catch {
|
} catch {
|
||||||
// Server not ready yet, continue polling
|
// Server not ready yet, continue polling
|
||||||
}
|
}
|
||||||
await sleep(100);
|
await sleep(SERVER_READY_POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
throw new Error("Server failed to start within timeout");
|
throw new Error("Server failed to start within timeout");
|
||||||
}
|
}
|
||||||
|
|
@ -208,10 +237,42 @@ export class ServerControl {
|
||||||
}
|
}
|
||||||
|
|
||||||
public isRunning(): boolean {
|
public isRunning(): boolean {
|
||||||
return this.process?.pid !== undefined;
|
const proc = this.process;
|
||||||
|
return (
|
||||||
|
proc !== null &&
|
||||||
|
proc.pid !== undefined &&
|
||||||
|
proc.exitCode === null &&
|
||||||
|
proc.signalCode === null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously SIGCONT-then-SIGKILL the child process. Safe to call
|
||||||
|
* from a `process.on("exit", ...)` handler, where async work cannot
|
||||||
|
* run. Used as a last-resort cleanup so a SIGSTOP'd server doesn't
|
||||||
|
* outlive the test runner and wedge the next CI invocation.
|
||||||
|
*/
|
||||||
|
public forceKillSync(): void {
|
||||||
|
const proc = this.process;
|
||||||
|
if (proc?.pid === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
process.kill(proc.pid, "SIGCONT");
|
||||||
|
} catch {
|
||||||
|
// Process may already be gone or never paused.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
process.kill(proc.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// Process already gone.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeConfigFile(destPath: string, dbDir: string): void {
|
private writeConfigFile(destPath: string, dbDir: string): void {
|
||||||
|
// Assumes config-e2e.yml has exactly one 2-space-indented `port:` and
|
||||||
|
// one `databases_directory_path:` (under `server:` and `database:`
|
||||||
|
// respectively)
|
||||||
const baseConfig = fs.readFileSync(this.baseConfigPath, "utf-8");
|
const baseConfig = fs.readFileSync(this.baseConfigPath, "utf-8");
|
||||||
const config = baseConfig
|
const config = baseConfig
|
||||||
.replace(/^\s*port:\s*\d+/m, ` port: ${this._port}`)
|
.replace(/^\s*port:\s*\d+/m, ` port: ${this._port}`)
|
||||||
|
|
|
||||||
|
|
@ -55,5 +55,17 @@ export class ServerManager {
|
||||||
})
|
})
|
||||||
.then(() => process.exit(143));
|
.then(() => process.exit(143));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Last-resort synchronous cleanup. Runs even when the process is
|
||||||
|
// exiting via process.exit() from unhandledRejection /
|
||||||
|
// uncaughtException — paths where async stopAll() cannot complete.
|
||||||
|
// SIGSTOP'd servers MUST receive SIGCONT before SIGKILL or the
|
||||||
|
// kernel keeps them as zombies holding the test's tmpdir, and the
|
||||||
|
// next CI run can't reuse the port.
|
||||||
|
process.on("exit", () => {
|
||||||
|
for (const server of this.activeServers) {
|
||||||
|
server.forceKillSync();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,9 @@ import { offlineDeleteVsRemoteUpdateTest } from "./tests/offline-delete-vs-remot
|
||||||
import { doubleOfflineCycleTest } from "./tests/double-offline-cycle.test";
|
import { doubleOfflineCycleTest } from "./tests/double-offline-cycle.test";
|
||||||
import { serverPauseRenameEditResumeTest } from "./tests/server-pause-rename-edit-resume.test";
|
import { serverPauseRenameEditResumeTest } from "./tests/server-pause-rename-edit-resume.test";
|
||||||
import { offlineUpdateBothThenDeleteOneTest } from "./tests/offline-update-both-then-delete-one.test";
|
import { offlineUpdateBothThenDeleteOneTest } from "./tests/offline-update-both-then-delete-one.test";
|
||||||
import { offlineCreateSamePathMergeableTest } from "./tests/offline-create-same-path-binary-conflict.test";
|
import { offlineCreateSamePathMergeableTest } from "./tests/offline-create-same-path-mergeable.test";
|
||||||
import { deleteDuringPendingCreateTest } from "./tests/delete-during-pending-create.test";
|
import { deleteDuringPendingCreateTest } from "./tests/delete-during-pending-create.test";
|
||||||
import { threeClientRenameCreateDeleteTest } from "./tests/three-client-rename-create-delete.test";
|
import { threeClientRenameCreateDeleteTest } from "./tests/three-client-rename-create-delete.test";
|
||||||
import { keyMigrationEventDropTest } from "./tests/key-migration-event-drop.test";
|
|
||||||
import { renameToPathOfUnconfirmedDeleteTest } from "./tests/rename-to-path-of-unconfirmed-delete.test";
|
import { renameToPathOfUnconfirmedDeleteTest } from "./tests/rename-to-path-of-unconfirmed-delete.test";
|
||||||
import { offlineEditThenMoveSameContentTest } from "./tests/offline-edit-then-move-same-content.test";
|
import { offlineEditThenMoveSameContentTest } from "./tests/offline-edit-then-move-same-content.test";
|
||||||
import { rapidCreateUpdateDeleteCycleTest } from "./tests/rapid-create-update-delete-cycle.test";
|
import { rapidCreateUpdateDeleteCycleTest } from "./tests/rapid-create-update-delete-cycle.test";
|
||||||
|
|
@ -47,10 +46,9 @@ import { offlineMoveThenRemoteDeleteTest } from "./tests/offline-move-then-remot
|
||||||
import { resetClearsRecentlyDeletedResurrectionTest } from "./tests/reset-clears-recently-deleted-resurrection.test";
|
import { resetClearsRecentlyDeletedResurrectionTest } from "./tests/reset-clears-recently-deleted-resurrection.test";
|
||||||
import { moveThenDeleteStalePathTest } from "./tests/move-then-delete-stale-path.test";
|
import { moveThenDeleteStalePathTest } from "./tests/move-then-delete-stale-path.test";
|
||||||
import { interruptedDeleteRetryTest } from "./tests/interrupted-delete-retry.test";
|
import { interruptedDeleteRetryTest } from "./tests/interrupted-delete-retry.test";
|
||||||
import { updateDoesNotSurvivesRemoteDeleteTest } from "./tests/update-survives-remote-delete.test";
|
import { updateDoesNotSurviveRemoteDeleteTest } from "./tests/update-does-not-survive-remote-delete.test";
|
||||||
import { movePreservesRemoteUpdateTest } from "./tests/move-preserves-remote-update.test";
|
import { movePreservesRemoteUpdateTest } from "./tests/move-preserves-remote-update.test";
|
||||||
import { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.test";
|
import { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.test";
|
||||||
import { migrateKeyPreservesExistingTest } from "./tests/migrate-key-preserves-existing.test";
|
|
||||||
import { watermarkAdvancesOnSkipTest } from "./tests/watermark-advances-on-skip.test";
|
import { watermarkAdvancesOnSkipTest } from "./tests/watermark-advances-on-skip.test";
|
||||||
import { watermarkGapRemoteUpdateNotRecordedTest } from "./tests/watermark-gap-remote-update-not-recorded.test";
|
import { watermarkGapRemoteUpdateNotRecordedTest } from "./tests/watermark-gap-remote-update-not-recorded.test";
|
||||||
import { queueResetLosesCoalescedLocalEditTest } from "./tests/queue-reset-loses-coalesced-local-edit.test";
|
import { queueResetLosesCoalescedLocalEditTest } from "./tests/queue-reset-loses-coalesced-local-edit.test";
|
||||||
|
|
@ -62,25 +60,25 @@ import { createRenameResponseSkipsFileTest } from "./tests/create-rename-respons
|
||||||
import { onlineCreateRenameConcurrentCreateOrphanTest } from "./tests/online-create-rename-concurrent-create-orphan.test";
|
import { onlineCreateRenameConcurrentCreateOrphanTest } from "./tests/online-create-rename-concurrent-create-orphan.test";
|
||||||
import { concurrentRenameFirstWinsTest } from "./tests/concurrent-rename-first-wins.test";
|
import { concurrentRenameFirstWinsTest } from "./tests/concurrent-rename-first-wins.test";
|
||||||
import { binaryToTextTransitionTest } from "./tests/binary-to-text-transition.test";
|
import { binaryToTextTransitionTest } from "./tests/binary-to-text-transition.test";
|
||||||
import { textPendingCreateNotDisplacedTest } from "./tests/1-text-pending-create-not-displaced.test";
|
import { textPendingCreateNotDisplacedTest } from "./tests/text-pending-create-not-displaced.test";
|
||||||
import { binaryPendingCreateNotDisplacedTest } from "./tests/2-binary-pending-create-not-displaced.test";
|
import { binaryPendingCreateNotDisplacedTest } from "./tests/binary-pending-create-not-displaced.test";
|
||||||
import { coalesceUpdateRemoteUpdateDataLossTest } from "./tests/3-coalesce-update-remote-update-data-loss.test";
|
import { coalesceUpdateRemoteUpdateDataLossTest } from "./tests/coalesce-update-remote-update-data-loss.test";
|
||||||
import { coalescedRemoteUpdateWatermarkLossTest } from "./tests/4-coalesced-remote-update-watermark-loss.test";
|
import { coalescedRemoteUpdateWatermarkLossTest } from "./tests/coalesced-remote-update-watermark-loss.test";
|
||||||
import { concurrentDeleteDuringRemoteUpdateTest } from "./tests/5-concurrent-delete-during-remote-update.test";
|
import { concurrentDeleteDuringRemoteUpdateTest } from "./tests/concurrent-delete-during-remote-update.test";
|
||||||
import { concurrentEditExactSamePositionTest } from "./tests/6-concurrent-edit-exact-same-position.test";
|
import { concurrentEditExactSamePositionTest } from "./tests/concurrent-edit-exact-same-position.test";
|
||||||
import { concurrentRenameAndCreateAtTargetTest as concurrentRenameAndCreateAtTargetRenameFirstTest } from "./tests/7-concurrent-rename-and-create-at-target.test";
|
import { concurrentRenameAndCreateAtTargetRenameFirstTest } from "./tests/concurrent-rename-and-create-at-target-rename-first.test";
|
||||||
import { concurrentRenameAndCreateAtTargetTest as concurrentRenameAndCreateAtTargetCreateFirstTest } from "./tests/8-concurrent-rename-and-create-at-target.test";
|
import { concurrentRenameAndCreateAtTargetCreateFirstTest } from "./tests/concurrent-rename-and-create-at-target-create-first.test";
|
||||||
import { concurrentRenameSameTargetTest } from "./tests/9-concurrent-rename-same-target.test";
|
import { concurrentRenameSameTargetTest } from "./tests/concurrent-rename-same-target.test";
|
||||||
import { concurrentUpdateDiffConsistencyTest } from "./tests/10-concurrent-update-diff-consistency.test";
|
import { concurrentUpdateDiffConsistencyTest } from "./tests/concurrent-update-diff-consistency.test";
|
||||||
import { userParenthesizedFileNotDeletedTest } from "./tests/10-user-parenthesized-file-not-deleted.test";
|
import { userParenthesizedFileNotDeletedTest } from "./tests/user-parenthesized-file-not-deleted.test";
|
||||||
import { createDeleteNoopTest } from "./tests/11-create-delete-noop.test";
|
import { createDeleteNoopTest } from "./tests/create-delete-noop.test";
|
||||||
import { createMergeDeleteTest } from "./tests/12-create-merge-delete.test";
|
import { createMergeDeleteTest } from "./tests/create-merge-delete.test";
|
||||||
import { moveIdenticalContentAmbiguityTest } from "./tests/13-move-identical-content-ambiguity.test";
|
import { moveIdenticalContentAmbiguityTest } from "./tests/move-identical-content-ambiguity.test";
|
||||||
import { createUpdateCoalesceServerPauseTest } from "./tests/15-create-update-coalesce-server-pause.test";
|
import { createUpdateCoalesceServerPauseTest } from "./tests/create-update-coalesce-server-pause.test";
|
||||||
import { createDuringReconciliationTest } from "./tests/16-create-during-reconciliation.test";
|
import { createDuringReconciliationTest } from "./tests/create-during-reconciliation.test";
|
||||||
import { createMergePreservesRenamedUpdateTest } from "./tests/17-create-merge-preserves-renamed-update.test";
|
import { createMergePreservesRenamedUpdateTest } from "./tests/create-merge-preserves-renamed-update.test";
|
||||||
import { createRenameCreateSamePathTest } from "./tests/18-create-rename-create-same-path.test";
|
import { createRenameCreateSamePathTest } from "./tests/create-rename-create-same-path.test";
|
||||||
import { moveChainThreeFilesTest } from "./tests/19-move-chain-three-files.test";
|
import { moveChainThreeFilesTest } from "./tests/move-chain-three-files.test";
|
||||||
import { deleteByOtherClientThenRecreateTest } from "./tests/delete-by-other-client-then-recreate.test";
|
import { deleteByOtherClientThenRecreateTest } from "./tests/delete-by-other-client-then-recreate.test";
|
||||||
import { onlineDeleteRecreateRapidCycleTest } from "./tests/online-delete-recreate-rapid-cycle.test";
|
import { onlineDeleteRecreateRapidCycleTest } from "./tests/online-delete-recreate-rapid-cycle.test";
|
||||||
import { onlineEditVsDeleteConvergenceTest } from "./tests/online-edit-vs-delete-convergence.test";
|
import { onlineEditVsDeleteConvergenceTest } from "./tests/online-edit-vs-delete-convergence.test";
|
||||||
|
|
@ -147,7 +145,6 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||||
"offline-create-same-path-mergeable": offlineCreateSamePathMergeableTest,
|
"offline-create-same-path-mergeable": offlineCreateSamePathMergeableTest,
|
||||||
"delete-during-pending-create": deleteDuringPendingCreateTest,
|
"delete-during-pending-create": deleteDuringPendingCreateTest,
|
||||||
"three-client-rename-create-delete": threeClientRenameCreateDeleteTest,
|
"three-client-rename-create-delete": threeClientRenameCreateDeleteTest,
|
||||||
"key-migration-event-drop": keyMigrationEventDropTest,
|
|
||||||
"rename-to-path-of-unconfirmed-delete": renameToPathOfUnconfirmedDeleteTest,
|
"rename-to-path-of-unconfirmed-delete": renameToPathOfUnconfirmedDeleteTest,
|
||||||
"offline-edit-then-move-same-content": offlineEditThenMoveSameContentTest,
|
"offline-edit-then-move-same-content": offlineEditThenMoveSameContentTest,
|
||||||
"rapid-create-update-delete-cycle": rapidCreateUpdateDeleteCycleTest,
|
"rapid-create-update-delete-cycle": rapidCreateUpdateDeleteCycleTest,
|
||||||
|
|
@ -160,11 +157,10 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||||
"move-then-delete-stale-path": moveThenDeleteStalePathTest,
|
"move-then-delete-stale-path": moveThenDeleteStalePathTest,
|
||||||
"offline-delete-vs-remote-update": offlineDeleteVsRemoteUpdateTest,
|
"offline-delete-vs-remote-update": offlineDeleteVsRemoteUpdateTest,
|
||||||
"interrupted-delete-retry": interruptedDeleteRetryTest,
|
"interrupted-delete-retry": interruptedDeleteRetryTest,
|
||||||
"update-survives-remote-delete": updateDoesNotSurvivesRemoteDeleteTest,
|
"update-does-not-survive-remote-delete": updateDoesNotSurviveRemoteDeleteTest,
|
||||||
"move-preserves-remote-update": movePreservesRemoteUpdateTest,
|
"move-preserves-remote-update": movePreservesRemoteUpdateTest,
|
||||||
"recently-deleted-cleared-on-reconnect":
|
"recently-deleted-cleared-on-reconnect":
|
||||||
recentlyDeletedClearedOnReconnectTest,
|
recentlyDeletedClearedOnReconnectTest,
|
||||||
"migrate-key-preserves-existing": migrateKeyPreservesExistingTest,
|
|
||||||
"watermark-advances-on-skip": watermarkAdvancesOnSkipTest,
|
"watermark-advances-on-skip": watermarkAdvancesOnSkipTest,
|
||||||
"watermark-gap-remote-update-not-recorded":
|
"watermark-gap-remote-update-not-recorded":
|
||||||
watermarkGapRemoteUpdateNotRecordedTest,
|
watermarkGapRemoteUpdateNotRecordedTest,
|
||||||
|
|
|
||||||
|
|
@ -266,18 +266,10 @@ export class TestRunner {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final attempt — let the error propagate
|
throw new Error(
|
||||||
await this.waitAllAgentsSettled();
|
`Convergence timed out after ${CONVERGENCE_TIMEOUT_MS}ms: ${lastError?.message ?? "no consistency check ran"}`,
|
||||||
|
{ cause: lastError }
|
||||||
try {
|
);
|
||||||
await this.assertConsistent();
|
|
||||||
this.logger.info("Barrier complete: all clients converged");
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(
|
|
||||||
`Convergence timed out after ${CONVERGENCE_TIMEOUT_MS}ms: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
{ cause: lastError }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,14 @@ import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = {
|
export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = {
|
||||||
description:
|
description:
|
||||||
"Client 0 edits a file while client 1 is offline. Client 1 reconnects " +
|
"Divergent offline edits with text-merge expectation. Client 0's " +
|
||||||
"and immediately edits the same file. Both edits should be preserved.",
|
"remote update fully lands before Client 1 reconnects (`sync`-after " +
|
||||||
|
"the c0 update enforces this), so Client 1's offline edit merges " +
|
||||||
|
"against a server-known version, not a coalesced batch. Both " +
|
||||||
|
"additions must survive in the final merged content. (Filename's " +
|
||||||
|
"'coalesce' framing is aspirational — a true update-coalesce test " +
|
||||||
|
"would skip the c0 sync and queue overlapping local + remote " +
|
||||||
|
"updates against the same parent version.)",
|
||||||
clients: 2,
|
clients: 2,
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { AssertableState } from "../utils/assertable-state";
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
|
export const concurrentRenameAndCreateAtTargetCreateFirstTest: TestDefinition = {
|
||||||
description:
|
description:
|
||||||
"One client renames X to Y while another creates a new file at Y, " +
|
"One client renames X to Y while another creates a new file at Y, " +
|
||||||
"both offline. After syncing, Y should contain merged content from " +
|
"both offline. After syncing, Y should contain merged content from " +
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { AssertableState } from "../utils/assertable-state";
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
|
export const concurrentRenameAndCreateAtTargetRenameFirstTest: TestDefinition = {
|
||||||
description:
|
description:
|
||||||
"One client renames X to Y while another creates a new file at Y, " +
|
"One client renames X to Y while another creates a new file at Y, " +
|
||||||
"both offline. We can't merge the create because it would result in a cycle",
|
"both offline. We can't merge the create because it would result in a cycle",
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import type { AssertableState } from "../utils/assertable-state";
|
|
||||||
import type { TestDefinition } from "../test-definition";
|
|
||||||
|
|
||||||
export const keyMigrationEventDropTest: TestDefinition = {
|
|
||||||
description:
|
|
||||||
"Client 0 creates a file and immediately updates it while the server is paused. " +
|
|
||||||
"After resume, both clients should have the updated content.",
|
|
||||||
clients: 2,
|
|
||||||
steps: [
|
|
||||||
{ type: "enable-sync", client: 0 },
|
|
||||||
{ type: "enable-sync", client: 1 },
|
|
||||||
{ type: "barrier" },
|
|
||||||
|
|
||||||
{ type: "pause-server" },
|
|
||||||
|
|
||||||
{
|
|
||||||
type: "create",
|
|
||||||
client: 0,
|
|
||||||
path: "A.md",
|
|
||||||
content: "initial content"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "update",
|
|
||||||
client: 0,
|
|
||||||
path: "A.md",
|
|
||||||
content: "updated content"
|
|
||||||
},
|
|
||||||
|
|
||||||
{ type: "resume-server" },
|
|
||||||
{ type: "barrier" },
|
|
||||||
|
|
||||||
{
|
|
||||||
type: "assert-consistent",
|
|
||||||
verify: (s: AssertableState): void => {
|
|
||||||
s.assertFileCount(1).assertContent("A.md", "updated content");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import type { AssertableState } from "../utils/assertable-state";
|
|
||||||
import type { TestDefinition } from "../test-definition";
|
|
||||||
|
|
||||||
export const migrateKeyPreservesExistingTest: TestDefinition = {
|
|
||||||
description:
|
|
||||||
"Client 0 creates a file and immediately updates it while the server is paused. " +
|
|
||||||
"After resume, the update must not be lost.",
|
|
||||||
clients: 2,
|
|
||||||
steps: [
|
|
||||||
{ type: "enable-sync", client: 0 },
|
|
||||||
{ type: "enable-sync", client: 1 },
|
|
||||||
{ type: "barrier" },
|
|
||||||
|
|
||||||
{ type: "pause-server" },
|
|
||||||
|
|
||||||
{ type: "create", client: 0, path: "A.md", content: "initial" },
|
|
||||||
{
|
|
||||||
type: "update",
|
|
||||||
client: 0,
|
|
||||||
path: "A.md",
|
|
||||||
content: "updated by client 0"
|
|
||||||
},
|
|
||||||
|
|
||||||
{ type: "resume-server" },
|
|
||||||
{ type: "barrier" },
|
|
||||||
|
|
||||||
{
|
|
||||||
type: "assert-consistent",
|
|
||||||
verify: (s: AssertableState): void => {
|
|
||||||
s.assertFileCount(1).assertContains(
|
|
||||||
"A.md",
|
|
||||||
"updated by client 0"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
@ -8,7 +8,7 @@ export default [
|
||||||
"sync-client/src/services/types.ts",
|
"sync-client/src/services/types.ts",
|
||||||
"**/dist/",
|
"**/dist/",
|
||||||
"**/*.mjs",
|
"**/*.mjs",
|
||||||
"**/*.js",
|
"**/*.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
...tseslint.config({
|
...tseslint.config({
|
||||||
|
|
@ -17,9 +17,7 @@ export default [
|
||||||
},
|
},
|
||||||
extends: [eslint.configs.recommended, tseslint.configs.all],
|
extends: [eslint.configs.recommended, tseslint.configs.all],
|
||||||
rules: {
|
rules: {
|
||||||
"no-console": "error",
|
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"curly": ["error", "all"],
|
|
||||||
"@typescript-eslint/restrict-template-expressions": "off",
|
"@typescript-eslint/restrict-template-expressions": "off",
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
"@typescript-eslint/no-floating-promises": [
|
"@typescript-eslint/no-floating-promises": [
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>VaultLink2</title>
|
|
||||||
<link rel="icon" href="data:," />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"name": "history-ui",
|
|
||||||
"version": "0.14.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite dev --host 0.0.0.0",
|
|
||||||
"build": "vite build",
|
|
||||||
"test": "echo 'no tests yet'"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
|
||||||
"svelte": "^5.0.0",
|
|
||||||
"vite": "^6.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { auth, nav, toasts } from "./lib/stores.svelte";
|
|
||||||
import { listVaults } from "./lib/api";
|
|
||||||
import Login from "./components/Login.svelte";
|
|
||||||
import VaultPicker from "./components/VaultPicker.svelte";
|
|
||||||
import Dashboard from "./components/Dashboard.svelte";
|
|
||||||
import ToastContainer from "./components/ToastContainer.svelte";
|
|
||||||
|
|
||||||
let restoring = $state(true);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const saved = auth.tryRestore();
|
|
||||||
if (!saved) {
|
|
||||||
restoring = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
listVaults(saved.token)
|
|
||||||
.then((response) => {
|
|
||||||
auth.authenticate(
|
|
||||||
saved.token,
|
|
||||||
response.userName,
|
|
||||||
response.vaults
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
saved.vaultId &&
|
|
||||||
response.vaults.some(
|
|
||||||
(v) => v.name === saved.vaultId
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
auth.selectVault(saved.vaultId);
|
|
||||||
}
|
|
||||||
restoring = false;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
restoring = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if restoring}
|
|
||||||
<div class="loading-screen">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
</div>
|
|
||||||
{:else if !auth.token}
|
|
||||||
<Login />
|
|
||||||
{:else if !auth.isAuthenticated}
|
|
||||||
<VaultPicker />
|
|
||||||
{:else}
|
|
||||||
<Dashboard
|
|
||||||
selectedDocumentId={nav.current.kind === "document" ? nav.current.documentId : undefined}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<ToastContainer />
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.loading-screen {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 3px solid var(--bg-tertiary);
|
|
||||||
border-top-color: var(--accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.6s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
:root {
|
|
||||||
--bg: #0d1117;
|
|
||||||
--bg-secondary: #161b22;
|
|
||||||
--bg-tertiary: #21262d;
|
|
||||||
--bg-hover: #30363d;
|
|
||||||
--border: #30363d;
|
|
||||||
--border-light: #21262d;
|
|
||||||
--text: #e6edf3;
|
|
||||||
--text-muted: #8b949e;
|
|
||||||
--text-subtle: #6e7681;
|
|
||||||
--accent: #58a6ff;
|
|
||||||
--accent-hover: #79c0ff;
|
|
||||||
--green: #3fb950;
|
|
||||||
--green-bg: rgba(63, 185, 80, 0.15);
|
|
||||||
--red: #f85149;
|
|
||||||
--red-bg: rgba(248, 81, 73, 0.15);
|
|
||||||
--orange: #d29922;
|
|
||||||
--orange-bg: rgba(210, 153, 34, 0.15);
|
|
||||||
--purple: #bc8cff;
|
|
||||||
--purple-bg: rgba(188, 140, 255, 0.15);
|
|
||||||
--blue: #58a6ff;
|
|
||||||
--blue-bg: rgba(88, 166, 255, 0.15);
|
|
||||||
--mono: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
|
|
||||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Noto Sans, Helvetica, Arial, sans-serif;
|
|
||||||
--radius: 6px;
|
|
||||||
--radius-sm: 4px;
|
|
||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body, #app {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--sans);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
color: inherit;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 8px 12px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
@ -1,346 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { VersionEvent } from "../lib/view-types";
|
|
||||||
import {
|
|
||||||
absoluteTime,
|
|
||||||
formatBytes
|
|
||||||
} from "../lib/stores.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
versions: VersionEvent[];
|
|
||||||
loading: boolean;
|
|
||||||
hasMore: boolean;
|
|
||||||
onLoadMore: () => void;
|
|
||||||
onSelectDocument: (documentId: string) => void;
|
|
||||||
onTimeTravel: (vaultUpdateId: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
versions,
|
|
||||||
loading,
|
|
||||||
hasMore,
|
|
||||||
onLoadMore,
|
|
||||||
onSelectDocument,
|
|
||||||
onTimeTravel
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
function timeOfDay(dateStr: string): string {
|
|
||||||
return new Date(dateStr).toLocaleTimeString("en-US", {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group by day
|
|
||||||
let grouped = $derived.by(() => {
|
|
||||||
const groups: { date: string; items: VersionEvent[] }[] = [];
|
|
||||||
const sortedDesc = [...versions].sort(
|
|
||||||
(a, b) => b.vaultUpdateId - a.vaultUpdateId
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const v of sortedDesc) {
|
|
||||||
const date = new Date(v.updatedDate).toLocaleDateString(
|
|
||||||
"en-US",
|
|
||||||
{ month: "long", day: "numeric", year: "numeric" }
|
|
||||||
);
|
|
||||||
const last = groups.at(-1);
|
|
||||||
if (last && last.date === date) {
|
|
||||||
last.items.push(v);
|
|
||||||
} else {
|
|
||||||
groups.push({ date, items: [v] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
});
|
|
||||||
|
|
||||||
const actionColors: Record<string, string> = {
|
|
||||||
created: "var(--green)",
|
|
||||||
updated: "var(--blue)",
|
|
||||||
renamed: "var(--orange)",
|
|
||||||
deleted: "var(--red)",
|
|
||||||
restored: "var(--purple)"
|
|
||||||
};
|
|
||||||
|
|
||||||
const actionBgColors: Record<string, string> = {
|
|
||||||
created: "var(--green-bg)",
|
|
||||||
updated: "var(--blue-bg)",
|
|
||||||
renamed: "var(--orange-bg)",
|
|
||||||
deleted: "var(--red-bg)",
|
|
||||||
restored: "var(--purple-bg)"
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="feed">
|
|
||||||
{#if loading && versions.length === 0}
|
|
||||||
<div class="feed-loading">Loading activity...</div>
|
|
||||||
{:else if versions.length === 0}
|
|
||||||
<div class="feed-empty">
|
|
||||||
No activity yet. Documents will appear here as sync clients
|
|
||||||
make changes.
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#each grouped as group}
|
|
||||||
<div class="day-group">
|
|
||||||
<div class="day-header">{group.date}</div>
|
|
||||||
<div class="items-list">
|
|
||||||
{#each group.items as event}
|
|
||||||
<div class="feed-item">
|
|
||||||
<button
|
|
||||||
class="feed-item-main"
|
|
||||||
onclick={() =>
|
|
||||||
onSelectDocument(event.documentId)}
|
|
||||||
>
|
|
||||||
<div class="feed-timeline">
|
|
||||||
<div
|
|
||||||
class="timeline-dot"
|
|
||||||
style="background: {actionColors[
|
|
||||||
event.action
|
|
||||||
]}"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="feed-content">
|
|
||||||
<div class="feed-header">
|
|
||||||
<span
|
|
||||||
class="action-pill"
|
|
||||||
style="color: {actionColors[
|
|
||||||
event.action
|
|
||||||
]}; background: {actionBgColors[
|
|
||||||
event.action
|
|
||||||
]}"
|
|
||||||
>
|
|
||||||
{event.action}
|
|
||||||
</span>
|
|
||||||
<span class="feed-path">
|
|
||||||
{#if event.action === "renamed" && event.previousPath}
|
|
||||||
<span class="prev-path"
|
|
||||||
>{event.previousPath}</span
|
|
||||||
>
|
|
||||||
<span class="arrow"
|
|
||||||
>→</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
<span
|
|
||||||
class:deleted={event.action ===
|
|
||||||
"deleted"}
|
|
||||||
>
|
|
||||||
{event.relativePath}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="feed-meta">
|
|
||||||
<span class="feed-user"
|
|
||||||
>{event.userId}</span
|
|
||||||
>
|
|
||||||
<span class="feed-dot"
|
|
||||||
>·</span
|
|
||||||
>
|
|
||||||
<span class="feed-size"
|
|
||||||
>{formatBytes(
|
|
||||||
event.contentSize
|
|
||||||
)}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="feed-time-btn"
|
|
||||||
title="Time travel to {absoluteTime(event.updatedDate)}"
|
|
||||||
onclick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onTimeTravel(event.vaultUpdateId);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{timeOfDay(event.updatedDate)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if hasMore}
|
|
||||||
<div class="load-more">
|
|
||||||
<button class="load-more-btn" onclick={onLoadMore}>
|
|
||||||
Load older activity
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.feed {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-loading,
|
|
||||||
.feed-empty {
|
|
||||||
padding: 48px 16px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-group {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-header {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: var(--bg);
|
|
||||||
border-bottom: 1px solid var(--border-light);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
width: 100%;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-item:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-item-main {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 10px 0 10px 16px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.items-list {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.items-list::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 21px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 2px;
|
|
||||||
background: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-timeline {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
width: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding-top: 6px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-pill {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 1px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-path {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 13px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prev-path {
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow {
|
|
||||||
color: var(--text-subtle);
|
|
||||||
margin: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deleted {
|
|
||||||
text-decoration: line-through;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-dot {
|
|
||||||
color: var(--text-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-time-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 16px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
color: var(--text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-left: 1px solid transparent;
|
|
||||||
transition: color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-time-btn:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
border-left-color: var(--border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more {
|
|
||||||
padding: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more-btn {
|
|
||||||
padding: 8px 20px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--accent);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more-btn:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
confirmLabel: string;
|
|
||||||
destructive?: boolean;
|
|
||||||
loading?: boolean;
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
confirmLabel,
|
|
||||||
destructive = false,
|
|
||||||
loading = false,
|
|
||||||
onConfirm,
|
|
||||||
onCancel
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") onCancel();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window on:keydown={handleKeydown} />
|
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<div class="backdrop" onclick={onCancel} role="presentation">
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
||||||
<div
|
|
||||||
class="dialog"
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
role="dialog"
|
|
||||||
aria-label={title}
|
|
||||||
>
|
|
||||||
<h3 class="dialog-title">{title}</h3>
|
|
||||||
<p class="dialog-message">{message}</p>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<button class="btn-cancel" onclick={onCancel} disabled={loading}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-confirm"
|
|
||||||
class:destructive
|
|
||||||
onclick={onConfirm}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{#if loading}
|
|
||||||
<span class="btn-spinner"></span>
|
|
||||||
{/if}
|
|
||||||
{confirmLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
animation: fade-in 0.15s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
max-width: 480px;
|
|
||||||
width: calc(100% - 32px);
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
||||||
animation: scale-in 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-message {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text-muted);
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel:hover:not(:disabled) {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-confirm {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
transition: background 0.15s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-confirm:hover:not(:disabled) {
|
|
||||||
background: var(--accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-confirm.destructive {
|
|
||||||
background: var(--red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-confirm.destructive:hover:not(:disabled) {
|
|
||||||
background: #f97583;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-confirm:disabled,
|
|
||||||
.btn-cancel:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-spinner {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.6s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes scale-in {
|
|
||||||
from { transform: scale(0.95); opacity: 0; }
|
|
||||||
to { transform: scale(1); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,508 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
auth,
|
|
||||||
nav,
|
|
||||||
toasts,
|
|
||||||
buildTree,
|
|
||||||
enrichVersions,
|
|
||||||
relativeTime,
|
|
||||||
formatBytes,
|
|
||||||
type View
|
|
||||||
} from "../lib/stores.svelte";
|
|
||||||
import type { DocumentVersionWithoutContent } from "../lib/types/DocumentVersionWithoutContent";
|
|
||||||
import type { VaultHistoryResponse } from "../lib/types/VaultHistoryResponse";
|
|
||||||
import type { VersionEvent, TreeNode } from "../lib/view-types";
|
|
||||||
import FileTree from "./FileTree.svelte";
|
|
||||||
import ActivityFeed from "./ActivityFeed.svelte";
|
|
||||||
import DocumentDetail from "./DocumentDetail.svelte";
|
|
||||||
import TimeSlider from "./TimeSlider.svelte";
|
|
||||||
import Header from "./Header.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
selectedDocumentId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { selectedDocumentId }: Props = $props();
|
|
||||||
|
|
||||||
// Data
|
|
||||||
let latestDocuments = $state<DocumentVersionWithoutContent[]>([]);
|
|
||||||
let historyVersions = $state<DocumentVersionWithoutContent[]>([]);
|
|
||||||
let historyHasMore = $state(false);
|
|
||||||
let loadingDocs = $state(true);
|
|
||||||
let loadingHistory = $state(true);
|
|
||||||
let showDeleted = $state(false);
|
|
||||||
let searchQuery = $state("");
|
|
||||||
let activeTab = $state<"activity" | "files">("activity");
|
|
||||||
|
|
||||||
// Time travel
|
|
||||||
let maxUpdateId = $state(0);
|
|
||||||
let minUpdateId = $state(0);
|
|
||||||
let timeSliderValue = $state<number | null>(null);
|
|
||||||
|
|
||||||
// Derived
|
|
||||||
let tree = $derived(buildTree(latestDocuments, showDeleted));
|
|
||||||
let enrichedHistory = $derived(enrichVersions(historyVersions));
|
|
||||||
let stats = $derived({
|
|
||||||
totalDocs: latestDocuments.filter((d) => !d.isDeleted).length,
|
|
||||||
deletedDocs: latestDocuments.filter((d) => d.isDeleted).length,
|
|
||||||
totalSize: latestDocuments
|
|
||||||
.filter((d) => !d.isDeleted)
|
|
||||||
.reduce((sum, d) => sum + d.contentSize, 0),
|
|
||||||
users: [...new Set(latestDocuments.map((d) => d.userId))]
|
|
||||||
});
|
|
||||||
|
|
||||||
let filteredTree = $derived.by(() => {
|
|
||||||
if (!searchQuery) return tree;
|
|
||||||
return filterTree(tree, searchQuery.toLowerCase());
|
|
||||||
});
|
|
||||||
|
|
||||||
function filterTree(node: TreeNode, query: string): TreeNode {
|
|
||||||
if (!node.isFolder) {
|
|
||||||
return node.name.toLowerCase().includes(query) ? node : { ...node, children: [] };
|
|
||||||
}
|
|
||||||
const filteredChildren = node.children
|
|
||||||
.map((c) => filterTree(c, query))
|
|
||||||
.filter((c) => c.isFolder ? c.children.length > 0 : true)
|
|
||||||
.filter((c) => !c.isFolder || c.children.length > 0);
|
|
||||||
return { ...node, children: filteredChildren };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time travel: compute vault state at a given updateId
|
|
||||||
let timeFilteredDocs = $derived.by(() => {
|
|
||||||
if (timeSliderValue === null || timeSliderValue >= maxUpdateId) {
|
|
||||||
return latestDocuments;
|
|
||||||
}
|
|
||||||
// From all history, find the latest version per documentId at or before timeSliderValue
|
|
||||||
const byDoc = new Map<string, DocumentVersionWithoutContent>();
|
|
||||||
for (const v of historyVersions) {
|
|
||||||
if (v.vaultUpdateId <= timeSliderValue) {
|
|
||||||
const existing = byDoc.get(v.documentId);
|
|
||||||
if (
|
|
||||||
!existing ||
|
|
||||||
v.vaultUpdateId > existing.vaultUpdateId
|
|
||||||
) {
|
|
||||||
byDoc.set(v.documentId, v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...byDoc.values()];
|
|
||||||
});
|
|
||||||
|
|
||||||
let timeFilteredTree = $derived(
|
|
||||||
buildTree(
|
|
||||||
timeSliderValue !== null && timeSliderValue < maxUpdateId
|
|
||||||
? timeFilteredDocs
|
|
||||||
: latestDocuments,
|
|
||||||
showDeleted
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
let displayTree = $derived(
|
|
||||||
searchQuery ? filteredTree : timeFilteredTree
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load data
|
|
||||||
async function loadData() {
|
|
||||||
const api = auth.api;
|
|
||||||
if (!api) return;
|
|
||||||
|
|
||||||
loadingDocs = true;
|
|
||||||
loadingHistory = true;
|
|
||||||
|
|
||||||
api.ping().then((ping) => {
|
|
||||||
auth.serverVersion = ping.serverVersion;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.fetchLatestDocuments();
|
|
||||||
latestDocuments = response.latestDocuments;
|
|
||||||
maxUpdateId = Number(response.lastUpdateId);
|
|
||||||
} catch (e) {
|
|
||||||
toasts.add("Failed to load documents", "error");
|
|
||||||
} finally {
|
|
||||||
loadingDocs = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.fetchVaultHistory(500);
|
|
||||||
historyVersions = response.versions;
|
|
||||||
historyHasMore = response.hasMore;
|
|
||||||
if (historyVersions.length > 0) {
|
|
||||||
minUpdateId = Math.min(
|
|
||||||
...historyVersions.map((v) => v.vaultUpdateId)
|
|
||||||
);
|
|
||||||
maxUpdateId = Math.max(
|
|
||||||
maxUpdateId,
|
|
||||||
Math.max(
|
|
||||||
...historyVersions.map((v) => v.vaultUpdateId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toasts.add("Failed to load history", "error");
|
|
||||||
} finally {
|
|
||||||
loadingHistory = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMoreHistory() {
|
|
||||||
const api = auth.api;
|
|
||||||
if (!api || !historyHasMore) return;
|
|
||||||
|
|
||||||
const oldest = Math.min(
|
|
||||||
...historyVersions.map((v) => v.vaultUpdateId)
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
const response = await api.fetchVaultHistory(500, oldest);
|
|
||||||
historyVersions = [...historyVersions, ...response.versions];
|
|
||||||
historyHasMore = response.hasMore;
|
|
||||||
minUpdateId = Math.min(
|
|
||||||
minUpdateId,
|
|
||||||
...response.versions.map((v) => v.vaultUpdateId)
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
toasts.add("Failed to load more history", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectDocument(documentId: string) {
|
|
||||||
nav.goto({ kind: "document", documentId });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRefresh() {
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (auth.isAuthenticated) {
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="dashboard">
|
|
||||||
<Header
|
|
||||||
vaultId={auth.vaultId}
|
|
||||||
serverVersion={auth.serverVersion}
|
|
||||||
onRefresh={handleRefresh}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="main-layout">
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<aside class="sidebar">
|
|
||||||
{#if !loadingDocs}
|
|
||||||
<div class="sidebar-stats">
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-value">{stats.totalDocs}</span>
|
|
||||||
<span class="stat-label">files</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-value"
|
|
||||||
>{formatBytes(stats.totalSize)}</span
|
|
||||||
>
|
|
||||||
<span class="stat-label">total</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-value">{stats.users.length}</span>
|
|
||||||
<span class="stat-label"
|
|
||||||
>user{stats.users.length !== 1 ? "s" : ""}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="sidebar-search">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Filter files..."
|
|
||||||
bind:value={searchQuery}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sidebar-controls">
|
|
||||||
<label class="toggle-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={showDeleted}
|
|
||||||
/>
|
|
||||||
Show deleted
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sidebar-tree">
|
|
||||||
{#if loadingDocs}
|
|
||||||
<div class="loading-placeholder">Loading...</div>
|
|
||||||
{:else}
|
|
||||||
<FileTree
|
|
||||||
node={displayTree}
|
|
||||||
selectedId={selectedDocumentId ?? null}
|
|
||||||
onSelect={selectDocument}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Main content -->
|
|
||||||
<main class="content">
|
|
||||||
{#if maxUpdateId > 0}
|
|
||||||
<div class="time-slider-container">
|
|
||||||
<TimeSlider
|
|
||||||
min={minUpdateId}
|
|
||||||
max={maxUpdateId}
|
|
||||||
value={timeSliderValue}
|
|
||||||
versions={historyVersions}
|
|
||||||
onchange={(v) => {
|
|
||||||
timeSliderValue = v;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if selectedDocumentId}
|
|
||||||
<DocumentDetail
|
|
||||||
documentId={selectedDocumentId}
|
|
||||||
onClose={() => nav.goHome()}
|
|
||||||
onRestore={handleRefresh}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="tabs">
|
|
||||||
<button
|
|
||||||
class="tab"
|
|
||||||
class:active={activeTab === "activity"}
|
|
||||||
onclick={() => (activeTab = "activity")}
|
|
||||||
>
|
|
||||||
Activity
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="tab"
|
|
||||||
class:active={activeTab === "files"}
|
|
||||||
onclick={() => (activeTab = "files")}
|
|
||||||
>
|
|
||||||
Files
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if activeTab === "activity"}
|
|
||||||
<ActivityFeed
|
|
||||||
versions={enrichedHistory}
|
|
||||||
loading={loadingHistory}
|
|
||||||
hasMore={historyHasMore}
|
|
||||||
onLoadMore={loadMoreHistory}
|
|
||||||
onSelectDocument={selectDocument}
|
|
||||||
onTimeTravel={(id) => {
|
|
||||||
timeSliderValue = id >= maxUpdateId ? null : id;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="file-list">
|
|
||||||
{#each latestDocuments
|
|
||||||
.filter((d) => showDeleted || !d.isDeleted)
|
|
||||||
.sort((a, b) => b.vaultUpdateId - a.vaultUpdateId) as doc}
|
|
||||||
<button
|
|
||||||
class="file-row"
|
|
||||||
class:deleted={doc.isDeleted}
|
|
||||||
onclick={() =>
|
|
||||||
selectDocument(doc.documentId)}
|
|
||||||
>
|
|
||||||
<span class="file-icon"
|
|
||||||
>{doc.isDeleted
|
|
||||||
? "🗑"
|
|
||||||
: "📄"}</span
|
|
||||||
>
|
|
||||||
<span class="file-path"
|
|
||||||
>{doc.relativePath}</span
|
|
||||||
>
|
|
||||||
<span class="file-meta">
|
|
||||||
{formatBytes(doc.contentSize)}
|
|
||||||
·
|
|
||||||
{doc.userId}
|
|
||||||
·
|
|
||||||
{relativeTime(doc.updatedDate)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.dashboard {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-layout {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: 280px;
|
|
||||||
min-width: 280px;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-stats {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-search {
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-search input {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-controls {
|
|
||||||
padding: 4px 16px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-label input[type="checkbox"] {
|
|
||||||
width: auto;
|
|
||||||
accent-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-tree {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-placeholder {
|
|
||||||
padding: 16px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-align: center;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-slider-container {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
padding: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
padding: 10px 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
transition: color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab:hover {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.active {
|
|
||||||
color: var(--text);
|
|
||||||
border-bottom-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 16px;
|
|
||||||
text-align: left;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-row:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-row.deleted {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-row.deleted .file-path {
|
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-path {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 13px;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-meta {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
oldContent: string;
|
|
||||||
newContent: string;
|
|
||||||
oldLabel: string;
|
|
||||||
newLabel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { oldContent, newContent, oldLabel, newLabel }: Props = $props();
|
|
||||||
|
|
||||||
interface DiffLine {
|
|
||||||
type: "add" | "remove" | "context";
|
|
||||||
content: string;
|
|
||||||
oldLineNo: number | null;
|
|
||||||
newLineNo: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let diffLines = $derived.by((): DiffLine[] => {
|
|
||||||
const oldLines = oldContent.split("\n");
|
|
||||||
const newLines = newContent.split("\n");
|
|
||||||
|
|
||||||
// Simple line-by-line diff using LCS
|
|
||||||
const lines: DiffLine[] = [];
|
|
||||||
const lcs = computeLCS(oldLines, newLines);
|
|
||||||
|
|
||||||
let oi = 0;
|
|
||||||
let ni = 0;
|
|
||||||
let oldLineNo = 1;
|
|
||||||
let newLineNo = 1;
|
|
||||||
|
|
||||||
for (const match of lcs) {
|
|
||||||
// Remove lines before match
|
|
||||||
while (oi < match.oldIndex) {
|
|
||||||
lines.push({
|
|
||||||
type: "remove",
|
|
||||||
content: oldLines[oi],
|
|
||||||
oldLineNo: oldLineNo++,
|
|
||||||
newLineNo: null
|
|
||||||
});
|
|
||||||
oi++;
|
|
||||||
}
|
|
||||||
// Add lines before match
|
|
||||||
while (ni < match.newIndex) {
|
|
||||||
lines.push({
|
|
||||||
type: "add",
|
|
||||||
content: newLines[ni],
|
|
||||||
oldLineNo: null,
|
|
||||||
newLineNo: newLineNo++
|
|
||||||
});
|
|
||||||
ni++;
|
|
||||||
}
|
|
||||||
// Context line
|
|
||||||
lines.push({
|
|
||||||
type: "context",
|
|
||||||
content: oldLines[oi],
|
|
||||||
oldLineNo: oldLineNo++,
|
|
||||||
newLineNo: newLineNo++
|
|
||||||
});
|
|
||||||
oi++;
|
|
||||||
ni++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remaining removes
|
|
||||||
while (oi < oldLines.length) {
|
|
||||||
lines.push({
|
|
||||||
type: "remove",
|
|
||||||
content: oldLines[oi],
|
|
||||||
oldLineNo: oldLineNo++,
|
|
||||||
newLineNo: null
|
|
||||||
});
|
|
||||||
oi++;
|
|
||||||
}
|
|
||||||
// Remaining adds
|
|
||||||
while (ni < newLines.length) {
|
|
||||||
lines.push({
|
|
||||||
type: "add",
|
|
||||||
content: newLines[ni],
|
|
||||||
oldLineNo: null,
|
|
||||||
newLineNo: newLineNo++
|
|
||||||
});
|
|
||||||
ni++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
});
|
|
||||||
|
|
||||||
let stats = $derived({
|
|
||||||
added: diffLines.filter((l) => l.type === "add").length,
|
|
||||||
removed: diffLines.filter((l) => l.type === "remove").length
|
|
||||||
});
|
|
||||||
|
|
||||||
interface LCSMatch {
|
|
||||||
oldIndex: number;
|
|
||||||
newIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeLCS(a: string[], b: string[]): LCSMatch[] {
|
|
||||||
const m = a.length;
|
|
||||||
const n = b.length;
|
|
||||||
|
|
||||||
// For large files, use a simpler approach
|
|
||||||
if (m * n > 1_000_000) {
|
|
||||||
return simpleDiff(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
|
||||||
new Array(n + 1).fill(0)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let i = 1; i <= m; i++) {
|
|
||||||
for (let j = 1; j <= n; j++) {
|
|
||||||
if (a[i - 1] === b[j - 1]) {
|
|
||||||
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
||||||
} else {
|
|
||||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backtrack
|
|
||||||
const matches: LCSMatch[] = [];
|
|
||||||
let i = m;
|
|
||||||
let j = n;
|
|
||||||
while (i > 0 && j > 0) {
|
|
||||||
if (a[i - 1] === b[j - 1]) {
|
|
||||||
matches.unshift({ oldIndex: i - 1, newIndex: j - 1 });
|
|
||||||
i--;
|
|
||||||
j--;
|
|
||||||
} else if (dp[i - 1][j] > dp[i][j - 1]) {
|
|
||||||
i--;
|
|
||||||
} else {
|
|
||||||
j--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
function simpleDiff(a: string[], b: string[]): LCSMatch[] {
|
|
||||||
// Hash-based matching for large files
|
|
||||||
const bMap = new Map<string, number[]>();
|
|
||||||
for (let j = 0; j < b.length; j++) {
|
|
||||||
const arr = bMap.get(b[j]);
|
|
||||||
if (arr) arr.push(j);
|
|
||||||
else bMap.set(b[j], [j]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches: LCSMatch[] = [];
|
|
||||||
let lastJ = -1;
|
|
||||||
for (let i = 0; i < a.length; i++) {
|
|
||||||
const candidates = bMap.get(a[i]);
|
|
||||||
if (!candidates) continue;
|
|
||||||
for (const j of candidates) {
|
|
||||||
if (j > lastJ) {
|
|
||||||
matches.push({ oldIndex: i, newIndex: j });
|
|
||||||
lastJ = j;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="diff-view">
|
|
||||||
<div class="diff-header">
|
|
||||||
<span class="diff-label">{oldLabel}</span>
|
|
||||||
<span class="diff-arrow">→</span>
|
|
||||||
<span class="diff-label">{newLabel}</span>
|
|
||||||
<span class="diff-stats">
|
|
||||||
<span class="diff-added">+{stats.added}</span>
|
|
||||||
<span class="diff-removed">-{stats.removed}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="diff-content">
|
|
||||||
{#each diffLines as line}
|
|
||||||
<div class="diff-line {line.type}">
|
|
||||||
<span class="line-no old-no">
|
|
||||||
{line.oldLineNo ?? ""}
|
|
||||||
</span>
|
|
||||||
<span class="line-no new-no">
|
|
||||||
{line.newLineNo ?? ""}
|
|
||||||
</span>
|
|
||||||
<span class="line-marker">
|
|
||||||
{#if line.type === "add"}+{:else if line.type === "remove"}-{:else} {/if}
|
|
||||||
</span>
|
|
||||||
<span class="line-content">{line.content}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.diff-view {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-label {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-arrow {
|
|
||||||
color: var(--text-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-stats {
|
|
||||||
margin-left: auto;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-added {
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-removed {
|
|
||||||
color: var(--red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-line {
|
|
||||||
display: flex;
|
|
||||||
white-space: pre;
|
|
||||||
min-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-line.add {
|
|
||||||
background: var(--green-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-line.remove {
|
|
||||||
background: var(--red-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-no {
|
|
||||||
display: inline-block;
|
|
||||||
width: 48px;
|
|
||||||
text-align: right;
|
|
||||||
padding-right: 8px;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
user-select: none;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-marker {
|
|
||||||
display: inline-block;
|
|
||||||
width: 20px;
|
|
||||||
text-align: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-line.add .line-marker {
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-line.remove .line-marker {
|
|
||||||
color: var(--red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-content {
|
|
||||||
flex: 1;
|
|
||||||
padding-right: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,729 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
auth,
|
|
||||||
toasts,
|
|
||||||
relativeTime,
|
|
||||||
absoluteTime,
|
|
||||||
formatBytes,
|
|
||||||
inferAction,
|
|
||||||
isTextFile,
|
|
||||||
isImageFile,
|
|
||||||
fileExtension
|
|
||||||
} from "../lib/stores.svelte";
|
|
||||||
import type { DocumentVersionWithoutContent } from "../lib/types/DocumentVersionWithoutContent";
|
|
||||||
import type { DocumentVersion } from "../lib/types/DocumentVersion";
|
|
||||||
import type { ActionType } from "../lib/view-types";
|
|
||||||
import DiffView from "./DiffView.svelte";
|
|
||||||
import ConfirmDialog from "./ConfirmDialog.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
documentId: string;
|
|
||||||
onClose: () => void;
|
|
||||||
onRestore: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { documentId, onClose, onRestore }: Props = $props();
|
|
||||||
|
|
||||||
let versions = $state<DocumentVersionWithoutContent[]>([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let selectedVersion = $state<DocumentVersionWithoutContent | null>(null);
|
|
||||||
let loadedContent = $state<string | null>(null);
|
|
||||||
let loadedContentBytes = $state<ArrayBuffer | null>(null);
|
|
||||||
let loadingContent = $state(false);
|
|
||||||
let activeTab = $state<"preview" | "diff">("preview");
|
|
||||||
|
|
||||||
// Diff state
|
|
||||||
let diffOldContent = $state<string | null>(null);
|
|
||||||
let diffNewContent = $state<string | null>(null);
|
|
||||||
let diffOldLabel = $state("");
|
|
||||||
let diffNewLabel = $state("");
|
|
||||||
|
|
||||||
// Restore state
|
|
||||||
let showRestoreDialog = $state(false);
|
|
||||||
let restoreTarget = $state<DocumentVersionWithoutContent | null>(null);
|
|
||||||
let restoring = $state(false);
|
|
||||||
|
|
||||||
let latest = $derived(versions.at(-1) ?? null);
|
|
||||||
let isDeleted = $derived(latest?.isDeleted ?? false);
|
|
||||||
let currentPath = $derived(latest?.relativePath ?? "");
|
|
||||||
|
|
||||||
// Derive action types
|
|
||||||
let versionEvents = $derived(
|
|
||||||
versions.map((v, i) => ({
|
|
||||||
version: v,
|
|
||||||
action: inferAction(v, i > 0 ? versions[i - 1] : undefined) as ActionType,
|
|
||||||
previousPath: i > 0 && versions[i - 1].relativePath !== v.relativePath
|
|
||||||
? versions[i - 1].relativePath
|
|
||||||
: undefined
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
async function loadVersions() {
|
|
||||||
const api = auth.api;
|
|
||||||
if (!api) return;
|
|
||||||
loading = true;
|
|
||||||
try {
|
|
||||||
versions = await api.fetchDocumentVersions(documentId);
|
|
||||||
// Auto-select latest
|
|
||||||
if (versions.length > 0) {
|
|
||||||
await selectVersion(versions.at(-1)!);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toasts.add("Failed to load document versions", "error");
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectVersion(v: DocumentVersionWithoutContent) {
|
|
||||||
selectedVersion = v;
|
|
||||||
activeTab = "preview";
|
|
||||||
diffOldContent = null;
|
|
||||||
diffNewContent = null;
|
|
||||||
loadingContent = true;
|
|
||||||
loadedContent = null;
|
|
||||||
loadedContentBytes = null;
|
|
||||||
|
|
||||||
const api = auth.api;
|
|
||||||
if (!api) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isTextFile(v.relativePath) || fileExtension(v.relativePath) === "") {
|
|
||||||
const fullVersion = await api.fetchDocumentVersion(
|
|
||||||
documentId,
|
|
||||||
v.vaultUpdateId
|
|
||||||
);
|
|
||||||
const bytes = Uint8Array.from(atob(fullVersion.contentBase64), c => c.charCodeAt(0));
|
|
||||||
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
||||||
loadedContent = decoder.decode(bytes);
|
|
||||||
loadedContentBytes = bytes.buffer;
|
|
||||||
} else if (isImageFile(v.relativePath)) {
|
|
||||||
loadedContentBytes = await api.fetchDocumentVersionContent(
|
|
||||||
documentId,
|
|
||||||
v.vaultUpdateId
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
loadedContentBytes = await api.fetchDocumentVersionContent(
|
|
||||||
documentId,
|
|
||||||
v.vaultUpdateId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toasts.add("Failed to load content", "error");
|
|
||||||
} finally {
|
|
||||||
loadingContent = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showDiff(v: DocumentVersionWithoutContent, idx: number) {
|
|
||||||
const api = auth.api;
|
|
||||||
if (!api || idx === 0) return;
|
|
||||||
|
|
||||||
activeTab = "diff";
|
|
||||||
loadingContent = true;
|
|
||||||
|
|
||||||
const prev = versions[idx - 1];
|
|
||||||
try {
|
|
||||||
const [oldVer, newVer] = await Promise.all([
|
|
||||||
api.fetchDocumentVersion(documentId, prev.vaultUpdateId),
|
|
||||||
api.fetchDocumentVersion(documentId, v.vaultUpdateId)
|
|
||||||
]);
|
|
||||||
const decode = (b64: string) => {
|
|
||||||
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
|
||||||
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
||||||
};
|
|
||||||
diffOldContent = decode(oldVer.contentBase64);
|
|
||||||
diffNewContent = decode(newVer.contentBase64);
|
|
||||||
diffOldLabel = `v${prev.vaultUpdateId}`;
|
|
||||||
diffNewLabel = `v${v.vaultUpdateId}`;
|
|
||||||
} catch {
|
|
||||||
toasts.add("Failed to load diff", "error");
|
|
||||||
} finally {
|
|
||||||
loadingContent = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmRestore(v: DocumentVersionWithoutContent) {
|
|
||||||
restoreTarget = v;
|
|
||||||
showRestoreDialog = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeRestore() {
|
|
||||||
const api = auth.api;
|
|
||||||
if (!api || !restoreTarget || !latest) return;
|
|
||||||
restoring = true;
|
|
||||||
try {
|
|
||||||
// Restore = re-submit the target version's bytes at its path
|
|
||||||
// as if it were a fresh edit. `update_document` short-circuits
|
|
||||||
// on `is_deleted`, so resurrecting a deleted doc has to go
|
|
||||||
// through `create_document`; a live doc takes the normal
|
|
||||||
// update path with the current latest as its parent.
|
|
||||||
const bytes = await api.fetchDocumentVersionContent(
|
|
||||||
documentId,
|
|
||||||
restoreTarget.vaultUpdateId
|
|
||||||
);
|
|
||||||
if (latest.isDeleted) {
|
|
||||||
await api.createDocument(
|
|
||||||
latest.vaultUpdateId,
|
|
||||||
restoreTarget.relativePath,
|
|
||||||
bytes
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await api.updateBinaryDocument(
|
|
||||||
documentId,
|
|
||||||
latest.vaultUpdateId,
|
|
||||||
restoreTarget.relativePath,
|
|
||||||
bytes
|
|
||||||
);
|
|
||||||
}
|
|
||||||
toasts.add(
|
|
||||||
`Restored to version #${restoreTarget.vaultUpdateId}`,
|
|
||||||
"success"
|
|
||||||
);
|
|
||||||
showRestoreDialog = false;
|
|
||||||
restoreTarget = null;
|
|
||||||
onRestore();
|
|
||||||
await loadVersions();
|
|
||||||
} catch (e) {
|
|
||||||
toasts.add(`Restore failed: ${e}`, "error");
|
|
||||||
} finally {
|
|
||||||
restoring = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getImageUrl(buffer: ArrayBuffer, path: string): string {
|
|
||||||
const ext = fileExtension(path);
|
|
||||||
const mimeMap: Record<string, string> = {
|
|
||||||
png: "image/png",
|
|
||||||
jpg: "image/jpeg",
|
|
||||||
jpeg: "image/jpeg",
|
|
||||||
gif: "image/gif",
|
|
||||||
webp: "image/webp",
|
|
||||||
svg: "image/svg+xml",
|
|
||||||
ico: "image/x-icon",
|
|
||||||
bmp: "image/bmp"
|
|
||||||
};
|
|
||||||
const mime = mimeMap[ext] ?? "application/octet-stream";
|
|
||||||
const blob = new Blob([buffer], { type: mime });
|
|
||||||
return URL.createObjectURL(blob);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
loadVersions();
|
|
||||||
});
|
|
||||||
|
|
||||||
const actionColors: Record<string, string> = {
|
|
||||||
created: "var(--green)",
|
|
||||||
updated: "var(--blue)",
|
|
||||||
renamed: "var(--orange)",
|
|
||||||
deleted: "var(--red)",
|
|
||||||
restored: "var(--purple)"
|
|
||||||
};
|
|
||||||
|
|
||||||
const actionBgColors: Record<string, string> = {
|
|
||||||
created: "var(--green-bg)",
|
|
||||||
updated: "var(--blue-bg)",
|
|
||||||
renamed: "var(--orange-bg)",
|
|
||||||
deleted: "var(--red-bg)",
|
|
||||||
restored: "var(--purple-bg)"
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="detail">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="detail-header">
|
|
||||||
<button class="back-btn" onclick={onClose} title="Back">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="header-info">
|
|
||||||
<div class="header-path">
|
|
||||||
<span class="path-text" class:deleted-path={isDeleted}>
|
|
||||||
{currentPath}
|
|
||||||
</span>
|
|
||||||
{#if isDeleted}
|
|
||||||
<span class="status-badge deleted-badge">Deleted</span>
|
|
||||||
{:else}
|
|
||||||
<span class="status-badge active-badge">Active</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="header-meta">
|
|
||||||
<span class="doc-id" title={documentId}>
|
|
||||||
{documentId.substring(0, 8)}...
|
|
||||||
</span>
|
|
||||||
{#if latest}
|
|
||||||
<span>·</span>
|
|
||||||
<span>{versions.length} version{versions.length !== 1 ? "s" : ""}</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>Last by {latest.userId}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="detail-loading">Loading versions...</div>
|
|
||||||
{:else}
|
|
||||||
<!-- Content area -->
|
|
||||||
<div class="detail-body">
|
|
||||||
<div class="content-panel">
|
|
||||||
{#if selectedVersion}
|
|
||||||
<div class="content-tabs">
|
|
||||||
<button
|
|
||||||
class="content-tab"
|
|
||||||
class:active={activeTab === "preview"}
|
|
||||||
onclick={() => (activeTab = "preview")}
|
|
||||||
>
|
|
||||||
Preview
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="content-tab"
|
|
||||||
class:active={activeTab === "diff"}
|
|
||||||
onclick={() => {
|
|
||||||
if (selectedVersion) {
|
|
||||||
const idx = versions.indexOf(selectedVersion);
|
|
||||||
if (idx > 0) showDiff(selectedVersion, idx);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={versions.indexOf(selectedVersion) === 0}
|
|
||||||
>
|
|
||||||
Diff
|
|
||||||
</button>
|
|
||||||
<div class="content-tab-spacer"></div>
|
|
||||||
<span class="viewing-label">
|
|
||||||
Viewing v#{selectedVersion.vaultUpdateId}
|
|
||||||
·
|
|
||||||
{relativeTime(selectedVersion.updatedDate)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content-view">
|
|
||||||
{#if loadingContent}
|
|
||||||
<div class="content-loading">Loading content...</div>
|
|
||||||
{:else if activeTab === "diff" && diffOldContent !== null && diffNewContent !== null}
|
|
||||||
<DiffView
|
|
||||||
oldContent={diffOldContent}
|
|
||||||
newContent={diffNewContent}
|
|
||||||
oldLabel={diffOldLabel}
|
|
||||||
newLabel={diffNewLabel}
|
|
||||||
/>
|
|
||||||
{:else if activeTab === "preview"}
|
|
||||||
{#if isTextFile(selectedVersion.relativePath) || fileExtension(selectedVersion.relativePath) === ""}
|
|
||||||
<pre class="text-content">{loadedContent ?? ""}</pre>
|
|
||||||
{:else if isImageFile(selectedVersion.relativePath) && loadedContentBytes}
|
|
||||||
<div class="image-preview">
|
|
||||||
<img
|
|
||||||
src={getImageUrl(loadedContentBytes, selectedVersion.relativePath)}
|
|
||||||
alt={selectedVersion.relativePath}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="binary-placeholder">
|
|
||||||
<div class="binary-icon">📦</div>
|
|
||||||
<div class="binary-label">Binary file</div>
|
|
||||||
<div class="binary-size">
|
|
||||||
{formatBytes(selectedVersion.contentSize)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Version timeline -->
|
|
||||||
<div class="version-panel">
|
|
||||||
<div class="version-panel-header">Version History</div>
|
|
||||||
<div class="version-list">
|
|
||||||
{#each [...versionEvents].reverse() as event, i}
|
|
||||||
{@const v = event.version}
|
|
||||||
{@const isSelected = selectedVersion?.vaultUpdateId === v.vaultUpdateId}
|
|
||||||
<div class="version-item" class:selected={isSelected}>
|
|
||||||
<button
|
|
||||||
class="version-main"
|
|
||||||
onclick={() => selectVersion(v)}
|
|
||||||
>
|
|
||||||
<div class="version-left">
|
|
||||||
<span class="version-id">#{v.vaultUpdateId}</span>
|
|
||||||
<span
|
|
||||||
class="version-action"
|
|
||||||
style="color: {actionColors[event.action]}; background: {actionBgColors[event.action]}"
|
|
||||||
>
|
|
||||||
{event.action}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="version-right">
|
|
||||||
<span class="version-user">{v.userId}</span>
|
|
||||||
<span
|
|
||||||
class="version-time"
|
|
||||||
title={absoluteTime(v.updatedDate)}
|
|
||||||
>
|
|
||||||
{relativeTime(v.updatedDate)}
|
|
||||||
</span>
|
|
||||||
<span class="version-size">{formatBytes(v.contentSize)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{#if event.previousPath}
|
|
||||||
<div class="version-rename">
|
|
||||||
{event.previousPath} → {v.relativePath}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="version-actions">
|
|
||||||
{#if i < versionEvents.length - 1}
|
|
||||||
<button
|
|
||||||
class="version-btn"
|
|
||||||
onclick={() => {
|
|
||||||
const realIdx = versions.indexOf(v);
|
|
||||||
showDiff(v, realIdx);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Diff
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if v !== latest}
|
|
||||||
<button
|
|
||||||
class="version-btn restore-btn"
|
|
||||||
onclick={() => confirmRestore(v)}
|
|
||||||
>
|
|
||||||
Restore
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showRestoreDialog && restoreTarget}
|
|
||||||
<ConfirmDialog
|
|
||||||
title="Restore Version"
|
|
||||||
message={`Restore "${currentPath}" to version #${restoreTarget.vaultUpdateId} from ${absoluteTime(restoreTarget.updatedDate)}? This creates a new version with the old content. Current content is preserved in history.`}
|
|
||||||
confirmLabel="Restore"
|
|
||||||
destructive={false}
|
|
||||||
loading={restoring}
|
|
||||||
onConfirm={executeRestore}
|
|
||||||
onCancel={() => {
|
|
||||||
showRestoreDialog = false;
|
|
||||||
restoreTarget = null;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.detail {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn {
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
transition: color 0.15s, background 0.15s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn:hover {
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-path {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.path-text {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deleted-path {
|
|
||||||
text-decoration: line-through;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 1px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-badge {
|
|
||||||
color: var(--green);
|
|
||||||
background: var(--green-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deleted-badge {
|
|
||||||
color: var(--red);
|
|
||||||
background: var(--red-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-meta {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 2px;
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-id {
|
|
||||||
font-family: var(--mono);
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-loading {
|
|
||||||
padding: 48px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-body {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-panel {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-tabs {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 16px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-tab {
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
transition: color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-tab:hover:not(:disabled) {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-tab.active {
|
|
||||||
color: var(--text);
|
|
||||||
border-bottom-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-tab:disabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-tab-spacer {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewing-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
font-family: var(--mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-view {
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-loading {
|
|
||||||
padding: 48px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-content {
|
|
||||||
padding: 16px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
tab-size: 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview {
|
|
||||||
padding: 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 60vh;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.binary-placeholder {
|
|
||||||
padding: 64px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.binary-icon {
|
|
||||||
font-size: 48px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.binary-label {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.binary-size {
|
|
||||||
font-size: 14px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Version panel */
|
|
||||||
.version-panel {
|
|
||||||
width: 320px;
|
|
||||||
min-width: 320px;
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-panel-header {
|
|
||||||
padding: 10px 16px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-item {
|
|
||||||
border-bottom: 1px solid var(--border-light);
|
|
||||||
padding: 8px 12px;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-item:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-item.selected {
|
|
||||||
background: var(--blue-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-main {
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-id {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-action {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0 6px;
|
|
||||||
border-radius: 8px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-rename {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--orange);
|
|
||||||
font-family: var(--mono);
|
|
||||||
margin: 4px 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-btn {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--accent);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-btn:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.restore-btn {
|
|
||||||
color: var(--orange);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { TreeNode } from "../lib/view-types";
|
|
||||||
import FileTree from "./FileTree.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
node: TreeNode;
|
|
||||||
selectedId: string | null;
|
|
||||||
onSelect: (documentId: string) => void;
|
|
||||||
depth?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { node, selectedId, onSelect, depth = 0 }: Props = $props();
|
|
||||||
|
|
||||||
let expanded = $state<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
function toggle(path: string) {
|
|
||||||
expanded[path] = !expanded[path];
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExpanded(path: string): boolean {
|
|
||||||
return expanded[path] ?? true;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if node.isFolder && depth === 0}
|
|
||||||
{#each node.children as child}
|
|
||||||
<FileTree
|
|
||||||
node={child}
|
|
||||||
{selectedId}
|
|
||||||
{onSelect}
|
|
||||||
depth={depth + 1}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
{:else if node.isFolder}
|
|
||||||
<div class="tree-folder">
|
|
||||||
<button
|
|
||||||
class="tree-item folder"
|
|
||||||
style="padding-left: {depth * 16}px"
|
|
||||||
onclick={() => toggle(node.path)}
|
|
||||||
>
|
|
||||||
<span class="expand-icon"
|
|
||||||
>{isExpanded(node.path) ? "▾" : "▸"}</span
|
|
||||||
>
|
|
||||||
<span class="folder-icon">📁</span>
|
|
||||||
<span class="node-name">{node.name}</span>
|
|
||||||
</button>
|
|
||||||
{#if isExpanded(node.path)}
|
|
||||||
{#each node.children as child}
|
|
||||||
<FileTree
|
|
||||||
node={child}
|
|
||||||
{selectedId}
|
|
||||||
{onSelect}
|
|
||||||
depth={depth + 1}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
class="tree-item file"
|
|
||||||
class:selected={node.document?.documentId === selectedId}
|
|
||||||
class:deleted={node.isDeleted}
|
|
||||||
style="padding-left: {depth * 16 + 8}px"
|
|
||||||
onclick={() =>
|
|
||||||
node.document && onSelect(node.document.documentId)}
|
|
||||||
>
|
|
||||||
<span class="file-icon">{node.isDeleted ? "○" : "●"}</span>
|
|
||||||
<span class="node-name">{node.name}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tree-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 3px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: left;
|
|
||||||
transition: background 0.1s;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-item:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-item.selected {
|
|
||||||
background: var(--blue-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-item.deleted {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-item.deleted .node-name {
|
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expand-icon {
|
|
||||||
font-size: 10px;
|
|
||||||
width: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-icon {
|
|
||||||
font-size: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-name {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { auth } from "../lib/stores.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
vaultId: string;
|
|
||||||
serverVersion: string;
|
|
||||||
onRefresh: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { vaultId, serverVersion, onRefresh }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<header class="header">
|
|
||||||
<div class="header-left">
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
|
||||||
<path d="M2 17l10 5 10-5" />
|
|
||||||
<path d="M2 12l10 5 10-5" />
|
|
||||||
</svg>
|
|
||||||
<span class="header-title">VaultLink</span>
|
|
||||||
<span class="header-sep">/</span>
|
|
||||||
<span class="header-vault">{vaultId}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-right">
|
|
||||||
<span class="server-version">v{serverVersion}</span>
|
|
||||||
<button class="header-btn" onclick={onRefresh} title="Refresh">
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{#if auth.availableVaults.length > 1}
|
|
||||||
<button
|
|
||||||
class="header-btn"
|
|
||||||
onclick={() => auth.deselectVault()}
|
|
||||||
title="Switch vault"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
class="header-btn"
|
|
||||||
onclick={() => auth.logout()}
|
|
||||||
title="Sign out"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
|
||||||
<polyline points="16 17 21 12 16 7" />
|
|
||||||
<line x1="21" y1="12" x2="9" y2="12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 48px;
|
|
||||||
padding: 0 16px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-sep {
|
|
||||||
color: var(--text-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-vault {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-version {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
font-family: var(--mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-btn {
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
transition: color 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-btn:hover {
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { auth } from "../lib/stores.svelte";
|
|
||||||
import { listVaults } from "../lib/api";
|
|
||||||
|
|
||||||
let token = $state("");
|
|
||||||
let error = $state("");
|
|
||||||
let loading = $state(false);
|
|
||||||
|
|
||||||
async function handleSubmit(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!token.trim()) {
|
|
||||||
error = "Token is required.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
error = "";
|
|
||||||
loading = true;
|
|
||||||
try {
|
|
||||||
const response = await listVaults(token.trim());
|
|
||||||
auth.authenticate(
|
|
||||||
token.trim(),
|
|
||||||
response.userName,
|
|
||||||
response.vaults
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
error = "Authentication failed. Check your token.";
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="login-page">
|
|
||||||
<div class="login-card">
|
|
||||||
<div class="logo">
|
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
|
||||||
<path d="M2 17l10 5 10-5"/>
|
|
||||||
<path d="M2 12l10 5 10-5"/>
|
|
||||||
</svg>
|
|
||||||
<h1>VaultLink</h1>
|
|
||||||
</div>
|
|
||||||
<p class="subtitle">Vault History Browser</p>
|
|
||||||
|
|
||||||
<form onsubmit={handleSubmit}>
|
|
||||||
<label>
|
|
||||||
<span>Token</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
bind:value={token}
|
|
||||||
placeholder="Enter your access token"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="error">{error}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button type="submit" class="btn-primary" disabled={loading}>
|
|
||||||
{#if loading}
|
|
||||||
<span class="btn-spinner"></span>
|
|
||||||
Connecting...
|
|
||||||
{:else}
|
|
||||||
Connect
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.login-page {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
padding: 40px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 32px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
label span {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: var(--red);
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--red-bg);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 16px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
transition: background 0.15s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
background: var(--accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-spinner {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.6s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { DocumentVersionWithoutContent } from "../lib/types/DocumentVersionWithoutContent";
|
|
||||||
import { relativeTime, absoluteTime } from "../lib/stores.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
value: number | null;
|
|
||||||
versions: DocumentVersionWithoutContent[];
|
|
||||||
onchange: (value: number | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { min, max, value, versions, onchange }: Props = $props();
|
|
||||||
|
|
||||||
let isNow = $derived(value === null || value >= max);
|
|
||||||
|
|
||||||
function handleInput(e: Event) {
|
|
||||||
const target = e.target as HTMLInputElement;
|
|
||||||
const v = parseInt(target.value, 10);
|
|
||||||
if (v >= max) {
|
|
||||||
onchange(null);
|
|
||||||
} else {
|
|
||||||
onchange(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapToNow() {
|
|
||||||
onchange(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentVersion = $derived(
|
|
||||||
value !== null
|
|
||||||
? versions.find((v) => v.vaultUpdateId === value) ??
|
|
||||||
versions.reduce(
|
|
||||||
(closest, v) =>
|
|
||||||
Math.abs(v.vaultUpdateId - (value ?? max)) <
|
|
||||||
Math.abs(
|
|
||||||
closest.vaultUpdateId - (value ?? max)
|
|
||||||
)
|
|
||||||
? v
|
|
||||||
: closest,
|
|
||||||
versions[0]
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="time-slider">
|
|
||||||
<div class="slider-label">
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<polyline points="12 6 12 12 16 14" />
|
|
||||||
</svg>
|
|
||||||
<span class="label-text">Time Travel</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="slider-track">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
value={value ?? max}
|
|
||||||
oninput={handleInput}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="slider-info">
|
|
||||||
{#if isNow}
|
|
||||||
<span class="now-badge">Now</span>
|
|
||||||
{:else if currentVersion}
|
|
||||||
<span
|
|
||||||
class="time-info"
|
|
||||||
title={absoluteTime(currentVersion.updatedDate)}
|
|
||||||
>
|
|
||||||
#{value}
|
|
||||||
·
|
|
||||||
{relativeTime(currentVersion.updatedDate)}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="time-info">#{value}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if !isNow}
|
|
||||||
<button class="snap-btn" onclick={snapToNow} title="Back to now">
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.time-slider {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-text {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-track {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-track input[type="range"] {
|
|
||||||
width: 100%;
|
|
||||||
height: 4px;
|
|
||||||
appearance: none;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 2px;
|
|
||||||
outline: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-track input[type="range"]::-webkit-slider-thumb {
|
|
||||||
appearance: none;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
background: var(--accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-track input[type="range"]::-webkit-slider-thumb:hover {
|
|
||||||
transform: scale(1.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-info {
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.now-badge {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--green);
|
|
||||||
background: var(--green-bg);
|
|
||||||
padding: 2px 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-info {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.snap-btn {
|
|
||||||
padding: 4px;
|
|
||||||
color: var(--accent);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
transition: background 0.15s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snap-btn:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { toasts } from "../lib/stores.svelte";
|
|
||||||
|
|
||||||
const typeColors: Record<string, string> = {
|
|
||||||
success: "var(--green)",
|
|
||||||
error: "var(--red)",
|
|
||||||
info: "var(--accent)"
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if toasts.items.length > 0}
|
|
||||||
<div class="toast-container">
|
|
||||||
{#each toasts.items as toast (toast.id)}
|
|
||||||
<div
|
|
||||||
class="toast"
|
|
||||||
style="border-left-color: {typeColors[toast.type]}"
|
|
||||||
>
|
|
||||||
<span class="toast-message">{toast.message}</span>
|
|
||||||
<button
|
|
||||||
class="toast-dismiss"
|
|
||||||
onclick={() => toasts.dismiss(toast.id)}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.toast-container {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 16px;
|
|
||||||
right: 16px;
|
|
||||||
z-index: 9999;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
max-width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-left-width: 3px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
animation: slide-in 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-message {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-dismiss {
|
|
||||||
font-size: 18px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-dismiss:hover {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slide-in {
|
|
||||||
from {
|
|
||||||
transform: translateX(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateX(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { auth } from "../lib/stores.svelte";
|
|
||||||
import { relativeTime } from "../lib/stores.svelte";
|
|
||||||
import type { VaultInfo } from "../lib/types/VaultInfo";
|
|
||||||
|
|
||||||
function select(vault: VaultInfo) {
|
|
||||||
auth.selectVault(vault.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatStats(vault: VaultInfo): string {
|
|
||||||
const docs = vault.documentCount === 1
|
|
||||||
? "1 document"
|
|
||||||
: `${vault.documentCount} documents`;
|
|
||||||
if (!vault.createdAt) return docs;
|
|
||||||
return `${docs} · created ${relativeTime(vault.createdAt)}`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="picker-page">
|
|
||||||
<div class="picker-card">
|
|
||||||
<div class="picker-header">
|
|
||||||
<div class="logo">
|
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
|
||||||
<path d="M2 17l10 5 10-5"/>
|
|
||||||
<path d="M2 12l10 5 10-5"/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<h1>Select a vault</h1>
|
|
||||||
<p class="user-info">
|
|
||||||
Signed in as <strong>{auth.userName}</strong>
|
|
||||||
<button class="logout-link" onclick={() => auth.logout()}>Sign out</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if auth.availableVaults.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<p>No vaults found</p>
|
|
||||||
<p class="empty-hint">
|
|
||||||
Vaults are created when a sync client first connects.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<ul class="vault-list">
|
|
||||||
{#each auth.availableVaults as vault}
|
|
||||||
<li>
|
|
||||||
<button class="vault-item" onclick={() => select(vault)}>
|
|
||||||
<svg class="vault-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
<div class="vault-details">
|
|
||||||
<span class="vault-name">{vault.name}</span>
|
|
||||||
<span class="vault-stats">{formatStats(vault)}</span>
|
|
||||||
</div>
|
|
||||||
<svg class="vault-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<polyline points="9 18 15 12 9 6"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.picker-page {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 480px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-header {
|
|
||||||
padding: 32px 32px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo svg {
|
|
||||||
margin-top: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo h1 {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info strong {
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logout-link {
|
|
||||||
color: var(--text-subtle);
|
|
||||||
font-size: 13px;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin-left: 8px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logout-link:hover {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vault-list {
|
|
||||||
list-style: none;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vault-list li + li {
|
|
||||||
border-top: 1px solid var(--border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vault-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px 24px;
|
|
||||||
text-align: left;
|
|
||||||
color: var(--text);
|
|
||||||
transition: background 0.12s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vault-item:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vault-icon {
|
|
||||||
color: var(--text-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vault-details {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vault-name {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vault-stats {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vault-arrow {
|
|
||||||
color: var(--text-subtle);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
padding: 32px;
|
|
||||||
text-align: center;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty p {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-hint {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
|
|
||||||
import type { DocumentVersion } from "./types/DocumentVersion";
|
|
||||||
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
|
||||||
import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse";
|
|
||||||
import type { ListVaultsResponse } from "./types/ListVaultsResponse";
|
|
||||||
import type { PingResponse } from "./types/PingResponse";
|
|
||||||
import type { VaultHistoryResponse } from "./types/VaultHistoryResponse";
|
|
||||||
|
|
||||||
async function fetchJsonWithToken<T>(
|
|
||||||
path: string,
|
|
||||||
token: string,
|
|
||||||
init?: RequestInit
|
|
||||||
): Promise<T> {
|
|
||||||
const response = await fetch(path, {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"device-id": "history-ui",
|
|
||||||
...init?.headers
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.text();
|
|
||||||
throw new Error(`HTTP ${response.status}: ${body}`);
|
|
||||||
}
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listVaults(token: string): Promise<ListVaultsResponse> {
|
|
||||||
return fetchJsonWithToken("/vaults", token);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApiClient {
|
|
||||||
constructor(
|
|
||||||
private vaultId: string,
|
|
||||||
private token: string
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private get baseUrl(): string {
|
|
||||||
return `/vaults/${encodeURIComponent(this.vaultId)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
|
||||||
return fetchJsonWithToken(path, this.token, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
async ping(): Promise<PingResponse> {
|
|
||||||
return this.fetchJson(`${this.baseUrl}/ping`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchLatestDocuments(): Promise<FetchLatestDocumentsResponse> {
|
|
||||||
return this.fetchJson(`${this.baseUrl}/documents`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchDocumentVersions(
|
|
||||||
documentId: string
|
|
||||||
): Promise<DocumentVersionWithoutContent[]> {
|
|
||||||
return this.fetchJson(
|
|
||||||
`${this.baseUrl}/documents/${documentId}/versions`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchDocumentVersion(
|
|
||||||
documentId: string,
|
|
||||||
vaultUpdateId: number
|
|
||||||
): Promise<DocumentVersion> {
|
|
||||||
return this.fetchJson(
|
|
||||||
`${this.baseUrl}/documents/${documentId}/versions/${vaultUpdateId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchDocumentVersionContent(
|
|
||||||
documentId: string,
|
|
||||||
vaultUpdateId: number
|
|
||||||
): Promise<ArrayBuffer> {
|
|
||||||
const response = await fetch(
|
|
||||||
`${this.baseUrl}/documents/${documentId}/versions/${vaultUpdateId}/content`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.token}`,
|
|
||||||
"device-id": "history-ui"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.arrayBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchVaultHistory(
|
|
||||||
limit?: number,
|
|
||||||
beforeUpdateId?: number
|
|
||||||
): Promise<VaultHistoryResponse> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (limit !== undefined) params.set("limit", String(limit));
|
|
||||||
if (beforeUpdateId !== undefined)
|
|
||||||
params.set("before_update_id", String(beforeUpdateId));
|
|
||||||
const qs = params.toString();
|
|
||||||
return this.fetchJson(`${this.baseUrl}/history${qs ? `?${qs}` : ""}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a new version of an existing (non-deleted) document. The
|
|
||||||
* server treats this like any other edit — server-side merging,
|
|
||||||
* path dedupe, and broadcast still apply. Used by the UI to restore
|
|
||||||
* an old version by re-submitting its bytes on top of the latest.
|
|
||||||
*/
|
|
||||||
async updateBinaryDocument(
|
|
||||||
documentId: string,
|
|
||||||
parentVersionId: number,
|
|
||||||
relativePath: string,
|
|
||||||
content: ArrayBuffer
|
|
||||||
): Promise<DocumentUpdateResponse> {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append("parent_version_id", String(parentVersionId));
|
|
||||||
form.append("relative_path", relativePath);
|
|
||||||
form.append("content", new Blob([content]));
|
|
||||||
return this.fetchJson(
|
|
||||||
`${this.baseUrl}/documents/${documentId}/binary`,
|
|
||||||
{ method: "PUT", body: form }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new document. Used by the UI to restore a deleted
|
|
||||||
* document: `update_document` short-circuits on `is_deleted`, so
|
|
||||||
* resurrection has to go through `create_document` — which detects
|
|
||||||
* an existing doc at the same path, merges or dedupes as needed,
|
|
||||||
* and returns the resulting version.
|
|
||||||
*/
|
|
||||||
async createDocument(
|
|
||||||
lastSeenVaultUpdateId: number,
|
|
||||||
relativePath: string,
|
|
||||||
content: ArrayBuffer
|
|
||||||
): Promise<DocumentUpdateResponse> {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append("last_seen_vault_update_id", String(lastSeenVaultUpdateId));
|
|
||||||
form.append("relative_path", relativePath);
|
|
||||||
form.append("content", new Blob([content]));
|
|
||||||
return this.fetchJson(`${this.baseUrl}/documents`, {
|
|
||||||
method: "POST",
|
|
||||||
body: form
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,290 +0,0 @@
|
||||||
import { ApiClient } from "./api";
|
|
||||||
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
|
||||||
import type { VaultInfo } from "./types/VaultInfo";
|
|
||||||
import type { VersionEvent, ActionType, TreeNode } from "./view-types";
|
|
||||||
|
|
||||||
class AuthStore {
|
|
||||||
token = $state("");
|
|
||||||
userName = $state("");
|
|
||||||
vaultId = $state("");
|
|
||||||
serverVersion = $state("");
|
|
||||||
availableVaults = $state<VaultInfo[]>([]);
|
|
||||||
isAuthenticated = $state(false);
|
|
||||||
api = $state<ApiClient | null>(null);
|
|
||||||
|
|
||||||
authenticate(token: string, userName: string, vaults: VaultInfo[]) {
|
|
||||||
this.token = token;
|
|
||||||
this.userName = userName;
|
|
||||||
this.availableVaults = vaults;
|
|
||||||
sessionStorage.setItem("vaultlink_token", token);
|
|
||||||
}
|
|
||||||
|
|
||||||
selectVault(vaultId: string) {
|
|
||||||
this.vaultId = vaultId;
|
|
||||||
this.isAuthenticated = true;
|
|
||||||
this.api = new ApiClient(vaultId, this.token);
|
|
||||||
sessionStorage.setItem("vaultlink_vault", vaultId);
|
|
||||||
}
|
|
||||||
|
|
||||||
deselectVault() {
|
|
||||||
this.vaultId = "";
|
|
||||||
this.isAuthenticated = false;
|
|
||||||
this.api = null;
|
|
||||||
sessionStorage.removeItem("vaultlink_vault");
|
|
||||||
}
|
|
||||||
|
|
||||||
logout() {
|
|
||||||
this.token = "";
|
|
||||||
this.userName = "";
|
|
||||||
this.vaultId = "";
|
|
||||||
this.serverVersion = "";
|
|
||||||
this.availableVaults = [];
|
|
||||||
this.isAuthenticated = false;
|
|
||||||
this.api = null;
|
|
||||||
sessionStorage.removeItem("vaultlink_token");
|
|
||||||
sessionStorage.removeItem("vaultlink_vault");
|
|
||||||
}
|
|
||||||
|
|
||||||
tryRestore(): { token: string; vaultId?: string } | null {
|
|
||||||
const token = sessionStorage.getItem("vaultlink_token");
|
|
||||||
if (!token) return null;
|
|
||||||
const vaultId = sessionStorage.getItem("vaultlink_vault") ?? undefined;
|
|
||||||
return { token, vaultId };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const auth = new AuthStore();
|
|
||||||
|
|
||||||
// Navigation
|
|
||||||
export type View =
|
|
||||||
| { kind: "dashboard" }
|
|
||||||
| { kind: "document"; documentId: string };
|
|
||||||
|
|
||||||
class NavStore {
|
|
||||||
current = $state<View>({ kind: "dashboard" });
|
|
||||||
|
|
||||||
goto(view: View) {
|
|
||||||
this.current = view;
|
|
||||||
}
|
|
||||||
|
|
||||||
goHome() {
|
|
||||||
this.current = { kind: "dashboard" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const nav = new NavStore();
|
|
||||||
|
|
||||||
// Toasts
|
|
||||||
interface Toast {
|
|
||||||
id: number;
|
|
||||||
message: string;
|
|
||||||
type: "success" | "error" | "info";
|
|
||||||
}
|
|
||||||
|
|
||||||
class ToastStore {
|
|
||||||
items = $state<Toast[]>([]);
|
|
||||||
private nextId = 0;
|
|
||||||
|
|
||||||
add(message: string, type: Toast["type"] = "info") {
|
|
||||||
const id = this.nextId++;
|
|
||||||
this.items.push({ id, message, type });
|
|
||||||
setTimeout(() => this.dismiss(id), 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
dismiss(id: number) {
|
|
||||||
this.items = this.items.filter((t) => t.id !== id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const toasts = new ToastStore();
|
|
||||||
|
|
||||||
// Utilities
|
|
||||||
|
|
||||||
export function inferAction(
|
|
||||||
version: DocumentVersionWithoutContent,
|
|
||||||
previousVersion?: DocumentVersionWithoutContent
|
|
||||||
): ActionType {
|
|
||||||
if (version.isDeleted) return "deleted";
|
|
||||||
if (!previousVersion) return "created";
|
|
||||||
if (previousVersion.isDeleted && !version.isDeleted) return "restored";
|
|
||||||
if (previousVersion.relativePath !== version.relativePath) return "renamed";
|
|
||||||
return "updated";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function enrichVersions(
|
|
||||||
versions: DocumentVersionWithoutContent[]
|
|
||||||
): VersionEvent[] {
|
|
||||||
// versions should be sorted by vaultUpdateId ascending
|
|
||||||
const sorted = [...versions].sort(
|
|
||||||
(a, b) => a.vaultUpdateId - b.vaultUpdateId
|
|
||||||
);
|
|
||||||
const byDoc = new Map<string, DocumentVersionWithoutContent[]>();
|
|
||||||
for (const v of sorted) {
|
|
||||||
let arr = byDoc.get(v.documentId);
|
|
||||||
if (!arr) {
|
|
||||||
arr = [];
|
|
||||||
byDoc.set(v.documentId, arr);
|
|
||||||
}
|
|
||||||
arr.push(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sorted.map((v) => {
|
|
||||||
const docVersions = byDoc.get(v.documentId)!;
|
|
||||||
const idx = docVersions.indexOf(v);
|
|
||||||
const prev = idx > 0 ? docVersions[idx - 1] : undefined;
|
|
||||||
const action = inferAction(v, prev);
|
|
||||||
return {
|
|
||||||
...v,
|
|
||||||
action,
|
|
||||||
previousPath: action === "renamed" ? prev?.relativePath : undefined
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildTree(
|
|
||||||
documents: DocumentVersionWithoutContent[],
|
|
||||||
showDeleted: boolean
|
|
||||||
): TreeNode {
|
|
||||||
const root: TreeNode = {
|
|
||||||
name: "",
|
|
||||||
path: "",
|
|
||||||
isFolder: true,
|
|
||||||
children: []
|
|
||||||
};
|
|
||||||
|
|
||||||
const filtered = showDeleted
|
|
||||||
? documents
|
|
||||||
: documents.filter((d) => !d.isDeleted);
|
|
||||||
|
|
||||||
for (const doc of filtered) {
|
|
||||||
const parts = doc.relativePath.split("/");
|
|
||||||
let current = root;
|
|
||||||
|
|
||||||
for (let i = 0; i < parts.length; i++) {
|
|
||||||
const part = parts[i];
|
|
||||||
const isFile = i === parts.length - 1;
|
|
||||||
const path = parts.slice(0, i + 1).join("/");
|
|
||||||
|
|
||||||
if (isFile) {
|
|
||||||
current.children.push({
|
|
||||||
name: part,
|
|
||||||
path,
|
|
||||||
isFolder: false,
|
|
||||||
children: [],
|
|
||||||
document: doc,
|
|
||||||
isDeleted: doc.isDeleted
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let folder = current.children.find(
|
|
||||||
(c) => c.isFolder && c.name === part
|
|
||||||
);
|
|
||||||
if (!folder) {
|
|
||||||
folder = {
|
|
||||||
name: part,
|
|
||||||
path,
|
|
||||||
isFolder: true,
|
|
||||||
children: []
|
|
||||||
};
|
|
||||||
current.children.push(folder);
|
|
||||||
}
|
|
||||||
current = folder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sortTree(root);
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortTree(node: TreeNode) {
|
|
||||||
node.children.sort((a, b) => {
|
|
||||||
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
for (const child of node.children) {
|
|
||||||
if (child.isFolder) sortTree(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function relativeTime(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const now = Date.now();
|
|
||||||
const diff = now - date.getTime();
|
|
||||||
const seconds = Math.floor(diff / 1000);
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
|
|
||||||
if (seconds < 60) return "just now";
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
if (days < 7) return `${days}d ago`;
|
|
||||||
return date.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: days > 365 ? "numeric" : undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function absoluteTime(dateStr: string): string {
|
|
||||||
return new Date(dateStr).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatBytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return "0 B";
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ["B", "KB", "MB", "GB"];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fileExtension(path: string): string {
|
|
||||||
const dot = path.lastIndexOf(".");
|
|
||||||
return dot > -1 ? path.substring(dot + 1).toLowerCase() : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isTextFile(path: string): boolean {
|
|
||||||
const textExts = new Set([
|
|
||||||
"md",
|
|
||||||
"txt",
|
|
||||||
"json",
|
|
||||||
"yaml",
|
|
||||||
"yml",
|
|
||||||
"toml",
|
|
||||||
"xml",
|
|
||||||
"html",
|
|
||||||
"css",
|
|
||||||
"js",
|
|
||||||
"ts",
|
|
||||||
"svelte",
|
|
||||||
"rs",
|
|
||||||
"py",
|
|
||||||
"sh",
|
|
||||||
"bash",
|
|
||||||
"zsh",
|
|
||||||
"csv",
|
|
||||||
"svg",
|
|
||||||
"log",
|
|
||||||
"conf",
|
|
||||||
"cfg",
|
|
||||||
"ini",
|
|
||||||
"env",
|
|
||||||
"gitignore",
|
|
||||||
"editorconfig"
|
|
||||||
]);
|
|
||||||
return textExts.has(fileExtension(path));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isImageFile(path: string): boolean {
|
|
||||||
const imageExts = new Set([
|
|
||||||
"png",
|
|
||||||
"jpg",
|
|
||||||
"jpeg",
|
|
||||||
"gif",
|
|
||||||
"webp",
|
|
||||||
"svg",
|
|
||||||
"ico",
|
|
||||||
"bmp"
|
|
||||||
]);
|
|
||||||
return imageExts.has(fileExtension(path));
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
|
||||||
|
|
||||||
export type ClientCursors = {
|
|
||||||
userName: string;
|
|
||||||
deviceId: string;
|
|
||||||
documentsWithCursors: Array<DocumentWithCursors>;
|
|
||||||
};
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
|
||||||
|
|
||||||
export type CursorPositionFromClient = {
|
|
||||||
documentsWithCursors: Array<DocumentWithCursors>;
|
|
||||||
};
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
import type { ClientCursors } from "./ClientCursors";
|
|
||||||
|
|
||||||
export type CursorPositionFromServer = { clients: Array<ClientCursors> };
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
import type { DocumentVersion } from "./DocumentVersion";
|
|
||||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response to a create/update document request.
|
|
||||||
*/
|
|
||||||
export type DocumentUpdateResponse =
|
|
||||||
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
|
|
||||||
| ({ type: "MergingUpdate" } & DocumentVersion);
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
|
|
||||||
export type DocumentVersion = {
|
|
||||||
vaultUpdateId: number;
|
|
||||||
documentId: string;
|
|
||||||
relativePath: string;
|
|
||||||
updatedDate: string;
|
|
||||||
contentBase64: string;
|
|
||||||
isDeleted: boolean;
|
|
||||||
userId: string;
|
|
||||||
deviceId: string;
|
|
||||||
};
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
|
|
||||||
export type DocumentVersionWithoutContent = {
|
|
||||||
vaultUpdateId: number;
|
|
||||||
documentId: string;
|
|
||||||
relativePath: string;
|
|
||||||
updatedDate: string;
|
|
||||||
isDeleted: boolean;
|
|
||||||
userId: string;
|
|
||||||
deviceId: string;
|
|
||||||
contentSize: number;
|
|
||||||
/**
|
|
||||||
* True iff this is the first version of the document
|
|
||||||
*/
|
|
||||||
isNewFile: boolean;
|
|
||||||
};
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
import type { CursorSpan } from "./CursorSpan";
|
|
||||||
|
|
||||||
export type DocumentWithCursors = {
|
|
||||||
vaultUpdateId: number | null;
|
|
||||||
documentId: string;
|
|
||||||
relativePath: string;
|
|
||||||
cursors: Array<CursorSpan>;
|
|
||||||
};
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response to a fetch latest documents request.
|
|
||||||
*/
|
|
||||||
export type FetchLatestDocumentsResponse = {
|
|
||||||
latestDocuments: Array<DocumentVersionWithoutContent>;
|
|
||||||
/**
|
|
||||||
* The update ID of the latest document in the response.
|
|
||||||
*/
|
|
||||||
lastUpdateId: bigint;
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
import type { VaultInfo } from "./VaultInfo";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response to listing vaults accessible to the authenticated user.
|
|
||||||
*/
|
|
||||||
export type ListVaultsResponse = {
|
|
||||||
vaults: Array<VaultInfo>;
|
|
||||||
hasMore: boolean;
|
|
||||||
userName: string;
|
|
||||||
};
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response to a ping request.
|
|
||||||
*/
|
|
||||||
export type PingResponse = {
|
|
||||||
/**
|
|
||||||
* Semantic version of the server.
|
|
||||||
*/
|
|
||||||
serverVersion: string;
|
|
||||||
/**
|
|
||||||
* Whether the client is authenticated based on the sent Authorization
|
|
||||||
* header.
|
|
||||||
*/
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
/**
|
|
||||||
* List of file extensions that are allowed to be merged.
|
|
||||||
*/
|
|
||||||
mergeableFileExtensions: Array<string>;
|
|
||||||
/**
|
|
||||||
* API version ensuring backwards & forwards compatibility between the client
|
|
||||||
* and server.
|
|
||||||
*/
|
|
||||||
supportedApiVersion: number;
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
|
|
||||||
export type SerializedError = {
|
|
||||||
errorType: string;
|
|
||||||
message: string;
|
|
||||||
causes: Array<string>;
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
|
|
||||||
export type UpdateTextDocumentVersion = {
|
|
||||||
parentVersionId: number;
|
|
||||||
relativePath: string | null;
|
|
||||||
content: Array<number | string>;
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response to a vault history request (paginated).
|
|
||||||
*/
|
|
||||||
export type VaultHistoryResponse = {
|
|
||||||
versions: Array<DocumentVersionWithoutContent>;
|
|
||||||
hasMore: boolean;
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Summary of a single vault returned by the list-vaults endpoint.
|
|
||||||
*/
|
|
||||||
export type VaultInfo = {
|
|
||||||
name: string;
|
|
||||||
documentCount: number;
|
|
||||||
createdAt: string | null;
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
import type { CursorPositionFromClient } from "./CursorPositionFromClient";
|
|
||||||
import type { WebSocketHandshake } from "./WebSocketHandshake";
|
|
||||||
|
|
||||||
export type WebSocketClientMessage =
|
|
||||||
| ({ type: "handshake" } & WebSocketHandshake)
|
|
||||||
| ({ type: "cursorPositions" } & CursorPositionFromClient);
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
|
|
||||||
export type WebSocketHandshake = {
|
|
||||||
token: string;
|
|
||||||
deviceId: string;
|
|
||||||
lastSeenVaultUpdateId: number | null;
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
import type { CursorPositionFromServer } from "./CursorPositionFromServer";
|
|
||||||
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
|
|
||||||
|
|
||||||
export type WebSocketServerMessage =
|
|
||||||
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
|
|
||||||
| ({ type: "cursorPositions" } & CursorPositionFromServer);
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
||||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
|
||||||
|
|
||||||
export type WebSocketVaultUpdate = { document: DocumentVersionWithoutContent };
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
|
||||||
|
|
||||||
export type ActionType =
|
|
||||||
| "created"
|
|
||||||
| "updated"
|
|
||||||
| "renamed"
|
|
||||||
| "deleted"
|
|
||||||
| "restored";
|
|
||||||
|
|
||||||
export interface VersionEvent extends DocumentVersionWithoutContent {
|
|
||||||
action: ActionType;
|
|
||||||
previousPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TreeNode {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isFolder: boolean;
|
|
||||||
children: TreeNode[];
|
|
||||||
document?: DocumentVersionWithoutContent;
|
|
||||||
isDeleted?: boolean;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { mount } from "svelte";
|
|
||||||
import App from "./App.svelte";
|
|
||||||
import "./app.css";
|
|
||||||
|
|
||||||
const app = mount(App, { target: document.getElementById("app")! });
|
|
||||||
|
|
||||||
export default app;
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
preprocess: vitePreprocess()
|
|
||||||
};
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ESNext",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"types": ["svelte"]
|
|
||||||
},
|
|
||||||
"include": ["src/**/*", "src/**/*.svelte"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [svelte()],
|
|
||||||
build: {
|
|
||||||
outDir: "dist",
|
|
||||||
emptyOutDir: true
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
"/vaults": "http://localhost:3010"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -150,25 +150,6 @@ test("parseArgs - default log level is INFO", () => {
|
||||||
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"
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(args.logLevel, LogLevel.DEBUG);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseArgs - parse ERROR log level", () => {
|
test("parseArgs - parse ERROR log level", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"node",
|
"node",
|
||||||
|
|
@ -188,43 +169,6 @@ test("parseArgs - parse ERROR log level", () => {
|
||||||
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"
|
|
||||||
]);
|
|
||||||
|
|
||||||
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/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseArgs - reads required options from environment variables", () => {
|
test("parseArgs - reads required options from environment variables", () => {
|
||||||
process.env.VAULTLINK_LOCAL_PATH = "/env/path";
|
process.env.VAULTLINK_LOCAL_PATH = "/env/path";
|
||||||
|
|
@ -267,184 +211,3 @@ test("parseArgs - CLI arguments take precedence over environment variables", ()
|
||||||
delete process.env.VAULTLINK_TOKEN;
|
delete process.env.VAULTLINK_TOKEN;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseArgs - reads log level from environment variable", () => {
|
|
||||||
process.env.VAULTLINK_LOG_LEVEL = "DEBUG";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const args = parseArgs([
|
|
||||||
"node",
|
|
||||||
"cli.js",
|
|
||||||
"-l",
|
|
||||||
"/path/to/vault",
|
|
||||||
"-r",
|
|
||||||
"https://sync.example.com",
|
|
||||||
"-t",
|
|
||||||
"mytoken",
|
|
||||||
"-v",
|
|
||||||
"default"
|
|
||||||
]);
|
|
||||||
assert.equal(args.logLevel, LogLevel.DEBUG);
|
|
||||||
} finally {
|
|
||||||
delete process.env.VAULTLINK_LOG_LEVEL;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseArgs - quiet defaults to false", () => {
|
|
||||||
const args = parseArgs([
|
|
||||||
"node",
|
|
||||||
"cli.js",
|
|
||||||
"-l",
|
|
||||||
"/path/to/vault",
|
|
||||||
"-r",
|
|
||||||
"https://sync.example.com",
|
|
||||||
"-t",
|
|
||||||
"mytoken",
|
|
||||||
"-v",
|
|
||||||
"default"
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(args.quiet, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseArgs - parse --quiet flag", () => {
|
|
||||||
const args = parseArgs([
|
|
||||||
"node",
|
|
||||||
"cli.js",
|
|
||||||
"-l",
|
|
||||||
"/path/to/vault",
|
|
||||||
"-r",
|
|
||||||
"https://sync.example.com",
|
|
||||||
"-t",
|
|
||||||
"mytoken",
|
|
||||||
"-v",
|
|
||||||
"default",
|
|
||||||
"--quiet"
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(args.quiet, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseArgs - parse -q short flag", () => {
|
|
||||||
const args = parseArgs([
|
|
||||||
"node",
|
|
||||||
"cli.js",
|
|
||||||
"-l",
|
|
||||||
"/path/to/vault",
|
|
||||||
"-r",
|
|
||||||
"https://sync.example.com",
|
|
||||||
"-t",
|
|
||||||
"mytoken",
|
|
||||||
"-v",
|
|
||||||
"default",
|
|
||||||
"-q"
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(args.quiet, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseArgs - line-endings defaults to auto", () => {
|
|
||||||
const args = parseArgs([
|
|
||||||
"node",
|
|
||||||
"cli.js",
|
|
||||||
"-l",
|
|
||||||
"/path/to/vault",
|
|
||||||
"-r",
|
|
||||||
"https://sync.example.com",
|
|
||||||
"-t",
|
|
||||||
"mytoken",
|
|
||||||
"-v",
|
|
||||||
"default"
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(args.lineEndings, "auto");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseArgs - parse --line-endings lf", () => {
|
|
||||||
const args = parseArgs([
|
|
||||||
"node",
|
|
||||||
"cli.js",
|
|
||||||
"-l",
|
|
||||||
"/path/to/vault",
|
|
||||||
"-r",
|
|
||||||
"https://sync.example.com",
|
|
||||||
"-t",
|
|
||||||
"mytoken",
|
|
||||||
"-v",
|
|
||||||
"default",
|
|
||||||
"--line-endings",
|
|
||||||
"lf"
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(args.lineEndings, "lf");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseArgs - parse --line-endings crlf", () => {
|
|
||||||
const args = parseArgs([
|
|
||||||
"node",
|
|
||||||
"cli.js",
|
|
||||||
"-l",
|
|
||||||
"/path/to/vault",
|
|
||||||
"-r",
|
|
||||||
"https://sync.example.com",
|
|
||||||
"-t",
|
|
||||||
"mytoken",
|
|
||||||
"-v",
|
|
||||||
"default",
|
|
||||||
"--line-endings",
|
|
||||||
"crlf"
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(args.lineEndings, "crlf");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseArgs - throws on invalid remote URI protocol", () => {
|
|
||||||
assert.throws(() => {
|
|
||||||
parseArgs([
|
|
||||||
"node",
|
|
||||||
"cli.js",
|
|
||||||
"-l",
|
|
||||||
"/path/to/vault",
|
|
||||||
"-r",
|
|
||||||
"ftp://sync.example.com",
|
|
||||||
"-t",
|
|
||||||
"mytoken",
|
|
||||||
"-v",
|
|
||||||
"default"
|
|
||||||
]);
|
|
||||||
}, /Invalid remote URI/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseArgs - accepts http:// remote URI", () => {
|
|
||||||
const args = parseArgs([
|
|
||||||
"node",
|
|
||||||
"cli.js",
|
|
||||||
"-l",
|
|
||||||
"/path/to/vault",
|
|
||||||
"-r",
|
|
||||||
"http://localhost:3000",
|
|
||||||
"-t",
|
|
||||||
"mytoken",
|
|
||||||
"-v",
|
|
||||||
"default"
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(args.remoteUri, "http://localhost:3000");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseArgs - accepts wss:// remote URI", () => {
|
|
||||||
const args = parseArgs([
|
|
||||||
"node",
|
|
||||||
"cli.js",
|
|
||||||
"-l",
|
|
||||||
"/path/to/vault",
|
|
||||||
"-r",
|
|
||||||
"wss://sync.example.com",
|
|
||||||
"-t",
|
|
||||||
"mytoken",
|
|
||||||
"-v",
|
|
||||||
"default"
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(args.remoteUri, "wss://sync.example.com");
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { Command, Option } from "commander";
|
||||||
import packageJson from "../package.json";
|
import packageJson from "../package.json";
|
||||||
import { LogLevel } from "sync-client";
|
import { LogLevel } from "sync-client";
|
||||||
|
|
||||||
type LineEndingMode = "auto" | "lf" | "crlf";
|
export const LINE_ENDING_MODES = ["auto", "lf", "crlf"] as const;
|
||||||
|
export type LineEndingMode = (typeof LINE_ENDING_MODES)[number];
|
||||||
|
|
||||||
interface CliArgs {
|
interface CliArgs {
|
||||||
remoteUri: string;
|
remoteUri: string;
|
||||||
|
|
@ -21,6 +22,35 @@ interface CliArgs {
|
||||||
|
|
||||||
const VALID_PROTOCOLS = ["http://", "https://", "ws://", "wss://"];
|
const VALID_PROTOCOLS = ["http://", "https://", "ws://", "wss://"];
|
||||||
|
|
||||||
|
const REQUIRED_OPTIONS = {
|
||||||
|
localPath: {
|
||||||
|
flags: "-l, --local-path <path>",
|
||||||
|
env: "VAULTLINK_LOCAL_PATH"
|
||||||
|
},
|
||||||
|
remoteUri: {
|
||||||
|
flags: "-r, --remote-uri <uri>",
|
||||||
|
env: "VAULTLINK_REMOTE_URI"
|
||||||
|
},
|
||||||
|
token: { flags: "-t, --token <token>", env: "VAULTLINK_TOKEN" },
|
||||||
|
vaultName: {
|
||||||
|
flags: "-v, --vault-name <name>",
|
||||||
|
env: "VAULTLINK_VAULT_NAME"
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function requireOption<T>(
|
||||||
|
value: T | undefined,
|
||||||
|
name: keyof typeof REQUIRED_OPTIONS
|
||||||
|
): T {
|
||||||
|
if (value === undefined) {
|
||||||
|
const { flags, env } = REQUIRED_OPTIONS[name];
|
||||||
|
throw new Error(
|
||||||
|
`required option '${flags}' not specified (or set ${env})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseArgs(argv: string[]): CliArgs {
|
export function parseArgs(argv: string[]): CliArgs {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
|
|
@ -32,23 +62,25 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||||
.version(packageJson.version)
|
.version(packageJson.version)
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option(
|
new Option(
|
||||||
"-l, --local-path <path>",
|
REQUIRED_OPTIONS.localPath.flags,
|
||||||
"Local directory path to sync"
|
"Local directory path to sync"
|
||||||
).env("VAULTLINK_LOCAL_PATH")
|
).env(REQUIRED_OPTIONS.localPath.env)
|
||||||
)
|
)
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option("-r, --remote-uri <uri>", "Remote server URI").env(
|
new Option(
|
||||||
"VAULTLINK_REMOTE_URI"
|
REQUIRED_OPTIONS.remoteUri.flags,
|
||||||
)
|
"Remote server URI"
|
||||||
|
).env(REQUIRED_OPTIONS.remoteUri.env)
|
||||||
)
|
)
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option("-t, --token <token>", "Authentication token").env(
|
new Option(
|
||||||
"VAULTLINK_TOKEN"
|
REQUIRED_OPTIONS.token.flags,
|
||||||
)
|
"Authentication token"
|
||||||
|
).env(REQUIRED_OPTIONS.token.env)
|
||||||
)
|
)
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option("-v, --vault-name <name>", "Vault name").env(
|
new Option(REQUIRED_OPTIONS.vaultName.flags, "Vault name").env(
|
||||||
"VAULTLINK_VAULT_NAME"
|
REQUIRED_OPTIONS.vaultName.env
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.addOption(
|
.addOption(
|
||||||
|
|
@ -105,7 +137,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||||
"[OPTIONAL] Line ending style: auto (platform default), lf, crlf"
|
"[OPTIONAL] Line ending style: auto (platform default), lf, crlf"
|
||||||
)
|
)
|
||||||
.default("auto")
|
.default("auto")
|
||||||
.choices(["auto", "lf", "crlf"])
|
.choices([...LINE_ENDING_MODES])
|
||||||
.env("VAULTLINK_LINE_ENDINGS")
|
.env("VAULTLINK_LINE_ENDINGS")
|
||||||
)
|
)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
|
|
@ -144,22 +176,6 @@ Environment variables:
|
||||||
const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto";
|
const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto";
|
||||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
|
||||||
const requireOption = <T>(value: T | undefined, name: string): T => {
|
|
||||||
if (value === undefined) {
|
|
||||||
const option = program.options.find(
|
|
||||||
(o) => o.attributeName() === name
|
|
||||||
);
|
|
||||||
const envHint =
|
|
||||||
option?.envVar !== undefined
|
|
||||||
? ` (or set ${option.envVar})`
|
|
||||||
: "";
|
|
||||||
throw new Error(
|
|
||||||
`required option '${option?.flags ?? name}' not specified${envHint}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const requiredLocalPath = requireOption(localPath, "localPath");
|
const requiredLocalPath = requireOption(localPath, "localPath");
|
||||||
const requiredRemoteUri = requireOption(remoteUri, "remoteUri");
|
const requiredRemoteUri = requireOption(remoteUri, "remoteUri");
|
||||||
const requiredToken = requireOption(token, "token");
|
const requiredToken = requireOption(token, "token");
|
||||||
|
|
@ -187,13 +203,11 @@ Environment variables:
|
||||||
}
|
}
|
||||||
const logLevel = logLevelUpper;
|
const logLevel = logLevelUpper;
|
||||||
|
|
||||||
const validLineEndings: readonly string[] = ["auto", "lf", "crlf"];
|
const isLineEndingMode = (value: string): value is LineEndingMode =>
|
||||||
const isLineEndingMode = (value: string): value is LineEndingMode => {
|
(LINE_ENDING_MODES as readonly string[]).includes(value);
|
||||||
return validLineEndings.includes(value);
|
|
||||||
};
|
|
||||||
if (!isLineEndingMode(lineEndingsStr)) {
|
if (!isLineEndingMode(lineEndingsStr)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${validLineEndings.join(", ")}`
|
`Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${LINE_ENDING_MODES.join(", ")}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const lineEndings = lineEndingsStr;
|
const lineEndings = lineEndingsStr;
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,12 @@ import {
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
Logger,
|
Logger,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
type LogLine,
|
LogLine,
|
||||||
type SyncSettings,
|
type SyncSettings,
|
||||||
type StoredDatabase
|
type StoredDatabase
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
import { parseArgs } from "./args";
|
import { parseArgs, type LineEndingMode } from "./args";
|
||||||
import { NodeFileSystemOperations } from "./node-filesystem";
|
import { NodeFileSystemOperations, VAULTLINK_DIR } from "./node-filesystem";
|
||||||
import { FileWatcher } from "./file-watcher";
|
import { FileWatcher } from "./file-watcher";
|
||||||
import { formatLogLine } from "./logger-formatter";
|
import { formatLogLine } from "./logger-formatter";
|
||||||
import packageJson from "../package.json";
|
import packageJson from "../package.json";
|
||||||
|
|
@ -50,7 +50,7 @@ function createLogHandler(minLevel: LogLevel): (logLine: LogLine) => void {
|
||||||
const HEALTH_CHECK_INTERVAL_MS = 30 * 1000;
|
const HEALTH_CHECK_INTERVAL_MS = 30 * 1000;
|
||||||
const PROGRESS_LOG_INTERVAL_MS = 2000;
|
const PROGRESS_LOG_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
function resolveLineEndings(mode: "auto" | "lf" | "crlf"): string {
|
function resolveLineEndings(mode: LineEndingMode): string {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case "lf":
|
case "lf":
|
||||||
return "\n";
|
return "\n";
|
||||||
|
|
@ -65,9 +65,13 @@ async function main(): Promise<void> {
|
||||||
const args = parseArgs(process.argv);
|
const args = parseArgs(process.argv);
|
||||||
const absolutePath = path.resolve(args.localPath);
|
const absolutePath = path.resolve(args.localPath);
|
||||||
|
|
||||||
const logger = new Logger();
|
|
||||||
const logHandler = createLogHandler(args.logLevel);
|
const logHandler = createLogHandler(args.logLevel);
|
||||||
logger.onLogEmitted.add(logHandler);
|
// Boot-time messages are emitted directly through logHandler before the
|
||||||
|
// SyncClient (and its Logger) exist; afterwards every log line flows
|
||||||
|
// through client.logger.
|
||||||
|
const emitBoot = (level: LogLevel, message: string): void => {
|
||||||
|
logHandler(new LogLine(level, message));
|
||||||
|
};
|
||||||
|
|
||||||
if (!fsSync.existsSync(absolutePath)) {
|
if (!fsSync.existsSync(absolutePath)) {
|
||||||
fsSync.mkdirSync(absolutePath, { recursive: true });
|
fsSync.mkdirSync(absolutePath, { recursive: true });
|
||||||
|
|
@ -76,27 +80,31 @@ async function main(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const stats = await fs.stat(absolutePath);
|
const stats = await fs.stat(absolutePath);
|
||||||
if (!stats.isDirectory()) {
|
if (!stats.isDirectory()) {
|
||||||
logger.error(`${absolutePath} is not a directory`);
|
emitBoot(LogLevel.ERROR, `${absolutePath} is not a directory`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
emitBoot(
|
||||||
|
LogLevel.ERROR,
|
||||||
`Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`
|
`Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!args.quiet) {
|
if (!args.quiet) {
|
||||||
logger.info(`VaultLink Local CLI v${packageJson.version}`);
|
emitBoot(LogLevel.INFO, `VaultLink Local CLI v${packageJson.version}`);
|
||||||
logger.info(`Local path: ${absolutePath}`);
|
emitBoot(LogLevel.INFO, `Local path: ${absolutePath}`);
|
||||||
logger.info(`Remote URI: ${args.remoteUri}`);
|
emitBoot(LogLevel.INFO, `Remote URI: ${args.remoteUri}`);
|
||||||
logger.info(`Vault name: ${args.vaultName}`);
|
emitBoot(LogLevel.INFO, `Vault name: ${args.vaultName}`);
|
||||||
if (args.lineEndings !== "auto") {
|
if (args.lineEndings !== "auto") {
|
||||||
logger.info(`Line endings: ${args.lineEndings.toUpperCase()}`);
|
emitBoot(
|
||||||
|
LogLevel.INFO,
|
||||||
|
`Line endings: ${args.lineEndings.toUpperCase()}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataDir = path.join(absolutePath, ".vaultlink");
|
const dataDir = path.join(absolutePath, VAULTLINK_DIR);
|
||||||
const dataFile = path.join(dataDir, "sync-data.json");
|
const dataFile = path.join(dataDir, "sync-data.json");
|
||||||
|
|
||||||
await fs.mkdir(dataDir, { recursive: true });
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
|
@ -105,8 +113,7 @@ async function main(): Promise<void> {
|
||||||
|
|
||||||
const ignorePatterns = [
|
const ignorePatterns = [
|
||||||
...(args.ignorePatterns ?? []),
|
...(args.ignorePatterns ?? []),
|
||||||
".vaultlink/**",
|
`${VAULTLINK_DIR}/**`
|
||||||
".git/**"
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const settings: SyncSettings = {
|
const settings: SyncSettings = {
|
||||||
|
|
@ -134,7 +141,10 @@ async function main(): Promise<void> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
database = JSON.parse(content) as Partial<StoredDatabase>;
|
database = JSON.parse(content) as Partial<StoredDatabase>;
|
||||||
} catch {
|
} catch {
|
||||||
logger.warn(`Cannot read data file at ${dataFile}`);
|
emitBoot(
|
||||||
|
LogLevel.WARNING,
|
||||||
|
`Cannot read data file at ${dataFile}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -269,7 +279,6 @@ async function main(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error: unknown) => {
|
main().catch((error: unknown) => {
|
||||||
// Last-resort handler before the logger exists
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error(
|
console.error(
|
||||||
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`
|
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import * as fs from "fs/promises";
|
import * as fs from "fs/promises";
|
||||||
import type { Dirent } from "fs";
|
import type { Dirent } from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import type {
|
import type {
|
||||||
FileSystemOperations,
|
FileSystemOperations,
|
||||||
RelativePath,
|
RelativePath,
|
||||||
|
|
@ -8,8 +9,13 @@ import type {
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
import { toUnixPath } from "./path-utils";
|
import { toUnixPath } from "./path-utils";
|
||||||
|
|
||||||
|
// VaultLink's per-vault metadata directory. Holds the persisted sync database
|
||||||
|
// and the tmp files atomicWrite renames into place; the matching `${VAULTLINK_DIR}/**`
|
||||||
|
// ignore pattern keeps everything in here invisible to the file watcher.
|
||||||
|
export const VAULTLINK_DIR = ".vaultlink";
|
||||||
|
|
||||||
export class NodeFileSystemOperations implements FileSystemOperations {
|
export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public constructor(private readonly basePath: string) {}
|
public constructor(private readonly basePath: string) { }
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
directory: RelativePath | undefined
|
directory: RelativePath | undefined
|
||||||
|
|
@ -132,12 +138,37 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
content: Uint8Array | string,
|
content: Uint8Array | string,
|
||||||
encoding?: BufferEncoding
|
encoding?: BufferEncoding
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const tmpPath = fullPath + ".tmp";
|
const tmpDir = path.join(this.basePath, VAULTLINK_DIR);
|
||||||
await fs.writeFile(tmpPath, content, encoding);
|
await fs.mkdir(tmpDir, { recursive: true });
|
||||||
const fd = await fs.open(tmpPath, "r");
|
const tmpPath = path.join(tmpDir, `atomic-write-${randomUUID()}.tmp`);
|
||||||
await fd.datasync();
|
try {
|
||||||
await fd.close();
|
await fs.writeFile(tmpPath, content, encoding);
|
||||||
await fs.rename(tmpPath, fullPath);
|
const fd = await fs.open(tmpPath, "r");
|
||||||
|
try {
|
||||||
|
await fd.datasync();
|
||||||
|
} finally {
|
||||||
|
await fd.close();
|
||||||
|
}
|
||||||
|
await fs.rename(tmpPath, fullPath);
|
||||||
|
await this.syncDirectory(path.dirname(fullPath));
|
||||||
|
} catch (error) {
|
||||||
|
await fs.unlink(tmpPath).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the rename durable by fsync'ing the destination's parent directory.
|
||||||
|
// Skipped on Windows: fsync on a directory handle isn't supported there
|
||||||
|
private async syncDirectory(dir: string): Promise<void> {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fd = await fs.open(dir, "r");
|
||||||
|
try {
|
||||||
|
await fd.sync();
|
||||||
|
} finally {
|
||||||
|
await fd.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async walkDirectory(
|
private async walkDirectory(
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,14 @@ export function toUnixPath(nativePath: string): string {
|
||||||
return nativePath.split(path.sep).join(path.posix.sep);
|
return nativePath.split(path.sep).join(path.posix.sep);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match a file path against a glob pattern
|
// Match a file path against a glob pattern.
|
||||||
// Extends path.matchesGlob so that "dir/**" also matches the directory itself
|
//
|
||||||
|
// Behaves like Node's path.matchesGlob with one extension: `dir/**` matches
|
||||||
|
// the directory `dir` itself, not only its descendants. The watcher feeds us
|
||||||
|
// a directory's relative path (e.g. ".git") at the same time it's about to
|
||||||
|
// recurse into it, and the natural way for users to write the ignore pattern
|
||||||
|
// is `.git/**` — under stdlib semantics that pattern would let the directory
|
||||||
|
// through and only block its children, defeating the prune.
|
||||||
export function matchesGlob(filePath: string, pattern: string): boolean {
|
export function matchesGlob(filePath: string, pattern: string): boolean {
|
||||||
if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) {
|
if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) {
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@ module.exports = (env, argv) => ({
|
||||||
const destinations = [
|
const destinations = [
|
||||||
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
|
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
|
||||||
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link"
|
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link"
|
||||||
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
|
|
||||||
];
|
];
|
||||||
destinations.forEach((destination) => {
|
destinations.forEach((destination) => {
|
||||||
fs.copy(source, destination)
|
fs.copy(source, destination)
|
||||||
|
|
|
||||||
5100
frontend/package-lock.json
generated
5100
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,40 +6,28 @@
|
||||||
"obsidian-plugin",
|
"obsidian-plugin",
|
||||||
"test-client",
|
"test-client",
|
||||||
"deterministic-tests",
|
"deterministic-tests",
|
||||||
"local-client-cli",
|
"local-client-cli"
|
||||||
"history-ui"
|
|
||||||
],
|
],
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"tabWidth": 4,
|
"tabWidth": 4,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"endOfLine": "lf",
|
"endOfLine": "lf"
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
"*.yml",
|
|
||||||
"*.yaml",
|
|
||||||
"*.md"
|
|
||||||
],
|
|
||||||
"options": {
|
|
||||||
"tabWidth": 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build --workspaces",
|
"build": "npm run build --workspaces",
|
||||||
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
|
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
|
||||||
"test": "npm run test --workspaces",
|
"test": "npm run test --workspaces",
|
||||||
"lint": "eslint --fix sync-client obsidian-plugin test-client deterministic-tests local-client-cli && prettier --write \"**/*.ts\"",
|
"lint": "eslint --fix sync-client obsidian-plugin test-client deterministic-tests local-client-cli && prettier --write \"**/*.ts\"",
|
||||||
"update": "ncu -u"
|
"update": "ncu -u -ws"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"eslint": "9.39.2",
|
"eclint": "^2.8.1",
|
||||||
"eslint-plugin-unused-imports": "^4.3.0",
|
"eslint": "9.38.0",
|
||||||
"npm-check-updates": "^19.2.0",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"prettier": "^3.7.4",
|
"npm-check-updates": "^19.1.1",
|
||||||
"typescript-eslint": "8.49.0"
|
"prettier": "^3.6.2",
|
||||||
|
"typescript-eslint": "8.41.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,19 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"byte-base64": "^1.1.0",
|
"byte-base64": "^1.1.0",
|
||||||
"minimatch": "^10.1.1",
|
"minimatch": "^10.0.1",
|
||||||
"p-queue": "^9.0.1",
|
"p-queue": "^8.1.0",
|
||||||
"reconcile-text": "^0.8.0",
|
"reconcile-text": "^0.8.0",
|
||||||
"@types/node": "^25.0.2",
|
"uuid": "^13.0.0",
|
||||||
"ts-loader": "^9.5.4",
|
"@types/node": "^24.8.1",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.8.3",
|
||||||
"webpack": "^5.103.0",
|
"webpack": "^5.99.9",
|
||||||
"webpack-cli": "^6.0.1",
|
"webpack-cli": "^6.0.1",
|
||||||
"webpack-merge": "^6.0.1",
|
"webpack-merge": "^6.0.1",
|
||||||
"@sentry/browser": "^10.30.0"
|
"@sentry/browser": "^10.8.0",
|
||||||
|
"ws": "^8.18.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export const TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS = 60;
|
export const TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS = 60;
|
||||||
|
export const DIFF_CACHE_SIZE_MB = 2;
|
||||||
export const MAX_LOG_MESSAGE_COUNT = 100000;
|
export const MAX_LOG_MESSAGE_COUNT = 100000;
|
||||||
export const MAX_HISTORY_ENTRY_COUNT = 5000;
|
export const MAX_HISTORY_ENTRY_COUNT = 5000;
|
||||||
export const SUPPORTED_API_VERSION = 3;
|
export const SUPPORTED_API_VERSION = 2;
|
||||||
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS = 10;
|
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_S = 10;
|
||||||
export const WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS = 10;
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export class FileAlreadyExistsError extends Error {
|
|
||||||
public constructor(
|
|
||||||
message: string,
|
|
||||||
public readonly filePath: string
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = "FileAlreadyExistsError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export class HttpClientError extends Error {
|
|
||||||
public constructor(
|
|
||||||
public readonly statusCode: number,
|
|
||||||
message: string
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = "HttpClientError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import type {
|
||||||
import type { RelativePath } from "../sync-operations/types";
|
Database,
|
||||||
|
DocumentRecord,
|
||||||
|
RelativePath
|
||||||
|
} from "../persistence/database";
|
||||||
import { FileOperations } from "./file-operations";
|
import { FileOperations } from "./file-operations";
|
||||||
import { Logger } from "../tracing/logger";
|
import { Logger } from "../tracing/logger";
|
||||||
import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly";
|
import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly";
|
||||||
import type { FileSystemOperations } from "./filesystem-operations";
|
import type { FileSystemOperations } from "./filesystem-operations";
|
||||||
import type { TextWithCursors } from "reconcile-text";
|
import type { TextWithCursors } from "reconcile-text";
|
||||||
import type { ServerConfig, ServerConfigData } from "../services/server-config";
|
import type { ServerConfig, ServerConfigData } from "../services/server-config";
|
||||||
import { ExpectedFsEvents } from "../sync-operations/expected-fs-events";
|
|
||||||
import { FileAlreadyExistsError } from "../errors/file-already-exists-error";
|
|
||||||
|
|
||||||
class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
||||||
public async getConfig(): Promise<ServerConfigData> {
|
public async getConfig(): Promise<ServerConfigData> {
|
||||||
|
|
@ -20,13 +21,29 @@ class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MockDatabase implements Partial<Database> {
|
||||||
|
public getLatestDocumentByRelativePath(
|
||||||
|
_find: RelativePath
|
||||||
|
): DocumentRecord | undefined {
|
||||||
|
// no-op
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public move(
|
||||||
|
_oldRelativePath: RelativePath,
|
||||||
|
_newRelativePath: RelativePath
|
||||||
|
): void {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class FakeFileSystemOperations implements FileSystemOperations {
|
class FakeFileSystemOperations implements FileSystemOperations {
|
||||||
public readonly names = new Set<string>();
|
public readonly names = new Set<string>();
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
_root: RelativePath | undefined
|
_root: RelativePath | undefined
|
||||||
): Promise<RelativePath[]> {
|
): Promise<RelativePath[]> {
|
||||||
return Array.from(this.names);
|
return ["file.md"];
|
||||||
}
|
}
|
||||||
public async read(_path: RelativePath): Promise<Uint8Array> {
|
public async read(_path: RelativePath): Promise<Uint8Array> {
|
||||||
throw new Error("Method not implemented.");
|
throw new Error("Method not implemented.");
|
||||||
|
|
@ -46,14 +63,17 @@ class FakeFileSystemOperations implements FileSystemOperations {
|
||||||
public async getFileSize(_path: RelativePath): Promise<number> {
|
public async getFileSize(_path: RelativePath): Promise<number> {
|
||||||
throw new Error("Method not implemented.");
|
throw new Error("Method not implemented.");
|
||||||
}
|
}
|
||||||
|
public async getModificationTime(_path: RelativePath): Promise<Date> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
public async exists(path: RelativePath): Promise<boolean> {
|
public async exists(path: RelativePath): Promise<boolean> {
|
||||||
return this.names.has(path);
|
return this.names.has(path);
|
||||||
}
|
}
|
||||||
public async createDirectory(_path: RelativePath): Promise<void> {
|
public async createDirectory(_path: RelativePath): Promise<void> {
|
||||||
// no-op for the in-memory fake; we only track files
|
// this is called but irrelevant for this mock
|
||||||
}
|
}
|
||||||
public async delete(path: RelativePath): Promise<void> {
|
public async delete(_path: RelativePath): Promise<void> {
|
||||||
this.names.delete(path);
|
throw new Error("Method not implemented.");
|
||||||
}
|
}
|
||||||
public async rename(
|
public async rename(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
|
|
@ -64,92 +84,152 @@ class FakeFileSystemOperations implements FileSystemOperations {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeOps(): {
|
|
||||||
fs: FakeFileSystemOperations;
|
|
||||||
ops: FileOperations;
|
|
||||||
} {
|
|
||||||
const fs = new FakeFileSystemOperations();
|
|
||||||
const ops = new FileOperations(
|
|
||||||
new Logger(),
|
|
||||||
fs,
|
|
||||||
new MockServerConfig() as ServerConfig, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
|
||||||
new ExpectedFsEvents()
|
|
||||||
);
|
|
||||||
return { fs, ops };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("File operations", () => {
|
describe("File operations", () => {
|
||||||
it("create writes the file at the requested path", async () => {
|
it("should deconflict renames", async () => {
|
||||||
const { fs, ops } = makeOps();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
|
const fileOperations = new FileOperations(
|
||||||
const result = await ops.create("a", new Uint8Array());
|
new Logger(),
|
||||||
|
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
assertSetContainsExactly(fs.names, "a");
|
fileSystemOperations,
|
||||||
assert.equal(result.actualPath, "a");
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
});
|
|
||||||
|
|
||||||
it("create throws FileAlreadyExistsError when the path is occupied", async () => {
|
|
||||||
const { fs, ops } = makeOps();
|
|
||||||
|
|
||||||
await ops.create("note.md", new Uint8Array());
|
|
||||||
await assert.rejects(
|
|
||||||
ops.create("note.md", new Uint8Array()),
|
|
||||||
FileAlreadyExistsError
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// The original file is left intact and no other entries appeared.
|
await fileOperations.create("a", new Uint8Array());
|
||||||
assertSetContainsExactly(fs.names, "note.md");
|
assertSetContainsExactly(fileSystemOperations.names, "a");
|
||||||
|
await fileOperations.move("a", "b");
|
||||||
|
assertSetContainsExactly(fileSystemOperations.names, "b");
|
||||||
|
|
||||||
|
await fileOperations.create("c", new Uint8Array());
|
||||||
|
assertSetContainsExactly(fileSystemOperations.names, "b", "c");
|
||||||
|
|
||||||
|
await fileOperations.move("c", "b");
|
||||||
|
assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)");
|
||||||
|
|
||||||
|
await fileOperations.create("c", new Uint8Array());
|
||||||
|
await fileOperations.move("c", "b");
|
||||||
|
assertSetContainsExactly(
|
||||||
|
fileSystemOperations.names,
|
||||||
|
"b",
|
||||||
|
"b (1)",
|
||||||
|
"b (2)"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("move to an empty target just renames the file", async () => {
|
it("should deconflict renames with file extension", async () => {
|
||||||
const { fs, ops } = makeOps();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
|
const fileOperations = new FileOperations(
|
||||||
await ops.create("a", new Uint8Array());
|
new Logger(),
|
||||||
assertSetContainsExactly(fs.names, "a");
|
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
fileSystemOperations,
|
||||||
const result = await ops.move("a", "b");
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
assertSetContainsExactly(fs.names, "b");
|
|
||||||
assert.equal(result.actualPath, "b");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("move with same source and target is a no-op", async () => {
|
|
||||||
const { fs, ops } = makeOps();
|
|
||||||
|
|
||||||
await ops.create("a", new Uint8Array());
|
|
||||||
const result = await ops.move("a", "a");
|
|
||||||
|
|
||||||
assertSetContainsExactly(fs.names, "a");
|
|
||||||
assert.equal(result.actualPath, "a");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("move throws FileAlreadyExistsError when the target is occupied", async () => {
|
|
||||||
const { fs, ops } = makeOps();
|
|
||||||
|
|
||||||
await ops.create("source.md", new Uint8Array());
|
|
||||||
await ops.create("dest.md", new Uint8Array());
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
ops.move("source.md", "dest.md"),
|
|
||||||
FileAlreadyExistsError
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Both files are left intact — no displacement happens.
|
await fileOperations.create("b.md", new Uint8Array());
|
||||||
assertSetContainsExactly(fs.names, "source.md", "dest.md");
|
await fileOperations.create("c.md", new Uint8Array());
|
||||||
|
await fileOperations.move("c.md", "b.md");
|
||||||
|
assertSetContainsExactly(
|
||||||
|
fileSystemOperations.names,
|
||||||
|
"b.md",
|
||||||
|
"b (1).md"
|
||||||
|
);
|
||||||
|
|
||||||
|
await fileOperations.create("d.md", new Uint8Array());
|
||||||
|
await fileOperations.move("d.md", "b.md");
|
||||||
|
assertSetContainsExactly(
|
||||||
|
fileSystemOperations.names,
|
||||||
|
"b.md",
|
||||||
|
"b (1).md",
|
||||||
|
"b (2).md"
|
||||||
|
);
|
||||||
|
|
||||||
|
await fileOperations.create("file-23.md", new Uint8Array());
|
||||||
|
await fileOperations.create("file-23 (1).md", new Uint8Array());
|
||||||
|
await fileOperations.move("file-23.md", "file-23 (1).md");
|
||||||
|
assertSetContainsExactly(
|
||||||
|
fileSystemOperations.names,
|
||||||
|
"b.md",
|
||||||
|
"b (1).md",
|
||||||
|
"b (2).md",
|
||||||
|
"file-23 (1).md",
|
||||||
|
"file-23 (2).md"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("create works for nested paths (parent-directory creation)", async () => {
|
it("should deconflict renames with paths", async () => {
|
||||||
const { fs, ops } = makeOps();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
|
const fileOperations = new FileOperations(
|
||||||
|
new Logger(),
|
||||||
|
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
fileSystemOperations,
|
||||||
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
);
|
||||||
|
|
||||||
await ops.create("a/b.c/d", new Uint8Array());
|
await fileOperations.create("a/b.c/d", new Uint8Array());
|
||||||
assertSetContainsExactly(fs.names, "a/b.c/d");
|
await fileOperations.create("a/b.c/e", new Uint8Array());
|
||||||
|
await fileOperations.move("a/b.c/d", "a/b.c/e");
|
||||||
|
assertSetContainsExactly(
|
||||||
|
fileSystemOperations.names,
|
||||||
|
"a/b.c/e",
|
||||||
|
"a/b.c/e (1)"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("move works for nested target paths (parent-directory creation)", async () => {
|
it("should continue deconfliction from existing number in filename", async () => {
|
||||||
const { fs, ops } = makeOps();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
|
const fileOperations = new FileOperations(
|
||||||
|
new Logger(),
|
||||||
|
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
fileSystemOperations,
|
||||||
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
);
|
||||||
|
|
||||||
await ops.create("source", new Uint8Array());
|
await fileOperations.create("document (5).md", new Uint8Array());
|
||||||
await ops.move("source", "a/b.c/dest");
|
await fileOperations.create("other.md", new Uint8Array());
|
||||||
|
|
||||||
assertSetContainsExactly(fs.names, "a/b.c/dest");
|
await fileOperations.move("other.md", "document (5).md");
|
||||||
|
assertSetContainsExactly(
|
||||||
|
fileSystemOperations.names,
|
||||||
|
"document (5).md",
|
||||||
|
"document (6).md"
|
||||||
|
);
|
||||||
|
|
||||||
|
await fileOperations.create("another.md", new Uint8Array());
|
||||||
|
await fileOperations.move("another.md", "document (5).md");
|
||||||
|
assertSetContainsExactly(
|
||||||
|
fileSystemOperations.names,
|
||||||
|
"document (5).md",
|
||||||
|
"document (6).md",
|
||||||
|
"document (7).md"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle dotfiles correctly", async () => {
|
||||||
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
|
const fileOperations = new FileOperations(
|
||||||
|
new Logger(),
|
||||||
|
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
fileSystemOperations,
|
||||||
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
);
|
||||||
|
|
||||||
|
await fileOperations.create(".gitignore", new Uint8Array());
|
||||||
|
await fileOperations.create("temp", new Uint8Array());
|
||||||
|
await fileOperations.move("temp", ".gitignore");
|
||||||
|
assertSetContainsExactly(
|
||||||
|
fileSystemOperations.names,
|
||||||
|
".gitignore",
|
||||||
|
".gitignore (1)"
|
||||||
|
);
|
||||||
|
|
||||||
|
await fileOperations.create(".config.json", new Uint8Array());
|
||||||
|
await fileOperations.create("temp2", new Uint8Array());
|
||||||
|
await fileOperations.move("temp2", ".config.json");
|
||||||
|
assertSetContainsExactly(
|
||||||
|
fileSystemOperations.names,
|
||||||
|
".gitignore",
|
||||||
|
".gitignore (1)",
|
||||||
|
".config.json",
|
||||||
|
".config (1).json"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,28 @@
|
||||||
import type { Logger } from "../tracing/logger";
|
import type { Logger } from "../tracing/logger";
|
||||||
import type { FileSystemOperations } from "./filesystem-operations";
|
import type { FileSystemOperations } from "./filesystem-operations";
|
||||||
import type { RelativePath } from "../sync-operations/types";
|
import type { Database, RelativePath } from "../persistence/database";
|
||||||
import { SafeFileSystemOperations } from "./safe-filesystem-operations";
|
import { SafeFileSystemOperations } from "./safe-filesystem-operations";
|
||||||
import type { TextWithCursors } from "reconcile-text";
|
import type { TextWithCursors } from "reconcile-text";
|
||||||
import { reconcile } from "reconcile-text";
|
import { reconcile } from "reconcile-text";
|
||||||
import { isFileTypeMergable } from "../utils/is-file-type-mergable";
|
import { isFileTypeMergable } from "../utils/is-file-type-mergable";
|
||||||
import { isBinary } from "../utils/is-binary";
|
import { isBinary } from "../utils/is-binary";
|
||||||
import type { ServerConfig } from "../services/server-config";
|
import type { ServerConfig } from "../services/server-config";
|
||||||
import { FileNotFoundError } from "../errors/file-not-found-error";
|
|
||||||
import { FileAlreadyExistsError } from "../errors/file-already-exists-error";
|
|
||||||
import type { ExpectedFsEvents } from "../sync-operations/expected-fs-events";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Outcome of a `move`/`create`. `actualPath` is where the file ended up;
|
|
||||||
* with the conflict-path machinery removed it is always equal to the
|
|
||||||
* requested path. The shape is preserved so callers don't all need to
|
|
||||||
* change.
|
|
||||||
*/
|
|
||||||
export interface FileOpResult {
|
|
||||||
actualPath: RelativePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FileOperations {
|
export class FileOperations {
|
||||||
|
private static readonly PARENTHESES_REGEX = / \((?<count>\d+)\)$/;
|
||||||
private readonly fs: SafeFileSystemOperations;
|
private readonly fs: SafeFileSystemOperations;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
|
private readonly database: Database,
|
||||||
fs: FileSystemOperations,
|
fs: FileSystemOperations,
|
||||||
private readonly serverConfig: ServerConfig,
|
private readonly serverConfig: ServerConfig,
|
||||||
private readonly expectedFsEvents: ExpectedFsEvents,
|
|
||||||
private readonly nativeLineEndings = "\n"
|
private readonly nativeLineEndings = "\n"
|
||||||
) {
|
) {
|
||||||
this.fs = new SafeFileSystemOperations(fs, logger);
|
this.fs = new SafeFileSystemOperations(fs, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getParentDirAndFileName(
|
private static getParentDirAndFile(
|
||||||
path: RelativePath
|
path: RelativePath
|
||||||
): [RelativePath, RelativePath] {
|
): [RelativePath, RelativePath] {
|
||||||
const pathParts = path.split("/");
|
const pathParts = path.split("/");
|
||||||
|
|
@ -57,42 +45,43 @@ export class FileOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a file at the specified path.
|
* Create a file at the specified path.
|
||||||
*
|
*
|
||||||
* Throws `FileAlreadyExistsError` if a file already lives at `path`.
|
* If a file with the same name already exists, it is moved before creating the new one.
|
||||||
* Parent directories are created if necessary. The reconciler is the
|
* Parent directories are created if necessary.
|
||||||
* only caller that places files now and pre-checks for conflicts;
|
*/
|
||||||
* the throw guards against a TOCTOU race rather than being a normal
|
|
||||||
* code path.
|
|
||||||
*/
|
|
||||||
public async create(
|
public async create(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
newContent: Uint8Array
|
newContent: Uint8Array
|
||||||
): Promise<FileOpResult> {
|
): Promise<void> {
|
||||||
if (await this.fs.exists(path)) {
|
await this.ensureClearPath(path);
|
||||||
throw new FileAlreadyExistsError(
|
return this.fs.write(path, this.toNativeLineEndings(newContent));
|
||||||
`Refusing to create '${path}': file already exists`,
|
}
|
||||||
path
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.createParentDirectories(path);
|
|
||||||
|
|
||||||
this.expectedFsEvents.expectCreate(path);
|
public async ensureClearPath(path: RelativePath): Promise<void> {
|
||||||
try {
|
if (await this.fs.exists(path)) {
|
||||||
await this.fs.write(path, this.toNativeLineEndings(newContent));
|
const deconflictedPath = await this.deconflictPath(path);
|
||||||
} catch (e) {
|
try {
|
||||||
this.expectedFsEvents.unexpectCreate(path);
|
this.logger.debug(
|
||||||
throw e;
|
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
|
||||||
|
);
|
||||||
|
|
||||||
|
this.database.move(path, deconflictedPath);
|
||||||
|
await this.fs.rename(path, deconflictedPath, true);
|
||||||
|
} finally {
|
||||||
|
this.fs.unlock(deconflictedPath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.createParentDirectories(path);
|
||||||
}
|
}
|
||||||
return { actualPath: path };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the file at the given path.
|
* Update the file at the given path.
|
||||||
*
|
*
|
||||||
* Performs a 3-way merge before writing if the file's content differs from `expectedContent`.
|
* Performs a 3-way merge before writing if the file's content differs from `expectedContent`.
|
||||||
* Does not recreate the file if it no longer exists, returning an empty array instead.
|
* Does not recreate the file if it no longer exists, returning an empty array instead.
|
||||||
*/
|
*/
|
||||||
public async write(
|
public async write(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
expectedContent: Uint8Array,
|
expectedContent: Uint8Array,
|
||||||
|
|
@ -105,96 +94,58 @@ export class FileOperations {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-source the expectation registration: register exactly once
|
if (
|
||||||
// per call, and unexpect from the catch if the underlying fs op
|
!isFileTypeMergable(
|
||||||
// throws (FileNotFoundError or otherwise). The previous shape
|
|
||||||
// registered inside each branch and let the catch swallow
|
|
||||||
// FileNotFoundError, leaking the expectation into the map.
|
|
||||||
this.expectedFsEvents.expectUpdate(path);
|
|
||||||
try {
|
|
||||||
if (
|
|
||||||
!isFileTypeMergable(
|
|
||||||
path,
|
|
||||||
(await this.serverConfig.getConfig())
|
|
||||||
.mergeableFileExtensions
|
|
||||||
) ||
|
|
||||||
isBinary(expectedContent) ||
|
|
||||||
isBinary(newContent)
|
|
||||||
) {
|
|
||||||
this.logger.debug(
|
|
||||||
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
|
|
||||||
);
|
|
||||||
await this.fs.write(
|
|
||||||
path,
|
|
||||||
// `newContent` might not be binary so we still have to ensure the line endings are correct
|
|
||||||
this.toNativeLineEndings(newContent)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let expectedText = "";
|
|
||||||
let newText = "";
|
|
||||||
try {
|
|
||||||
expectedText = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
||||||
expectedContent
|
|
||||||
); // this comes from a previous read which must only have \n line endings
|
|
||||||
newText = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
||||||
newContent
|
|
||||||
); // this comes from the server which stores text with \n line endings
|
|
||||||
} catch (decodeError) {
|
|
||||||
this.logger.warn(
|
|
||||||
`3-way merge aborted for ${path}: one of expected/new is not valid UTF-8 (${decodeError}); falling back to overwrite`
|
|
||||||
);
|
|
||||||
await this.fs.write(path, this.toNativeLineEndings(newContent));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.fs.atomicUpdateText(
|
|
||||||
path,
|
path,
|
||||||
({ text, cursors }: TextWithCursors): TextWithCursors => {
|
(await this.serverConfig.getConfig()).mergeableFileExtensions
|
||||||
this.logger.debug(
|
) ||
|
||||||
`Performing a 3-way merge for ${path} with the expected content`
|
isBinary(expectedContent) ||
|
||||||
);
|
isBinary(newContent)
|
||||||
|
) {
|
||||||
text = text.replaceAll(this.nativeLineEndings, "\n");
|
this.logger.debug(
|
||||||
const merged = reconcile(
|
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
|
||||||
expectedText,
|
|
||||||
{ text, cursors },
|
|
||||||
newText
|
|
||||||
);
|
|
||||||
|
|
||||||
const resultText = merged.text.replaceAll(
|
|
||||||
"\n",
|
|
||||||
this.nativeLineEndings
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
text: resultText,
|
|
||||||
cursors: merged.cursors
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
await this.fs.write(
|
||||||
this.expectedFsEvents.unexpectUpdate(path);
|
path,
|
||||||
if (e instanceof FileNotFoundError) {
|
// `newContent` might not be binary so we still have to ensure the line endings are correct
|
||||||
this.logger.debug(
|
this.toNativeLineEndings(newContent)
|
||||||
`File ${path} disappeared during write; not recreating`
|
);
|
||||||
);
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings
|
||||||
|
const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings
|
||||||
|
|
||||||
|
await this.fs.atomicUpdateText(
|
||||||
|
path,
|
||||||
|
({ text, cursors }: TextWithCursors): TextWithCursors => {
|
||||||
|
this.logger.debug(
|
||||||
|
`Performing a 3-way merge for ${path} with the expected content`
|
||||||
|
);
|
||||||
|
|
||||||
|
text = text.replaceAll(this.nativeLineEndings, "\n");
|
||||||
|
const merged = reconcile(
|
||||||
|
expectedText,
|
||||||
|
{ text, cursors },
|
||||||
|
newText
|
||||||
|
);
|
||||||
|
|
||||||
|
const resultText = merged.text.replaceAll(
|
||||||
|
"\n",
|
||||||
|
this.nativeLineEndings
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: resultText,
|
||||||
|
cursors: merged.cursors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(path: RelativePath): Promise<void> {
|
public async delete(path: RelativePath): Promise<void> {
|
||||||
if (await this.exists(path)) {
|
if (await this.exists(path)) {
|
||||||
this.expectedFsEvents.expectDelete(path);
|
await this.fs.delete(path);
|
||||||
try {
|
|
||||||
await this.fs.delete(path);
|
|
||||||
} catch (e) {
|
|
||||||
this.expectedFsEvents.unexpectDelete(path);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
|
await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug(`No need to delete '${path}', it doesn't exist`);
|
this.logger.debug(`No need to delete '${path}', it doesn't exist`);
|
||||||
|
|
@ -209,39 +160,23 @@ export class FileOperations {
|
||||||
return this.fs.exists(path);
|
return this.fs.exists(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Move the file at `oldPath` to `newPath`.
|
|
||||||
*
|
|
||||||
* Throws `FileAlreadyExistsError` if a file already lives at `newPath`
|
|
||||||
* (and `oldPath !== newPath`). The reconciler is the only caller that
|
|
||||||
* relocates tracked records and pre-checks for conflicts; the throw
|
|
||||||
* guards against a TOCTOU race.
|
|
||||||
*/
|
|
||||||
public async move(
|
public async move(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath
|
newPath: RelativePath
|
||||||
): Promise<FileOpResult> {
|
): Promise<void> {
|
||||||
if (oldPath === newPath) {
|
if (oldPath === newPath) {
|
||||||
return { actualPath: oldPath };
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await this.fs.exists(newPath)) {
|
await this.ensureClearPath(newPath);
|
||||||
throw new FileAlreadyExistsError(
|
|
||||||
`Refusing to move '${oldPath}' onto '${newPath}': target already exists`,
|
|
||||||
newPath
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.createParentDirectories(newPath);
|
|
||||||
|
|
||||||
this.expectedFsEvents.expectRename(oldPath, newPath);
|
this.database.move(oldPath, newPath);
|
||||||
try {
|
await this.fs.rename(oldPath, newPath);
|
||||||
await this.fs.rename(oldPath, newPath);
|
|
||||||
} catch (e) {
|
|
||||||
this.expectedFsEvents.unexpectRename(oldPath, newPath);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
||||||
return { actualPath: newPath };
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this.fs.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deletingEmptyParentDirectoriesOfDeletedFile(
|
private async deletingEmptyParentDirectoriesOfDeletedFile(
|
||||||
|
|
@ -250,7 +185,7 @@ export class FileOperations {
|
||||||
let directory = path;
|
let directory = path;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
[directory] = FileOperations.getParentDirAndFileName(directory);
|
[directory] = FileOperations.getParentDirAndFile(directory);
|
||||||
if (directory.length === 0) {
|
if (directory.length === 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -302,4 +237,55 @@ export class FileOperations {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found.
|
||||||
|
* The returned path has a lock acquired on it; it must be released by the caller when no longer needed.
|
||||||
|
*
|
||||||
|
* @param path The starting path to deconflict
|
||||||
|
* @returns a non-existent path with a lock acquired on it
|
||||||
|
*/
|
||||||
|
private async deconflictPath(path: RelativePath): Promise<RelativePath> {
|
||||||
|
// eslint-disable-next-line prefer-const
|
||||||
|
let [directory, fileName] = FileOperations.getParentDirAndFile(path);
|
||||||
|
|
||||||
|
if (directory) {
|
||||||
|
directory += "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameParts = fileName.split(".");
|
||||||
|
// Handle dotfiles: ".gitignore" should have no extension, ".config.json" should have ".json"
|
||||||
|
const isDotfile = fileName.startsWith(".") && nameParts[0] === "";
|
||||||
|
const extension =
|
||||||
|
nameParts.length > 1 && !(isDotfile && nameParts.length === 2)
|
||||||
|
? "." + nameParts[nameParts.length - 1]
|
||||||
|
: "";
|
||||||
|
let stem = extension ? nameParts.slice(0, -1).join(".") : fileName;
|
||||||
|
let currentCount = Number.parseInt(
|
||||||
|
FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.count ?? "0"
|
||||||
|
);
|
||||||
|
stem = stem.replace(FileOperations.PARENTHESES_REGEX, "");
|
||||||
|
|
||||||
|
let newName = path;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
while (true) {
|
||||||
|
currentCount++;
|
||||||
|
newName = `${directory}${stem} (${currentCount})${extension}`;
|
||||||
|
|
||||||
|
// Avoid multiple deconflictPath calls returning the same path
|
||||||
|
if (this.fs.tryLock(newName)) {
|
||||||
|
const newDocument =
|
||||||
|
this.database.getLatestDocumentByRelativePath(newName);
|
||||||
|
if (
|
||||||
|
newDocument?.isDeleted === false || // the document might have been confirmed by the server at a new path but haven't yet moved there locally
|
||||||
|
(await this.fs.exists(newName, true))
|
||||||
|
) {
|
||||||
|
this.fs.unlock(newName);
|
||||||
|
} else {
|
||||||
|
return newName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { RelativePath } from "../sync-operations/types";
|
import type { RelativePath } from "../persistence/database";
|
||||||
|
|
||||||
import type { TextWithCursors } from "reconcile-text";
|
import type { TextWithCursors } from "reconcile-text";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,24 @@
|
||||||
import type { RelativePath } from "../sync-operations/types";
|
import type { RelativePath } from "../persistence/database";
|
||||||
import type { FileSystemOperations } from "./filesystem-operations";
|
import type { FileSystemOperations } from "./filesystem-operations";
|
||||||
import type { Logger } from "../tracing/logger";
|
import type { Logger } from "../tracing/logger";
|
||||||
import { FileNotFoundError } from "../errors/file-not-found-error";
|
import { Locks } from "../utils/data-structures/locks";
|
||||||
|
import { FileNotFoundError } from "./file-not-found-error";
|
||||||
import type { TextWithCursors } from "reconcile-text";
|
import type { TextWithCursors } from "reconcile-text";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decorates `FileSystemOperations` to replace errors with `FileNotFoundError`
|
* Decorates `FileSystemOperations` to replace errors with `FileNotFoundError`
|
||||||
* if the accessed file doesn't exist.
|
* if the accessed file doesn't exist. It also ensures that there's at most a
|
||||||
|
* single request in-flight for any one file through the use of locks.
|
||||||
*/
|
*/
|
||||||
export class SafeFileSystemOperations implements FileSystemOperations {
|
export class SafeFileSystemOperations implements FileSystemOperations {
|
||||||
|
private readonly locks: Locks<RelativePath>;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly fs: FileSystemOperations,
|
private readonly fs: FileSystemOperations,
|
||||||
private readonly logger: Logger
|
private readonly logger: Logger
|
||||||
) {}
|
) {
|
||||||
|
this.locks = new Locks(logger);
|
||||||
|
}
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
root: RelativePath | undefined
|
root: RelativePath | undefined
|
||||||
|
|
@ -25,12 +31,19 @@ export class SafeFileSystemOperations implements FileSystemOperations {
|
||||||
|
|
||||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||||
this.logger.debug(`Reading file '${path}'`);
|
this.logger.debug(`Reading file '${path}'`);
|
||||||
return this.safeOperation(path, async () => this.fs.read(path), "read");
|
return this.safeOperation(
|
||||||
|
path,
|
||||||
|
async () =>
|
||||||
|
this.locks.withLock(path, async () => this.fs.read(path)),
|
||||||
|
"read"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
||||||
this.logger.debug(`Writing to file '${path}'`);
|
this.logger.debug(`Writing to file '${path}'`);
|
||||||
return this.fs.write(path, content);
|
return this.locks.withLock(path, async () =>
|
||||||
|
this.fs.write(path, content)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async atomicUpdateText(
|
public async atomicUpdateText(
|
||||||
|
|
@ -40,7 +53,10 @@ export class SafeFileSystemOperations implements FileSystemOperations {
|
||||||
this.logger.debug(`Atomically updating file '${path}'`);
|
this.logger.debug(`Atomically updating file '${path}'`);
|
||||||
return this.safeOperation(
|
return this.safeOperation(
|
||||||
path,
|
path,
|
||||||
async () => this.fs.atomicUpdateText(path, updater),
|
async () =>
|
||||||
|
this.locks.withLock(path, async () =>
|
||||||
|
this.fs.atomicUpdateText(path, updater)
|
||||||
|
),
|
||||||
"atomicUpdateText"
|
"atomicUpdateText"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -49,43 +65,80 @@ export class SafeFileSystemOperations implements FileSystemOperations {
|
||||||
// Logging this would be too noisy
|
// Logging this would be too noisy
|
||||||
return this.safeOperation(
|
return this.safeOperation(
|
||||||
path,
|
path,
|
||||||
async () => this.fs.getFileSize(path),
|
async () =>
|
||||||
|
this.locks.withLock(path, async () =>
|
||||||
|
this.fs.getFileSize(path)
|
||||||
|
),
|
||||||
"getFileSize"
|
"getFileSize"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exists(path: RelativePath): Promise<boolean> {
|
public async exists(
|
||||||
|
path: RelativePath,
|
||||||
|
skipLock = false
|
||||||
|
): Promise<boolean> {
|
||||||
this.logger.debug(`Checking if file '${path}' exists`);
|
this.logger.debug(`Checking if file '${path}' exists`);
|
||||||
return this.fs.exists(path);
|
if (skipLock) {
|
||||||
|
return this.fs.exists(path);
|
||||||
|
} else {
|
||||||
|
return this.locks.withLock(path, async () => this.fs.exists(path));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createDirectory(path: RelativePath): Promise<void> {
|
public async createDirectory(path: RelativePath): Promise<void> {
|
||||||
this.logger.debug(`Creating directory '${path}'`);
|
this.logger.debug(`Creating directory '${path}'`);
|
||||||
return this.fs.createDirectory(path);
|
return this.locks.withLock(path, async () =>
|
||||||
|
this.fs.createDirectory(path)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(path: RelativePath): Promise<void> {
|
public async delete(path: RelativePath): Promise<void> {
|
||||||
this.logger.debug(`Deleting file '${path}'`);
|
this.logger.debug(`Deleting file '${path}'`);
|
||||||
return this.fs.delete(path);
|
return this.locks.withLock(path, async () => this.fs.delete(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async rename(
|
public async rename(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath
|
newPath: RelativePath,
|
||||||
|
skipLock = false
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`);
|
this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`);
|
||||||
return this.safeOperation(
|
return this.safeOperation(
|
||||||
oldPath,
|
oldPath,
|
||||||
async () => this.fs.rename(oldPath, newPath),
|
async () => {
|
||||||
|
if (skipLock) {
|
||||||
|
return this.fs.rename(oldPath, newPath);
|
||||||
|
} else {
|
||||||
|
return this.locks.withLock([oldPath, newPath], async () =>
|
||||||
|
this.fs.rename(oldPath, newPath)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
"rename"
|
"rename"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public tryLock(path: RelativePath): boolean {
|
||||||
|
return this.locks.tryLock(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async waitForLock(path: RelativePath): Promise<void> {
|
||||||
|
return this.locks.waitForLock(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unlock(path: RelativePath): void {
|
||||||
|
this.locks.unlock(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this.locks.reset();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decorate an operation to ensure that the file exists before running it.
|
* Decorate an operation to ensure that the file exists before running it.
|
||||||
* If the operation fails, it will check if the file still exists and throw
|
* If the operation fails, it will check if the file still exists and throw
|
||||||
* a FileNotFoundError if it doesn't.
|
* a FileNotFoundError if it doesn't.
|
||||||
*/
|
*/
|
||||||
private async safeOperation<T>(
|
private async safeOperation<T>(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
operation: () => Promise<T>,
|
operation: () => Promise<T>,
|
||||||
|
|
@ -101,6 +154,9 @@ export class SafeFileSystemOperations implements FileSystemOperations {
|
||||||
try {
|
try {
|
||||||
return await operation();
|
return await operation();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Without locking the file, this isn't atomic, however, it's good enough in practice.
|
||||||
|
// This will only break if the file exists, gets deleted and then immediately
|
||||||
|
// recreated while `operation` is running.
|
||||||
if (await this.fs.exists(path)) {
|
if (await this.fs.exists(path)) {
|
||||||
throw error;
|
throw error;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue