diff --git a/desktop/.cargo/audit.toml b/desktop/.cargo/audit.toml new file mode 100644 index 000000000..6e335ceed --- /dev/null +++ b/desktop/.cargo/audit.toml @@ -0,0 +1,4 @@ +[advisories] +ignore = [ + "RUSTSEC-2023-0071", # https://radicle.network/nodes/iris.radicle.network/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/issues/bd8af7a5e3fd02c0963d7550a5cc2cab00950775 +] diff --git a/desktop/.dockerignore b/desktop/.dockerignore new file mode 100644 index 000000000..e0a554867 --- /dev/null +++ b/desktop/.dockerignore @@ -0,0 +1,4 @@ +target/ +node_modules/ +releases/ +.git/ diff --git a/desktop/.envrc b/desktop/.envrc new file mode 100644 index 000000000..63991438e --- /dev/null +++ b/desktop/.envrc @@ -0,0 +1,3 @@ +watch_file rust-toolchain nix/* + +use flake diff --git a/desktop/.github/workflows/e2e.yaml b/desktop/.github/workflows/e2e.yaml new file mode 100644 index 000000000..2585f0d13 --- /dev/null +++ b/desktop/.github/workflows/e2e.yaml @@ -0,0 +1,58 @@ +on: + pull_request: + push: + workflow_dispatch: + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + e2e: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.61.1-noble + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + + - run: | + apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + pkg-config \ + xz-utils \ + libssl-dev \ + libxdo-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libwebkit2gtk-4.1-dev \ + libjavascriptcoregtk-4.1-dev \ + openssh-client + - run: npm ci + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Cache heartwood binaries + uses: actions/cache@v5 + with: + path: tests/tmp/bin + key: heartwood-${{ hashFiles('tests/support/heartwood-release') }} + + - run: scripts/install-binaries + - run: npm run build:http + - run: mkdir -p tests/artifacts + + - name: Run e2e tests + run: npm run test:e2e + + # Kept for passing runs too: a test that passed on retry still leaves + # the failed attempt's logs and trace behind. + - name: Upload test artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v6 + with: + name: e2e-artifacts + path: tests/artifacts + retention-days: 7 diff --git a/desktop/.github/workflows/lint.yaml b/desktop/.github/workflows/lint.yaml new file mode 100644 index 000000000..c9d43863a --- /dev/null +++ b/desktop/.github/workflows/lint.yaml @@ -0,0 +1,41 @@ +on: + pull_request: + push: + workflow_dispatch: + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + lint-typescript: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + + - run: npm ci + - run: npm run check-js + + lint-rust: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - run: | + sudo apt-get update && sudo apt-get install -y --no-install-recommends \ + libxdo-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libwebkit2gtk-4.1-dev \ + libjavascriptcoregtk-4.1-dev + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + + - run: scripts/check-rs diff --git a/desktop/.github/workflows/unit-test.yaml b/desktop/.github/workflows/unit-test.yaml new file mode 100644 index 000000000..e71a02c93 --- /dev/null +++ b/desktop/.github/workflows/unit-test.yaml @@ -0,0 +1,19 @@ +on: + pull_request: + push: + workflow_dispatch: + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + unit-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + - run: npm ci + - run: npm run test:unit diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 000000000..388fe69f9 --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,36 @@ +/build/ +/target/ +/target-release/ +/releases/ +node_modules/ + +# Tauri +crates/test-http-api/target +crates/radicle-tauri/target +crates/radicle-tauri/gen/schemas + +# KaTeX files +public/*.min.css +public/fonts/KaTeX_**.ttf +public/fonts/KaTeX_**.woff +public/fonts/KaTeX_**.woff2 + +# Twemoji Assets +public/twemoji/*.svg + +# Editor directories and files +.vscode +.idea + +# Mac OS +.DS_Store + +# Integration Tests +tests/tmp/**/* +tests/artifacts/**/* + +# direnv cache +/.direnv/ + +.claude/settings.local.json +.claude/worktrees/ diff --git a/desktop/.npmrc b/desktop/.npmrc new file mode 100644 index 000000000..b2e7eb32d --- /dev/null +++ b/desktop/.npmrc @@ -0,0 +1,3 @@ +min-release-age=7 +strict-allow-scripts=true +loglevel=warn diff --git a/desktop/.nvmrc b/desktop/.nvmrc new file mode 100644 index 000000000..5bf4400f2 --- /dev/null +++ b/desktop/.nvmrc @@ -0,0 +1 @@ +24.15.0 diff --git a/desktop/.prettierignore b/desktop/.prettierignore new file mode 100644 index 000000000..51e75c117 --- /dev/null +++ b/desktop/.prettierignore @@ -0,0 +1,3 @@ +crates +*.md +*.lock diff --git a/desktop/.prettierrc b/desktop/.prettierrc new file mode 100644 index 000000000..daa2f1ece --- /dev/null +++ b/desktop/.prettierrc @@ -0,0 +1,8 @@ +{ + "arrowParens": "avoid", + "plugins": ["prettier-plugin-svelte"], + "svelteSortOrder": "options-scripts-styles-markup", + "bracketSameLine": true, + "proseWrap": "never", + "htmlWhitespaceSensitivity": "ignore" +} diff --git a/desktop/.ryph/Dockerfile b/desktop/.ryph/Dockerfile new file mode 100644 index 000000000..6068b206d --- /dev/null +++ b/desktop/.ryph/Dockerfile @@ -0,0 +1,39 @@ +FROM ghcr.io/drips-network/ryph-universal:1 + +USER root + +# Tauri's system libraries (radicle-desktop's cargo build, clippy and tests), +# and bzip2 for radicle-explorer's .tar.bz2 test fixtures. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libwebkit2gtk-4.1-dev \ + libjavascriptcoregtk-4.1-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libxdo-dev \ + bzip2 \ + && rm -rf /var/lib/apt/lists/* + +# The OS libraries Playwright's browsers need: WebKit for radicle-desktop's e2e +# tests, Chromium and WebKit for radicle-explorer's and always-on-node's. The +# browsers themselves are installed by each member's setup, keyed on its lockfile. +RUN npx -y playwright@1.61.1 install-deps chromium webkit \ + && rm -rf /var/lib/apt/lists/* /root/.npm \ + && chown -R runner:runner /usr/local/share/mise + +# garden-broker's `make lint`. +RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/v2.14.0/install.sh \ + | sh -s -- -b /usr/local/bin v2.14.0 \ + && golangci-lint version + +USER runner + +# garden-broker's go.mod files say `go 1.25.0`, a minimum, and pin no toolchain. +# Without this, mise reads that line as a pin and tries to download Go 1.25.0 +# instead of using the image's newer Go. +ENV MISE_IDIOMATIC_VERSION_FILE_ENABLE_TOOLS=node,python,deno,bun + +# Debug info is most of a warm target/ (8 GB of radicle-desktop's 13). Line +# tables keep file:line in panics and backtraces. Set for the whole machine, +# so the image's warm build and every run's builds use the same profile. +ENV CARGO_PROFILE_DEV_DEBUG=line-tables-only diff --git a/desktop/.ryph/environment.md b/desktop/.ryph/environment.md new file mode 100644 index 000000000..640217f20 --- /dev/null +++ b/desktop/.ryph/environment.md @@ -0,0 +1,43 @@ +# radicle-desktop environment + +This repository's `.ryph/Dockerfile` is the machine for the whole Radicle +workspace. On top of the universal image it adds: + +- Tauri's system libraries (`libwebkit2gtk-4.1-dev`, + `libjavascriptcoregtk-4.1-dev`, `libayatana-appindicator3-dev`, + `librsvg2-dev`, `libxdo-dev`), so the Rust crates build here; +- the OS libraries Playwright's Chromium and WebKit need, and `bzip2`; +- `golangci-lint` v2.14.0 in `/usr/local/bin`, for garden-broker; +- `MISE_IDIOMATIC_VERSION_FILE_ENABLE_TOOLS=node,python,deno,bun`, so mise + does not read a `go.mod` `go` line as a pin: Go is the image's own. +- `CARGO_PROFILE_DEV_DEBUG=line-tables-only`: dev and test builds carry + line tables, not full debug info. Backtraces keep file and line. Do not + override it, or every crate rebuilds. + +`.ryph/setup.yml` installs the npm dependencies, Playwright's WebKit and the +pinned Radicle binaries (`tests/tmp/bin`), and warms clippy, the test build +and the e2e backend (`test-http-api`, `rad-job`) in `target/`. Node 24.15.0 +(`.nvmrc`) and Rust 1.97.1 (`rust-toolchain.toml`) are installed by mise and +rustup on first use. + +## Checks (what CI runs) + +```sh +npm run check-js # svelte-check, eslint, prettier +scripts/check-rs # cargo fmt --check, clippy -Dwarnings, cargo test +npm run test:unit +npm run test:e2e # WebKit; needs scripts/install-binaries, done by setup +``` + +`npm run check:ci` runs all of them in order and logs to +`tests/tmp/check-ci.log`. The machine has 2 CPUs: the Rust test build and the +e2e suite are the slow parts. + +## What does not work here + +- Chromium cannot be installed by Playwright: its download redirects to + `storage.googleapis.com`, which the egress allowlist refuses. This app's + e2e tests use WebKit only and are unaffected; radicle-explorer's and + always-on-node's Chromium e2e tests are. +- There is no container runtime, so nothing that runs `docker` or `podman` + works (always-on-node's `pnpm test:e2e`, garden-broker's integration tests). diff --git a/desktop/.ryph/setup.yml b/desktop/.ryph/setup.yml new file mode 100644 index 000000000..3bd3c8bf7 --- /dev/null +++ b/desktop/.ryph/setup.yml @@ -0,0 +1,26 @@ +steps: + # An image layer keeps whole-second mtimes, but cargo fingerprints the + # nanoseconds it saw at build time. Rounding tracked files down to the + # second before building keeps the warm target/ fresh at run time. Never + # set them to the commit time: on an incremental image build that can make + # a changed file older than its stale build output. + - name: round source mtimes down to whole seconds (image builds only) + run: '[ "$RYPH_SETUP_PHASE" != image ] || git ls-files -z | xargs -0 perl -e ''for (@ARGV) { next if -l; my $m = (lstat)[9]; utime $m, $m, $_ }''' + - name: install npm dependencies + run: npm install --prefer-offline + - name: install Playwright WebKit + run: npx playwright install webkit + - name: install the pinned Radicle binaries for e2e tests + run: scripts/install-binaries + # The cargo warm steps only fill the image's target/. At run time cargo + # builds what a command needs when it runs. + - name: warm clippy (scripts/check-rs, image builds only) + run: 'mkdir -p build && { [ "$RYPH_SETUP_PHASE" != image ] || cargo clippy --workspace --all-targets --locked; }' + - name: warm cargo test (scripts/check-rs, image builds only) + run: '[ "$RYPH_SETUP_PHASE" != image ] || cargo test --workspace --no-run --locked' + - name: warm the e2e backend (tests/support/globalSetup.ts, image builds only) + run: '[ "$RYPH_SETUP_PHASE" != image ] || { cargo build --manifest-path ./crates/test-http-api/Cargo.toml --locked && cargo build -p radicle-job --bin rad-job --locked; }' + # rustc's incremental cache only speeds up rebuilding this repo's own + # crates after an edit, and it is gigabytes. Image builds only. + - name: drop the incremental cache (image builds only) + run: '[ "$RYPH_SETUP_PHASE" != image ] || rm -rf target/debug/incremental' diff --git a/desktop/CHANGELOG.md b/desktop/CHANGELOG.md new file mode 100644 index 000000000..c520f5834 --- /dev/null +++ b/desktop/CHANGELOG.md @@ -0,0 +1,395 @@ +### v0.17.0 + +**Features** + +- Added Releases and Release views for browsing a repository's releases and + their artifacts, with links, mentions, a warning on releases and artifacts + published by others, and the artifact node and rad-artifact CLI versions + in the status popover +- Added smart links and better mentions: `rad:` links now open in the app, + Radicle entities in comments are linked, and mentions search node aliases +- Added a repository identity document view +- Assignees are now suggested by alias as you type +- Added keyboard shortcuts +- Added Markdown shortcuts to textareas +- Markdown now renders Mermaid diagrams, GitHub style alerts and repository + images in READMEs +- Task list items in Markdown can now be checked and unchecked +- AsciiDoc READMEs are now rendered in the source view, with cross + references linked to their source +- Document links now stay inside the app +- Merged patches are now sorted by merge time +- The patch list now shows the latest revision's diffstat +- You can seed a missing repository directly from mentions +- Adding new repositories will immediately fetch them from the network +- Issues can now be created with only a subject +- The issue page now matches the patch page design, with the metadata + buttons in the action row +- Collapsed sidebar items now show tooltips, and private repositories show + a lock on their avatar +- Polished sidebar animations and spacing + +**Bug fixes** + +- Back and Forward are now disabled when they lead nowhere +- Fixed file tree folders flickering and closing when selecting a file +- The source view now keeps the browsed file when navigating +- Fixed muddy diff colors in light mode +- Fixed pasted images not rendering +- Retina screenshots are now shown at their intended size +- Dropped files now go only into the textarea under the cursor +- Files dropped from Windows paths are now named correctly +- Assignee changes are now shown correctly in timelines +- A number of fixes to Markdown rendering +- RIDs with non-base58 characters are now treated as invalid +- Failed CI job lookups are now handled + +**Shout-out to our contributors 🙏✨** + +- [2color](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MktwkohCx8aHZ1QCjVZUiLmX92oPZFxRiFZkbq32Tk5Tkm) +- [brandonoxendine](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkpwnLQxFBQXyMK3Es91s8A7Ew7G11BFFyng1dqZR8QhG3) +- [efstajas](https://radicle.network/nodes/iris.radicle.network/users/z6MkqG3Pu18RWuZ8GrqGQ9s4yoi5rfhcLq9rpo1ZZX8en994) + + +### v0.16.0 + +**Features** + +- Added a collapsible, resizable sidebar that remembers its width and + collapsed state across launches +- Repositories that are only in local storage can now be shown alongside + seeded ones, controlled by a new setting +- Private repositories are now marked with a lock and public ones with a + globe in the sidebar +- Added "Stop seeding" to the sidebar context menu, with the option to + delete the repository files as well +- Patches now show the branch they are opened against, in the patch list, + the patch page and the merge activity item +- The author and committer popover now shows emails, lists co-authors, and + shows both the authored and committed time +- The detected git binary is now shown in the settings +- The Guide button in the sidebar can now be hidden +- Added a Cmd+, shortcut to open Settings +- The issue modal can now be submitted with Cmd+Return from the title field +- The app now logs to a file when it isn't launched from a terminal + +**Bug fixes** + +- Fixed patch diffs for revisions whose head is a merge commit dropping + most of their commits +- Fixed a single duplicate path, such as a symlink replaced by a regular + file, emptying the whole Changes tab +- Git and rad are now resolved outside the session PATH, fixing git not + being found when the app is launched from the desktop +- Editing a patch title no longer resets its target branch to the default + branch +- The patch description is no longer collapsed while it is being edited +- Reacting or commenting no longer makes the view jump +- Clicking a commit in a patch no longer shifts the layout +- The window stays draggable while a modal is open +- Save buttons are now disabled instead of hidden when there is nothing to + save +- Commit time is now used instead of author time consistently + + +**Shout-out to our contributors 🙏✨** + +- [2color](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MktwkohCx8aHZ1QCjVZUiLmX92oPZFxRiFZkbq32Tk5Tkm) +- [brandonoxendine](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkpwnLQxFBQXyMK3Es91s8A7Ew7G11BFFyng1dqZR8QhG3) +- [upbeat-molar](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkmRaBHeJuJKQE21aRVSe2uW3j5T7gQQAEAY7UF4tr5jRn) + + +### v0.15.0 + +**Features** + +- Reworked the patch page UX, review UI and activity timeline +- Patch changes are now rendered with Pierre's diff viewer +- Added a comment column to the review view: every line comment is listed + beside the files, and clicking one scrolls the diff to it +- The review view's column can now switch between comments and a file tree, + show only the commented files, or be put away to give the diff full width +- Added a comment stepper to the Changes tab for walking a diff's comments +- Diff settings now live in the settings modal, grouped with the code font, + instead of a per-view popover +- Patches can now be deleted +- Discussion comments can now be deleted +- Resolved comments now show who resolved them +- Added an author hover card +- Timestamps now show their absolute value on hover +- Added dismissible hints to textboxes + + +**Shout-out to our contributors 🙏✨** + +- [brandonoxendine](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkpwnLQxFBQXyMK3Es91s8A7Ew7G11BFFyng1dqZR8QhG3) + + +### v0.14.0 + +**Features** + +- Added a share widget to repo, issue and patch pages, replacing the separate + copy-link and open-in-explorer buttons +- Commit diffs are now rendered with Pierre's diff viewer + +**Bug fixes** + +- Links now use your configured explorer and preferred seed instead of + hardcoded defaults +- Fixed console windows flashing when navigating repos on Windows + + +**Shout-out to our contributors 🙏✨** + +- [brandonoxendine](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkpwnLQxFBQXyMK3Es91s8A7Ew7G11BFFyng1dqZR8QhG3) +- [ck](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkuLTraBcQJ1FNVPjN61vWXhMsTznTzr1Rmn1SfFmdhFkY) +- [hlatimer266](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkkwiqeqFrjPNvSNuNACpynpwiuVHqF8YHwGLzCBSfNJzZ) +- [justarandomgeek](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkwfrBy9mKTfcVELcV4wc6zfN379FPMnAqsxnwt4j2TdQ2) + + +### v0.13.0 + +**Features** + +- Added a diff actions menu with copy-to-clipboard and save-to-disk options + +**Performance** + +- Virtualized the file tree, commit diff, and patch-review diff views for smoother scrolling on large repositories and diffs +- Sped up opening files and computing diff stats in large repositories +- Sped up commit counting using git's bitmap index +- Improved issues list loading performance + +**Bug fixes** + +- Fixed patches list pagination loading everything at once instead of paging +- Fixed the update checker reading a stale cached version file +- Fixed the Linux launcher showing the wrong app name +- List scroll position is now restored when navigating back and forth through history + + +### v0.12.0 + +**Features** + +- Added a branch/tag selector to browse repository source from different users +- A placeholder is now shown when a commit has no message + +**Performance** + +- Improved overall performance for repository browsing + +**Bug fixes** + +- Fixed the Linux AppImage failing to launch on non-Debian distributions +- Fixed duplicate CI job runs by deduplicating them by run ID + + +### v0.11.0 + +**Features** + +- Added a commit history list and single-commit view for repositories +- Added repo pinning to keep favorite repos at the top of the sidebar +- Added a fuzzy filter to the notification inbox +- Added a reload button to the sidebar +- Added a custom in-app context menu for sidebar items and disabled the native browser menu app-wide +- Redesigned the CI job status popover and added it to the single-commit view +- Reviews can now be started directly from a code comment on a revision diff +- Issue and patch breadcrumbs now show status-specific labels and link back to the matching filter +- The first revision description is now shown on the patch view +- Replies are now indented into individual boxes for clearer threading +- Reactions have been restyled and your own reactions are highlighted +- Comment actions now appear only on hover for a calmer reading experience +- Polished comment form footer +- Save actions across the app now use brand styling +- The discussion comment form is now always visible +- Repo header text is now truncated to avoid layout overflow +- Navigating to a repo now expands the All Repos section in the sidebar + +**Bug fixes** + +- Patch authors can now change the state of their own patches +- Hardened rendered markdown links and restricted job log links to http(s) URLs +- Fixed the update checker not detecting new versions +- Fixed CSP rules so local assets load reliably in release builds +- Fixed a regression where the first patch revision wasn't editable +- Fixed inbox "Show all" behavior and aligned group filter styling +- Fixed emoji substitution in notification teasers and twemoji alt attributes +- Fixed markdown tables rendering text vertically +- Fixed ordered list clipping in rendered README and markdown content +- Fixed text-input autofocus after portal moves +- Fixed the contributor filter in the repo list +- Fixed the add-repo button incorrectly expanding the All Repos section +- Excluded job COBs without runs from the jobs list +- Registered the dialog plugin in release builds so file pickers work +- Worked around alias lookups failing in some cases +- Improved Windows compatibility (CLI detection and npm script hints) + + +**Shout-out to our contributors 🙏✨** + +- [2color](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MktwkohCx8aHZ1QCjVZUiLmX92oPZFxRiFZkbq32Tk5Tkm) +- [brandonhaslegs](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkpwnLQxFBQXyMK3Es91s8A7Ew7G11BFFyng1dqZR8QhG3) +- [fintohaps](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM) +- [julien](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkgvaRdahQZfnf7ccMHByeHtTYrMrM3MuSGYawF6ZL6DNj) +- [justarandomgeek](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkwfrBy9mKTfcVELcV4wc6zfN379FPMnAqsxnwt4j2TdQ2) +- [killerdevildog](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6Mkgi3EW1eVVb4REBZspeU9cHrJev8GEZae7uACZJkxCKMz) +- [lorenz](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz) +- [yarikoptic](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkfCGiLM9j7AstSVNGhqtz9zkAhfb863etsbkRnXsipuRr) + + +### v0.10.0 + +- Fixed app loading indicator +- Sidebar scroll position is now preserved when navigating between views +- Changed default seed node to iris.radicle.network +- Updated to Heartwood 1.7.1 + +**Shout-out to our contributors 🙏✨** + +- [lorenz](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz) + + +### v0.9.0 + +- Redesigned the entire app with a new UI kit — typography, colors, icons, + avatars, and border radii have all been updated for a more polished, + consistent appearance matching the rest of the ecosystem +- Replaced blockies-based avatars with a new DID-based avatar system +- Moved Settings and Create Issue into fullscreen modals +- Default theme now follows the system theme +- Removed window decorations on macOS for a cleaner, more native feel +- Updated app icon +- Rewrote app navigation and layouts +- Added a sidebar that gives you access to all your repos +- App now opens directly to the notification inbox on startup +- Getting started guide shown to new users on first launch +- Added a setting to disable the notification badge +- Added a code font setting +- Fixed reactions and edits incorrectly targeting the root comment instead of the intended reply +- Restricted issue/patch state, labels, and assignees to delegates only + +**Shout-out to our contributors 🙏✨** + +- [brandonhaslegs](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkpwnLQxFBQXyMK3Es91s8A7Ew7G11BFFyng1dqZR8QhG3) +- [julien](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkgvaRdahQZfnf7ccMHByeHtTYrMrM3MuSGYawF6ZL6DNj) + + +### v0.8.0 + +- CI job run statuses are now shown for the default branch and in patch revision commit lists +- Fixed an issue where commit listings reversed when switching between patches +- Fixed a bug that prevented replying to review comments +- Fixed app version check feature +- Fixed incorrect IDs (e.g., patch revision, comments) being copied to clipboard +- Improved accessibility and keyboard navigation in repository filters on the home view + +**Shout-out to our contributors 🙏✨** + +- [geigerzaehler](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6Mki9XNNHeVRnYS88U59iCBzKUp2xWM3f4zvA3cXuKJFvWF) + + +### v0.7.1 + +- Fixed a bug in patch and notification lists caused by a library incompatibility + + +### v0.7.0 + +- Source code can now be browsed directly within the app +- Draft reviews and their comments can now be deleted +- Issue state changes no longer affect the issue list in the sidebar +- Repo cards now support keyboard navigation for easier selection + +**Shout-out to our contributors 🙏✨** + +- [geigerzaehler](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6Mki9XNNHeVRnYS88U59iCBzKUp2xWM3f4zvA3cXuKJFvWF) + + +### v0.6.1 + +- Fixed a bug where the app wouldn't onboard new users or locked identities +- Fixed regression where the current version of the app wasn't including the commit it was built from + + +### v0.6.0 + +- UI polish: scrollbars now auto-hide across the app +- Fixed button overflow in issue and patch sidebars +- Draft reviews can now be composed before publishing +- Added opt-in automatic app update notifications +- Reworked patch view for clearer, more efficient code reviews + +**Shout-out to our contributors 🙏✨** + +- [geigerzaehler](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6Mki9XNNHeVRnYS88U59iCBzKUp2xWM3f4zvA3cXuKJFvWF) + + +### v0.5.0 + +- The app is now officially available via Arch packaging +- Fuzzy search now supports repo IDs, descriptions, and issue/patch IDs +- Updated Heartwood crates to support Radicle 1.2.0 +- Bundled Radicle tooling and Node in Nix for smoother setup +- Added a startup spinner if launch takes more than 1 second +- Fullscreen error shown if COB schemas need upgrading +- UI improvements: themed scrollbars that auto-hide and match the color scheme +- Repos can now be seeded by RID directly from the app +- Added warning and one-click recovery when COB cache is out of date, fixing mismatched patch/issue counters +- Fixed regression where single patch view didn’t load additional items on scroll + +**Shout-out to our contributors 🙏✨** + +- [geigerzaehler](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6Mki9XNNHeVRnYS88U59iCBzKUp2xWM3f4zvA3cXuKJFvWF) +- [Kuehle](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7) +- [lorenz](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz) +- [tippfehlr](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MksSTAFPKk7eQju9aGzK64qpfiSnKQ1tJWv1rdzRd5bMHU) + + +### v0.4.1 + +- Fixed a bug in the release pipeline to update the latest artifacts +- Added WSL2 installation instructions to https://radworks.garden + + +### v0.4.0 + +- Notification inbox is now accessible from any view without navigating away +- App icon now shows a badge with the unread notification count and polls for updates +- Added global breadcrumbs for easier navigation +- Added quick copy actions for IDs and links to https://radicle.network +- Syntax highlighting added for diffs in markdown code blocks (` ```diff `) +- Improved dropdown UI — triggers are now visually distinct when active +- Fixed AppImage bug affecting Arch, Fedora, and Red Hat users +- App is now officially available for Nix users via https://radworks.garden + +**Shout-out to our contributors 🙏✨** + +- [fintohaps](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM) +- [geigerzaehler](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6Mki9XNNHeVRnYS88U59iCBzKUp2xWM3f4zvA3cXuKJFvWF) +- [lorenz](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz) +- [tshepang](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MkfPSKW7AgQqXSi8fgEJMduHpm9ABmsPYwPhMeF7PssonK) +- [youthlic](https://radicle.network/nodes/iris.radicle.network/users/did:key:z6MktsSuE4bVYbuTtEPjmhrQWA7dMri7GUg9Qp9o8tRCsmhu) + + +### v0.3.0 + +- New onboarding guide to help users get started faster +- Repo homepage now shows README and project info for quick overview +- UI improvements, including clearer buttons and a new "New Patch" button +- Settings now show version and commit hash for easier support +- Fixed notification count and comment placement bugs + + +### v0.2.0 + +- Added font size controls for enhanced readability and accessibility +- Introduced contextual explainers in key areas to guide users and improve overall user experience +- Enhanced contrast in both dark and light modes to improve legibility and visual clarity + + +### v0.1.0 + +- First public release diff --git a/desktop/CLAUDE.md b/desktop/CLAUDE.md new file mode 100644 index 000000000..db38d1a1b --- /dev/null +++ b/desktop/CLAUDE.md @@ -0,0 +1,195 @@ +# radicle-desktop + +Tauri desktop app for Radicle. Svelte 5 frontend communicates with a Rust +backend via Tauri IPC commands. TypeScript types are generated from Rust +structs using ts-rs (`crates/radicle-types/`). + +## Tech stack + +- Svelte 5 with runes syntax (`$state`, `$derived`, `$props`, `$effect`) +- TypeScript, Vite, Vitest, Playwright +- Path aliases: `@app` (src), `@bindings` (crates/radicle-types/bindings), `@tests` + +## Commands + +### Development + +```sh +npm run tauri dev # full Tauri dev build with hot reload +npm run start:http # test-http-api backend on :8081 (no Tauri runtime) +npm start # Vite dev server; in a browser it talks to test-http-api +``` + +Set `VITE_LOG_INVOKE_TIMINGS=true` to log each command's duration +(`[invoke] ms`) to the browser console. It is off by default. + +### Checks and linting + +```sh +npm run check # svelte-check, eslint, prettier, then scripts/check-rs +npm run format # auto-fix prettier issues +``` + +### Unit tests + +```sh +npm run test:unit +``` + +DOMPurify does not sanitize under happy-dom (the unit-test environment): +` + + + + Radicle + + + + + + + + + + + + + + + + +
+ + + + + + + + + +
+ + + diff --git a/desktop/isolation/index.html b/desktop/isolation/index.html new file mode 100644 index 000000000..06ef1742c --- /dev/null +++ b/desktop/isolation/index.html @@ -0,0 +1,11 @@ + + + + + Isolation Secure Script + + + + + + diff --git a/desktop/isolation/index.js b/desktop/isolation/index.js new file mode 100644 index 000000000..56d93860f --- /dev/null +++ b/desktop/isolation/index.js @@ -0,0 +1,3 @@ +window.__TAURI_ISOLATION_HOOK__ = payload => { + return payload; +}; diff --git a/desktop/nix/radicle-desktop.nix b/desktop/nix/radicle-desktop.nix new file mode 100644 index 000000000..6e53fe4fc --- /dev/null +++ b/desktop/nix/radicle-desktop.nix @@ -0,0 +1,118 @@ +{ + lib, + importNpmLock, + rust-bin, + makeRustPlatform, + cargo-tauri, + nodejs, + pkg-config, + wrapGAppsHook4, + glib, + gtk3, + libsoup_3, + openssl, + webkitgtk_4_1, + git, + openssh, + system, + playwright-driver, + heartwood, + twemoji-assets, + radicle-node, + GIT_HEAD ? null, +}: +let + rTc = rust-bin.fromRustupToolchainFile ./../rust-toolchain.toml; + rustPlatform = makeRustPlatform { + cargo = rTc; + rustc = rTc; + }; +in +rustPlatform.buildRustPackage rec { + pname = "radicle-desktop"; + inherit (with builtins; (fromJSON (readFile ./../package.json))) version; + + src = ./..; + + cargoDeps = rustPlatform.importCargoLock { + lockFile = ./../Cargo.lock; + outputHashes = { + "radicle-job-0.6.0" = "sha256-sdi3kksARVTFenqQq4dsgg499zwYk4V7tN4shg07aX8="; + }; + }; + + npmDeps = importNpmLock { + inherit version; + pname = pname + "-npm-deps"; + npmRoot = ./..; + }; + + nativeBuildInputs = [ + cargo-tauri.hook + nodejs + importNpmLock.npmConfigHook + pkg-config + wrapGAppsHook4 + ]; + + buildInputs = [ + glib + gtk3 + libsoup_3 + openssl + webkitgtk_4_1 + ]; + + propagatedBuildInputs = [ + radicle-node + ]; + + postPatch = '' + patchShebangs scripts/copy-katex-assets scripts/check-js scripts/check-rs + mkdir -p public/twemoji + cp -t public/twemoji -r -- ${twemoji-assets}/assets/svg/* + : >scripts/install-twemoji-assets + ''; + + doCheck = false; + nativeCheckInputs = [ + git + openssh + ]; + + env = + { + HW_RELEASE = "nix-" + (heartwood.shortRev or "unknown-ref"); + PLAYWRIGHT_BROWSERS_PATH = playwright-driver.browsers; + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = 1; + PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = true; + RUST_SRC_PATH = "${rTc}/lib/rustlib/src/rust/library"; + } + // (lib.optionalAttrs (GIT_HEAD != null) { + inherit GIT_HEAD; + }); + + preCheck = '' + export RAD_HOME="$PWD/_rad-home" + export RAD_PASSPHRASE="" + rad auth --alias test + bins="tests/tmp/bin/heartwood/$HW_RELEASE" + mkdir -p "$bins" + cp -t "$bins" -- ${heartwood.packages.${system}.radicle}/bin/* + printf "$HW_RELEASE" >tests/support/heartwood-release + ''; + + checkPhase = '' + npm run build:http + npm run test:unit + scripts/check-js + scripts/check-rs + ''; + + passthru.env = env; + meta = { + description = "Radicle Desktop App"; + license = lib.licenses.gpl3; + maintainers = [ ]; + }; +} diff --git a/desktop/package-lock.json b/desktop/package-lock.json new file mode 100644 index 000000000..c450b4cb9 --- /dev/null +++ b/desktop/package-lock.json @@ -0,0 +1,6833 @@ +{ + "name": "radicle-desktop", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "radicle-desktop", + "version": "0.0.0", + "hasInstallScript": true, + "license": "GPL-3.0-only", + "dependencies": { + "@asciidoctor/core": "^4.0.11", + "@floating-ui/dom": "^1.8.0", + "@pierre/diffs": "1.3.0-beta.9", + "@pierre/trees": "1.0.0-beta.5", + "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-clipboard-manager": "^2.3.2", + "@tauri-apps/plugin-deep-link": "^2.4.10", + "@tauri-apps/plugin-dialog": "^2.7.2", + "@tauri-apps/plugin-log": "^2.9.0", + "@tauri-apps/plugin-shell": "^2.3.5", + "@tauri-apps/plugin-window-state": "^2.4.1", + "hast-util-to-html": "^9.0.5", + "overlayscrollbars": "^2.16.0", + "overlayscrollbars-svelte": "^0.5.5", + "semver": "^7.8.5", + "virtua": "^0.49.3", + "zod": "^4.4.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "1.61.1", + "@sveltejs/vite-plugin-svelte": "^7.2.0", + "@tauri-apps/cli": "^2.11.4", + "@tsconfig/svelte": "^5.0.8", + "@types/hast": "^3.0.5", + "@types/js-yaml": "^4.0.9", + "@types/lodash": "^4.17.24", + "@types/md5": "^2.3.6", + "@types/node": "^24", + "@types/semver": "^7.7.1", + "@types/wait-on": "^5.3.4", + "@wooorm/starry-night": "^3.10.0", + "baconjs": "^3.0.23", + "bs58": "^6.0.0", + "buffer": "^6.0.3", + "chalk": "^5.6.2", + "dompurify": "^3.4.12", + "eslint": "^10.7.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-simple-import-sort": "^14.0.0", + "eslint-plugin-svelte": "^3.22.0", + "execa": "^10.0.0", + "fuzzysort": "^3.1.0", + "get-port": "^7.2.0", + "globals": "^17.7.0", + "happy-dom": "^20.11.0", + "hast-util-to-dom": "^4.0.1", + "js-yaml": "^5.2.1", + "katex": "^0.18.9", + "lodash": "^4.18.1", + "lru-cache": "^11.5.2", + "marked": "^18.0.6", + "marked-emoji": "^2.0.3", + "marked-footnote": "^1.4.0", + "marked-katex-extension": "^5.1.10", + "marked-linkify-it": "^4.0.0", + "md5": "^2.3.0", + "mermaid": "^11.16.0", + "playwright": "1.61.1", + "prettier": "^3.9.5", + "prettier-plugin-svelte": "^4.1.1", + "svelte": "^5.56.6", + "svelte-check": "^4.7.3", + "svelte-eslint-parser": "^1.8.0", + "tslib": "^2.8.1", + "twemoji": "^14.0.2", + "typescript": "^6.0.3", + "typescript-eslint": "^8.64.0", + "vite": "^8.1.5", + "vitest": "^4.1.10", + "vscode-oniguruma": "^2.0.1", + "wait-on": "^9.0.10" + }, + "engines": { + "node": ">=24", + "npm": ">=11.16" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-2.1.0.tgz", + "integrity": "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.8.0", + "tinyexec": "^1.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@asciidoctor/core": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@asciidoctor/core/-/core-4.0.11.tgz", + "integrity": "sha512-5uWPTLTiPJVQp9q4MllSROM2avdECzoT7oeVw/onT9H2PIdCCtQwKrSTvztwxMbSu/8cqHqZox/2qTBrQN454g==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz", + "integrity": "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^2.0.1", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mermaid-js/parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", + "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pierre/diffs": { + "version": "1.3.0-beta.9", + "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.3.0-beta.9.tgz", + "integrity": "sha512-XoSLsyHA9UjNb/5UGQoKN1Bqma+ecHHaaM2T2edYdeJ67iS27qivWNoKq+uWjKhRzZEB4JQQRzlXxKy79UvY9A==", + "license": "apache-2.0", + "dependencies": { + "@pierre/theme": "1.1.0", + "@pierre/theming": "0.0.2", + "@shikijs/transformers": "^3.0.0 || ^4.0.0", + "diff": "9.0.0", + "hast-util-to-html": "9.0.5", + "lru_map": "0.4.1", + "shiki": "^3.0.0 || ^4.0.0" + }, + "peerDependencies": { + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0" + } + }, + "node_modules/@pierre/theme": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pierre/theme/-/theme-1.1.0.tgz", + "integrity": "sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==", + "license": "MIT", + "engines": { + "vscode": "^1.0.0" + } + }, + "node_modules/@pierre/theming": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@pierre/theming/-/theming-0.0.2.tgz", + "integrity": "sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==", + "license": "apache-2.0", + "peerDependencies": { + "@pierre/theme": "^1.1.0", + "@shikijs/themes": "^3.0.0 || ^4.0.0", + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0", + "shiki": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@pierre/theme": { + "optional": true + }, + "@shikijs/themes": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "shiki": { + "optional": true + } + } + }, + "node_modules/@pierre/trees": { + "version": "1.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@pierre/trees/-/trees-1.0.0-beta.5.tgz", + "integrity": "sha512-IzxkB9qv6GLbeEXObhlAD205LfYHiLeRwJdnaIdX0f5keTZF4X9EfiuEQ3QiyxOxouVVmUX3rX7m6a8zNMo/wA==", + "license": "apache-2.0", + "dependencies": { + "@pierre/theming": "0.0.2", + "preact": "11.0.0-beta.0", + "preact-render-to-string": "6.6.5" + }, + "peerDependencies": { + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@shikijs/core": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.3.0.tgz", + "integrity": "sha512-EooU3i9F6IAE8kEu+AnGf9DFZWkQBZ+hJn3tLVbsH+61mtQiva5biai66fAA6nvFPXkLgvrh7BrR7YcJU83xQQ==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.3.0", + "@shikijs/types": "4.3.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.3.0.tgz", + "integrity": "sha512-hTv/KiFf2tpiqlACPiztGGurEARWIutB8YUhcrA1pUC7VzzwKO+g5crUocrLztrZ5ro5Z4hbXg7bYclETn3gSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.3.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.3.0.tgz", + "integrity": "sha512-1vMdN3gHfnKfLYwecUI2ITJI4RhHt96xEaJumVn7Heb0IlJ8WQMIH0Voak+2j22BpSNKdnOfB/pCTPnPm2gq7A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.3.0", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.3.0.tgz", + "integrity": "sha512-rnlqFbBRSys9bT4gl/5rw9RnS0W/I84ZldXPkO7cvlEMoV85TyF/aU01N7/NbSR776RNLjrJKjfFUXJR6wN1Cg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.3.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.3.0.tgz", + "integrity": "sha512-CPkz64PTa5diRW1ggzMZH9VM/du4RNChYgVtgqrFcgruvIybmCvySv8GkiHSczUHXYuuR8TdKEwFx+UnZMpgdg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.3.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.3.0.tgz", + "integrity": "sha512-Avgt05YiT+Y3prjIc9lmQxhJzHBcCfR6cjiFW4OyaMBbt2A6trX5rfjUzx+Vj/mE9qpArYjatnqo9XPjQNW/AQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.3.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/transformers": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-4.3.0.tgz", + "integrity": "sha512-5/elhJEcUrxdyQ95SSx0HzrnbzVPuipk6TYiXZL67tHhw8J+4N5shzmrTYyCaudiSnpuuwPdpaVtciwRFwSA7A==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.3.0", + "@shikijs/types": "4.3.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.3.0.tgz", + "integrity": "sha512-oc8b9U2SYvofKZk8e/737nIX0qwf6eV2vHFATeObAu7r+mUVpLs8Re0BmVkIjAWAYgkmG/CzLNo7rzuBzRu/wQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz", + "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-clipboard-manager": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.2.tgz", + "integrity": "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-deep-link": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.10.tgz", + "integrity": "sha512-SuXI47ORUzYuwG6c8dluLDrStTvli5bAZDRa2+VuUEzK/9TEa7FZz7UKXBQriL7+CPFXRHQsz5tSocc47tRXQQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", + "integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-log": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.9.0.tgz", + "integrity": "sha512-Ql8okrnsguk0eDq1GvRfttFV5KaeW/7vcao6bdbkXCRJ1+2sWE15ZJvJVEKVANrOKy1mRngqC3IFIAP+wP5qSw==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-shell": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", + "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, + "node_modules/@tauri-apps/plugin-window-state": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-window-state/-/plugin-window-state-2.4.1.tgz", + "integrity": "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz", + "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/md5": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.6.tgz", + "integrity": "sha512-WD69gNXtRBnpknfZcb4TRQ0XJQbUPZcai/Qdhmka3sxUR3Et8NrXoeAoknG/LghYHTf4ve795rInVYHBTQdNVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/wait-on": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@types/wait-on/-/wait-on-5.3.4.tgz", + "integrity": "sha512-EBsPjFMrFlMbbUFf9D1Fp+PAB2TwmUn7a3YtHyD9RLuTIk1jDd8SxXVAoez2Ciy+8Jsceo2MYEYZzJ/DvorOKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", + "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/type-utils": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.64.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", + "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", + "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.64.0", + "@typescript-eslint/types": "^8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz", + "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz", + "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz", + "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", + "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz", + "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.64.0", + "@typescript-eslint/tsconfig-utils": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", + "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz", + "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@wooorm/starry-night": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@wooorm/starry-night/-/starry-night-3.10.0.tgz", + "integrity": "sha512-JRp2va6hfbC14fXgYQTmwdl4j1+4ZID9KKkCD+Mn/+B/j1QMlzZYPUtXOFNxA+CWrEo+IPha6EMVbsap2OFZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "import-meta-resolve": "^4.0.0", + "vscode-oniguruma": "^2.0.0", + "vscode-textmate": "^9.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/baconjs": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/baconjs/-/baconjs-3.0.23.tgz", + "integrity": "sha512-Yzm9KTwEw9DhXuE47We0kwhBOkMa5BH2vrOQ8A/SGr8uAH3lDCbTSr0iU0G5irkRQDW/v+jDSMP77geV99U9dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "dev": true, + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cytoscape": { + "version": "3.34.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz", + "integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "dev": true, + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dev": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "dev": true, + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dompurify": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "dev": true, + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", + "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-simple-import-sort": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-14.0.0.tgz", + "integrity": "sha512-NUJO0+XFCkk+o5EsAJruTgnfMEpeWrPWeJS15UVF60GgXmqz1BJ9/3hzlvG7lkL8Bubzos5cCLptThbFfPnSMQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=5.0.0" + } + }, + "node_modules/eslint-plugin-svelte": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.22.0.tgz", + "integrity": "sha512-O3qn0NePTWta+1o25dIThqeEP/hEQ3VxDK2LVO8SQ5wG9umLMvulK+m1yQ4JGOb2Pkl8IB0G1lpRV/HXDXSLTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.6.1", + "@jridgewell/sourcemap-codec": "^1.5.0", + "esutils": "^2.0.3", + "globals": "^16.0.0", + "known-css-properties": "^0.37.0", + "postcss": "^8.4.49", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^7.0.0", + "semver": "^7.6.3", + "svelte-eslint-parser": "^1.7.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-svelte/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-10.0.0.tgz", + "integrity": "sha512-Cxl6MKxB1dr1H0FHmiizJ+lavKF7pV+fcDZFyqMB8d5m7qUPm/OtZYcD5vPWePKxSnTQ57KuBd9mtdZ3oNCvyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "figures": "^6.1.0", + "get-stream": "^9.0.1", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "path-key": "^4.0.0", + "pretty-ms": "^9.3.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "which-command": "^0.1.0", + "yoctocolors": "^2.1.2" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastdom": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz", + "integrity": "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "strictdom": "^1.0.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-extra/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fuzzysort": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", + "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-port": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", + "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "dev": true, + "license": "MIT" + }, + "node_modules/happy-dom": { + "version": "20.11.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.0.tgz", + "integrity": "sha512-XogN4asPd1a56di9prVG6bZxteNcXsZxxKmAvcEfc5Px5Ca2hMyMgk8wvqK2K1V8zXg40j9VANXsDaJYh9DeNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-dom": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-dom/-/hast-util-to-dom-4.0.1.tgz", + "integrity": "sha512-z1VE7sZ8uFzS2baF3LEflX1IPw2gSzrdo3QFEsyoi23MkCVY3FoE9x6nLgOgjwJu8VNWgo+07iaxtONhDzKrUQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "property-information": "^7.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/joi": { + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.9.tgz", + "integrity": "sha512-2mD929bUVKUhOLQQEVhlf6EZ0Mlo0DeRb5MO7cViR9AXLtBauuccEtB1py9Ocxpo/P7ucnh442iY/iOwrh3IQw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/js-yaml": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz", + "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz", + "integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^0.1.2" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/katex": { + "version": "0.18.9", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.9.tgz", + "integrity": "sha512-8ad9RyoKsb/g8/yLFE+KAlP+DhbCTRUNi/V9XGsxn0R+trJJltNwzcDNo0q/DEkOy5fQUQTAQyCXCYSE+OakTQ==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^15.0.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==", + "dev": true + }, + "node_modules/known-css-properties": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/linkify-it": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.0.0.tgz", + "integrity": "sha512-YXaVuD1L9iL52IsWy5WsfHbzJjjDL5VzbW7ARliFbB+oHxHXb+fh2qjYl34PGhyy06x3LCvuAuvWdaSFte0P1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru_map": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz", + "integrity": "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "18.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.6.tgz", + "integrity": "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/marked-emoji": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/marked-emoji/-/marked-emoji-2.0.3.tgz", + "integrity": "sha512-fChW/AfUqCHgoEC1nFDgiw3OR/qsi71/QXH/HTo05yd6B5+T+VHh1SqCpn/HpeGLDxkA+MK4+hr4eULB2/A8Jw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "marked": ">=4 <19" + } + }, + "node_modules/marked-footnote": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/marked-footnote/-/marked-footnote-1.4.0.tgz", + "integrity": "sha512-fZTxAhI1TcLEs5UOjCfYfTHpyKGaWQevbxaGTEA68B51l7i87SctPFtHETYqPkEN0ka5opvy4Dy1l/yXVC+hmg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "marked": ">=7.0.0" + } + }, + "node_modules/marked-katex-extension": { + "version": "5.1.13", + "resolved": "https://registry.npmjs.org/marked-katex-extension/-/marked-katex-extension-5.1.13.tgz", + "integrity": "sha512-+6gVygGg5at9g+YsuIQZk7XcAc6Pg1P0grEJKEWvF7DAwceZ7ZIvU0fhQe6auUqSbnPmSjJxZuICdSCdbGaFRw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "katex": ">=0.16 <0.19", + "marked": ">=4 <19" + } + }, + "node_modules/marked-linkify-it": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/marked-linkify-it/-/marked-linkify-it-4.0.0.tgz", + "integrity": "sha512-878NIJlZdg2Vs+dlo19tL6Ne09lyhEPjsEX40OMVK9QE3Y+HzqZgg3H2QPFexlml6UZVGJTNbssD0jg+BzXcsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "linkify-it": "^6.0.0" + }, + "peerDependencies": { + "marked": ">=4 <19" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mermaid": { + "version": "11.17.2", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.17.2.tgz", + "integrity": "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.34.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.21", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "fastdom": "1.0.12", + "katex": "^0.16.47", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/overlayscrollbars": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.16.0.tgz", + "integrity": "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==", + "license": "MIT" + }, + "node_modules/overlayscrollbars-svelte": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/overlayscrollbars-svelte/-/overlayscrollbars-svelte-0.5.5.tgz", + "integrity": "sha512-+dRW3YZSvFbKi5vDCpnUOHuoPLLSdu0BUVVMYZdmfVghu7XkafDRebG2y91/ImPqj6YDAUsz1rcWVYhCJSS/pQ==", + "license": "MIT", + "peerDependencies": { + "overlayscrollbars": "^2.0.0", + "svelte": "^5.0.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "dev": true, + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/preact": { + "version": "11.0.0-beta.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-11.0.0-beta.0.tgz", + "integrity": "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.6.5.tgz", + "integrity": "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10 || >= 11.0.0-0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-4.1.1.tgz", + "integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^5.0.0" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shiki": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.3.0.tgz", + "integrity": "sha512-NKKjWzR6LIGL3sXBrWDw9sDS9cxx42/DkysaNqJEeOWE8Kix5gpak0bc00OfDVEO4oyXSyz8+aRaqKoBD1yo7A==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.3.0", + "@shikijs/engine-javascript": "4.3.0", + "@shikijs/engine-oniguruma": "4.3.0", + "@shikijs/langs": "4.3.0", + "@shikijs/themes": "4.3.0", + "@shikijs/types": "4.3.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strictdom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz", + "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/svelte": { + "version": "5.56.6", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.6.tgz", + "integrity": "sha512-p4HDLDogGHKRKCrgckQHNs5PEfXkju6JI5jTywueaKJI5hAdjPohEhRtQ0M1SWC/+TA73SPln+r7srr+7e4nZA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.3.tgz", + "integrity": "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-eslint-parser": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.0.tgz", + "integrity": "sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.0", + "postcss": "^8.4.49", + "postcss-scss": "^4.0.9", + "postcss-selector-parser": "^7.0.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0", + "pnpm": "10.34.1" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/twemoji": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz", + "integrity": "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fs-extra": "^8.0.1", + "jsonfile": "^5.0.0", + "twemoji-parser": "14.0.0", + "universalify": "^0.1.2" + } + }, + "node_modules/twemoji-parser": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz", + "integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz", + "integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.64.0", + "@typescript-eslint/parser": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/virtua": { + "version": "0.49.3", + "resolved": "https://registry.npmjs.org/virtua/-/virtua-0.49.3.tgz", + "integrity": "sha512-k1Yn988Vz/L40uDtEWPjfdVo15Suumh4tU4/z5Srs0elNcU9DgBskqdh3llpHyAlXrCeHWTfcclYvY1uU4MmIg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0", + "solid-js": ">=1.0", + "svelte": ">=5.0", + "vue": ">=3.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vscode-oniguruma": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz", + "integrity": "sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-textmate": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.2.tgz", + "integrity": "sha512-n2uGbUcrjhUEBH16uGA0TvUfhWwliFZ1e3+pTjrkim1Mt7ydB41lV08aUvsi70OlzDWp6X7Bx3w/x3fAXIsN0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wait-on": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz", + "integrity": "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.16.0", + "joi": "^18.2.1", + "lodash": "^4.18.1", + "minimist": "^1.2.8", + "rxjs": "^7.8.2" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-command": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/which-command/-/which-command-0.1.0.tgz", + "integrity": "sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==", + "dev": true, + "license": "MIT", + "bin": { + "which-command": "cli.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/which-command?sponsor=1" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 000000000..6b303efc6 --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,112 @@ +{ + "name": "radicle-desktop", + "private": true, + "version": "0.0.0", + "description": "", + "type": "module", + "scripts": { + "start": "vite", + "dev": "npx tauri dev -- --profile dev-without-assertions --features devtools", + "start:http": "cargo run --manifest-path ./crates/test-http-api/Cargo.toml", + "build": "vite build && scripts/copy-katex-assets && scripts/install-twemoji-assets", + "build:http": "cargo build --manifest-path ./crates/test-http-api/Cargo.toml", + "postinstall": "scripts/copy-katex-assets && scripts/install-twemoji-assets", + "preview": "vite preview", + "check": "scripts/check-js && scripts/check-rs", + "check-js": "scripts/check-js", + "check-rs": "scripts/check-rs", + "check:ci": "scripts/check-ci", + "test:unit": "TZ='UTC' vitest run", + "test:e2e": "TZ='UTC' playwright test", + "format": "npx prettier . --write && npx eslint --fix .", + "generate-types": "cargo test --manifest-path ./crates/radicle-types/Cargo.toml", + "tauri": "npx tauri" + }, + "engines": { + "node": ">=24", + "npm": ">=11.16" + }, + "license": "GPL-3.0-only", + "dependencies": { + "@asciidoctor/core": "^4.0.11", + "@floating-ui/dom": "^1.8.0", + "@pierre/diffs": "1.3.0-beta.9", + "@pierre/trees": "1.0.0-beta.5", + "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-clipboard-manager": "^2.3.2", + "@tauri-apps/plugin-deep-link": "^2.4.10", + "@tauri-apps/plugin-dialog": "^2.7.2", + "@tauri-apps/plugin-log": "^2.9.0", + "@tauri-apps/plugin-shell": "^2.3.5", + "@tauri-apps/plugin-window-state": "^2.4.1", + "hast-util-to-html": "^9.0.5", + "overlayscrollbars": "^2.16.0", + "overlayscrollbars-svelte": "^0.5.5", + "semver": "^7.8.5", + "virtua": "^0.49.3", + "zod": "^4.4.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "1.61.1", + "@sveltejs/vite-plugin-svelte": "^7.2.0", + "@tauri-apps/cli": "^2.11.4", + "@tsconfig/svelte": "^5.0.8", + "@types/hast": "^3.0.5", + "@types/js-yaml": "^4.0.9", + "@types/lodash": "^4.17.24", + "@types/md5": "^2.3.6", + "@types/node": "^24", + "@types/semver": "^7.7.1", + "@types/wait-on": "^5.3.4", + "@wooorm/starry-night": "^3.10.0", + "baconjs": "^3.0.23", + "bs58": "^6.0.0", + "buffer": "^6.0.3", + "chalk": "^5.6.2", + "dompurify": "^3.4.12", + "eslint": "^10.7.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-simple-import-sort": "^14.0.0", + "eslint-plugin-svelte": "^3.22.0", + "execa": "^10.0.0", + "fuzzysort": "^3.1.0", + "get-port": "^7.2.0", + "globals": "^17.7.0", + "happy-dom": "^20.11.0", + "hast-util-to-dom": "^4.0.1", + "js-yaml": "^5.2.1", + "katex": "^0.18.9", + "lodash": "^4.18.1", + "lru-cache": "^11.5.2", + "marked": "^18.0.6", + "marked-emoji": "^2.0.3", + "marked-footnote": "^1.4.0", + "marked-katex-extension": "^5.1.10", + "marked-linkify-it": "^4.0.0", + "md5": "^2.3.0", + "mermaid": "^11.16.0", + "playwright": "1.61.1", + "prettier": "^3.9.5", + "prettier-plugin-svelte": "^4.1.1", + "svelte": "^5.56.6", + "svelte-check": "^4.7.3", + "svelte-eslint-parser": "^1.8.0", + "tslib": "^2.8.1", + "twemoji": "^14.0.2", + "typescript": "^6.0.3", + "typescript-eslint": "^8.64.0", + "vite": "^8.1.5", + "vitest": "^4.1.10", + "vscode-oniguruma": "^2.0.1", + "wait-on": "^9.0.10" + }, + "overrides": { + "mermaid": { + "katex": "$katex" + } + }, + "allowScripts": { + "fsevents": false + } +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts new file mode 100644 index 000000000..ddf2fb602 --- /dev/null +++ b/desktop/playwright.config.ts @@ -0,0 +1,74 @@ +import type { PlaywrightTestConfig } from "@playwright/test"; + +import * as net from "node:net"; + +import { devices } from "@playwright/test"; + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as net.AddressInfo).port; + server.close(err => (err ? reject(err) : resolve(port))); + }); + server.on("error", reject); + }); +} + +const desktopSafari = devices["Desktop Safari"]; +const userAgent = + process.platform === "darwin" + ? desktopSafari.userAgent + : desktopSafari.userAgent.replace(/\([^)]*\)/, "(X11; Linux x86_64)"); + +const testHttpApiPort = await getFreePort(); +process.env.VITE_TEST_HTTP_API_PORT = String(testHttpApiPort); + +// A separate build directory keeps test settings out of the app bundle. +const testBuildDir = "tests/tmp/build"; +const appServerCommand = process.env.E2E_DEV_SERVER + ? "npm run start -- --strictPort --port 3001" + : `npx vite build --logLevel error --outDir ${testBuildDir} --emptyOutDir && npx vite preview --outDir ${testBuildDir} --strictPort --port 3001`; + +const config: PlaywrightTestConfig = { + outputDir: "./tests/artifacts", + testDir: "./tests/e2e", + globalSetup: "./tests/support/globalSetup.ts", + timeout: 30_000, + expect: { + timeout: 8000, + }, + fullyParallel: true, + workers: process.env.CI ? 2 : undefined, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: [ + ["list"], + ["json", { outputFile: "tests/artifacts/results.json" }], + ], + use: { + colorScheme: "dark", + actionTimeout: 5000, + baseURL: "http://localhost:3001", + trace: "retain-on-failure", + }, + + projects: [ + { + name: "webkit", + use: { + ...desktopSafari, + userAgent, + }, + }, + ], + + webServer: [ + { + command: `VITE_AUTH_LONG_DELAY=1000 VITE_TEST_HTTP_API_PORT=${testHttpApiPort} ${appServerCommand}`, + port: 3001, + }, + ], +}; + +export default config; diff --git a/desktop/public/colors.css b/desktop/public/colors.css new file mode 100644 index 000000000..18a9ea6c8 --- /dev/null +++ b/desktop/public/colors.css @@ -0,0 +1,328 @@ +/* Light theme. */ +:root { + --color-surface-base: var(--color-neutrals-opaque-light-50); + --color-surface-canvas: var(--color-neutrals-opaque-light-0); + --color-surface-subtle: var(--color-neutrals-opaque-light-100); + --color-surface-mid: var(--color-neutrals-opaque-light-150); + --color-surface-strong: var(--color-neutrals-opaque-light-200); + --color-surface-alpha-subtle: var(--color-neutrals-alpha-light-50); + --color-surface-alpha-mid: var(--color-neutrals-alpha-light-100); + --color-surface-alpha-strong: var(--color-neutrals-alpha-light-200); + --color-surface-scrim: var(--color-neutrals-alpha-light-500); + --color-surface-open: var(--color-accent-green-200); + --color-surface-merged: var(--color-accent-blue-200); + --color-surface-draft: var(--color-neutrals-opaque-light-150); + --color-surface-closed: var(--color-accent-blue-200); + --color-surface-archived: var(--color-accent-pink-200); + + --color-text-primary: var(--color-neutrals-opaque-light-900); + --color-text-secondary: var(--color-neutrals-opaque-light-700); + --color-text-tertiary: var(--color-neutrals-opaque-light-600); + --color-text-quaternary: var(--color-neutrals-opaque-light-500); + --color-text-disabled: var(--color-neutrals-alpha-light-400); + --color-text-open: var(--color-accent-green-800); + --color-text-merged: var(--color-accent-blue-800); + --color-text-draft: var(--color-neutrals-opaque-light-600); + --color-text-closed: var(--color-accent-blue-800); + --color-text-archived: var(--color-accent-pink-800); + + --color-border-subtle: var(--color-neutrals-opaque-light-150); + --color-border-mid: var(--color-neutrals-opaque-light-200); + --color-border-strong: var(--color-neutrals-opaque-light-300); + --color-border-alpha-subtle: var(--color-neutrals-alpha-light-100); + --color-border-alpha-mid: var(--color-neutrals-alpha-light-200); + + --color-feedback-success-text: var(--color-semantic-green-800); + --color-feedback-success-border: var(--color-semantic-green-600); + --color-feedback-success-bg: var(--color-semantic-green-100); + --color-feedback-success-bg-selected: var(--color-semantic-green-200); + --color-feedback-warning-text: var(--color-semantic-amber-800); + --color-feedback-warning-border: var(--color-semantic-amber-600); + --color-feedback-warning-bg: var(--color-semantic-amber-100); + --color-feedback-error-text: var(--color-semantic-red-800); + --color-feedback-error-border: var(--color-semantic-red-600); + --color-feedback-error-bg: var(--color-semantic-red-100); + --color-feedback-error-bg-selected: var(--color-semantic-red-200); + --color-feedback-info-text: var(--color-semantic-blue-800); + --color-feedback-important-text: var(--color-accent-purple-800); + + --color-diff-addition: var(--color-semantic-green-500); + --color-diff-deletion: var(--color-semantic-red-500); + + --color-feedback-success-fill: var(--color-semantic-green-600); + --color-feedback-success-fill-hover: var(--color-semantic-green-500); + --color-feedback-success-fill-active: var(--color-semantic-green-700); + --color-feedback-error-fill: var(--color-semantic-red-600); + --color-feedback-error-fill-hover: var(--color-semantic-red-500); + --color-feedback-error-fill-active: var(--color-semantic-red-700); + + --color-code-keywords: var(--color-accent-blue-700); + --color-code-strings: var(--color-accent-green-700); + --color-code-numbers: var(--color-accent-purple-700); + --color-code-comments: var(--color-neutrals-opaque-light-700); + --color-code-error: var(--color-semantic-red-700); + --color-code-functions: var(--color-accent-emerald-700); + + /* Brand. */ + --color-brand-bg: var(--color-accent-blue-600); + --color-brand-hover: var(--color-accent-blue-500); + --color-brand-text-light: var(--color-accent-blue-600); + --color-brand-text-dark: var(--color-accent-blue-400); + --color-text-on-brand: var(--color-neutrals-opaque-light-0); + + --color-surface-brand-primary: var(--color-brand-bg); + --color-surface-brand-secondary: var(--color-brand-hover); + --color-surface-brand-subtle: var(--color-accent-blue-100); + --color-surface-brand-mid: var(--color-accent-blue-200); + --color-border-brand: var(--color-brand-hover); + --color-text-brand: var(--color-brand-text-light); +} + +/* Dark theme. */ +:root[data-theme="dark"] { + --color-surface-base: var(--color-neutrals-opaque-dark-0); + --color-surface-canvas: var(--color-neutrals-opaque-dark-50); + --color-surface-subtle: var(--color-neutrals-opaque-dark-100); + --color-surface-mid: var(--color-neutrals-opaque-dark-150); + --color-surface-strong: var(--color-neutrals-opaque-dark-200); + --color-surface-alpha-subtle: var(--color-neutrals-alpha-dark-50); + --color-surface-alpha-mid: var(--color-neutrals-alpha-dark-100); + --color-surface-alpha-strong: var(--color-neutrals-alpha-dark-200); + --color-surface-scrim: var(--color-neutrals-alpha-light-700); + --color-surface-open: var(--color-accent-green-900); + --color-surface-draft: var(--color-neutrals-opaque-dark-150); + --color-surface-closed: var(--color-accent-blue-900); + --color-surface-merged: var(--color-accent-blue-900); + --color-surface-archived: var(--color-accent-pink-900); + + --color-text-primary: var(--color-neutrals-opaque-dark-900); + --color-text-secondary: var(--color-neutrals-opaque-dark-700); + --color-text-tertiary: var(--color-neutrals-opaque-dark-600); + --color-text-quaternary: var(--color-neutrals-opaque-dark-500); + --color-text-disabled: var(--color-neutrals-alpha-dark-400); + --color-text-open: var(--color-accent-green-500); + --color-text-draft: var(--color-neutrals-opaque-dark-600); + --color-text-closed: var(--color-accent-blue-500); + --color-text-merged: var(--color-accent-blue-500); + --color-text-archived: var(--color-accent-pink-500); + + --color-border-subtle: var(--color-neutrals-opaque-dark-150); + --color-border-mid: var(--color-neutrals-opaque-dark-200); + --color-border-strong: var(--color-neutrals-opaque-dark-300); + --color-border-alpha-subtle: var(--color-neutrals-alpha-dark-100); + --color-border-alpha-mid: var(--color-neutrals-alpha-dark-200); + + --color-feedback-success-text: var(--color-semantic-green-400); + --color-feedback-success-border: var(--color-semantic-green-700); + --color-feedback-success-bg: var(--color-semantic-green-900); + --color-feedback-success-bg-selected: var(--color-semantic-green-800); + --color-feedback-warning-text: var(--color-semantic-amber-400); + --color-feedback-warning-border: var(--color-semantic-amber-700); + --color-feedback-warning-bg: var(--color-semantic-amber-900); + --color-feedback-error-text: var(--color-semantic-red-400); + --color-feedback-error-border: var(--color-semantic-red-700); + --color-feedback-error-bg: var(--color-semantic-red-900); + --color-feedback-error-bg-selected: var(--color-semantic-red-800); + --color-feedback-info-text: var(--color-semantic-blue-400); + --color-feedback-important-text: var(--color-accent-purple-400); + + --color-diff-addition: var(--color-semantic-green-400); + --color-diff-deletion: var(--color-semantic-red-400); + + --color-code-keywords: var(--color-accent-blue-400); + --color-code-strings: var(--color-accent-green-400); + --color-code-numbers: var(--color-accent-purple-400); + --color-code-comments: var(--color-neutrals-opaque-dark-700); + --color-code-error: var(--color-semantic-red-400); + --color-code-functions: var(--color-accent-emerald-400); + + --color-surface-brand-subtle: var(--color-accent-blue-900); + --color-surface-brand-mid: var(--color-accent-blue-800); + + --color-text-brand: var(--color-brand-text-dark); +} + +/* Internal color system, don't use directly! */ +:root { + --color-neutrals-opaque-light-0: #ffffff; + --color-neutrals-opaque-light-50: #f8f9fa; + --color-neutrals-opaque-light-100: #f1f3f5; + --color-neutrals-opaque-light-150: #e9ebef; + --color-neutrals-opaque-light-200: #e1e4e8; + --color-neutrals-opaque-light-250: #d6d9e0; + --color-neutrals-opaque-light-300: #c9cdd4; + --color-neutrals-opaque-light-400: #a2a7b1; + --color-neutrals-opaque-light-500: #7a8190; + --color-neutrals-opaque-light-600: #5a5f6b; + --color-neutrals-opaque-light-700: #3a3f49; + --color-neutrals-opaque-light-800: #1f232b; + --color-neutrals-opaque-light-900: #0b0d12; + + --color-neutrals-opaque-dark-0: #00060f; + --color-neutrals-opaque-dark-50: #0a1018; + --color-neutrals-opaque-dark-100: #141a22; + --color-neutrals-opaque-dark-150: #1f242c; + --color-neutrals-opaque-dark-200: #29303a; + --color-neutrals-opaque-dark-250: #33383f; + --color-neutrals-opaque-dark-300: #3d4248; + --color-neutrals-opaque-dark-400: #52565c; + --color-neutrals-opaque-dark-500: #666a6f; + --color-neutrals-opaque-dark-600: #8f9296; + --color-neutrals-opaque-dark-700: #b8babc; + --color-neutrals-opaque-dark-800: #e0e1e2; + --color-neutrals-opaque-dark-900: #ffffff; + + --color-neutrals-alpha-light-50: #00060f0a; + --color-neutrals-alpha-light-100: #00060f14; + --color-neutrals-alpha-light-200: #00060f1f; + --color-neutrals-alpha-light-300: #00060f29; + --color-neutrals-alpha-light-400: #00060f3d; + --color-neutrals-alpha-light-500: #00060f52; + --color-neutrals-alpha-light-600: #00060f7a; + --color-neutrals-alpha-light-700: #00060fa3; + --color-neutrals-alpha-light-800: #00060fcc; + --color-neutrals-alpha-light-900: #00060feb; + + --color-neutrals-alpha-dark-50: #ffffff0a; + --color-neutrals-alpha-dark-100: #ffffff14; + --color-neutrals-alpha-dark-200: #ffffff1f; + --color-neutrals-alpha-dark-300: #ffffff29; + --color-neutrals-alpha-dark-400: #ffffff3d; + --color-neutrals-alpha-dark-500: #ffffff52; + --color-neutrals-alpha-dark-600: #ffffff7a; + --color-neutrals-alpha-dark-700: #ffffffa3; + --color-neutrals-alpha-dark-800: #ffffffcc; + --color-neutrals-alpha-dark-900: #ffffffeb; + + --color-accent-blue-0: #f4f9ff; + --color-accent-blue-100: #d6e9ff; + --color-accent-blue-200: #afcfff; + --color-accent-blue-300: #7fb0ff; + --color-accent-blue-400: #4d94ff; + --color-accent-blue-500: #1c77ff; + --color-accent-blue-600: #165fcc; + --color-accent-blue-700: #104799; + --color-accent-blue-800: #0b3266; + --color-accent-blue-900: #061d33; + + --color-accent-green-0: #f7fff2; + --color-accent-green-100: #dffcc6; + --color-accent-green-200: #bef98a; + --color-accent-green-300: #99f24c; + --color-accent-green-400: #73e926; + --color-accent-green-500: #58e600; + --color-accent-green-600: #46ba00; + --color-accent-green-700: #358f00; + --color-accent-green-800: #256400; + --color-accent-green-900: #0a1f00; + + --color-accent-cyan-0: #f1fefe; + --color-accent-cyan-100: #ccf9fa; + --color-accent-cyan-200: #99eff0; + --color-accent-cyan-300: #66e5e7; + --color-accent-cyan-400: #33dbde; + --color-accent-cyan-500: #00d4da; + --color-accent-cyan-600: #00a8ae; + --color-accent-cyan-700: #007d82; + --color-accent-cyan-800: #005256; + --color-accent-cyan-900: #00292b; + + --color-accent-purple-0: #f7f5ff; + --color-accent-purple-100: #e0daff; + --color-accent-purple-200: #c0b5ff; + --color-accent-purple-300: #a08fff; + --color-accent-purple-400: #8a74fa; + --color-accent-purple-500: #886bf2; + --color-accent-purple-600: #6c54c2; + --color-accent-purple-700: #503f91; + --color-accent-purple-800: #352a61; + --color-accent-purple-900: #1b1530; + + --color-accent-pink-0: #fff6ff; + --color-accent-pink-100: #ffedff; + --color-accent-pink-200: #ffdbff; + --color-accent-pink-300: #ffc9ff; + --color-accent-pink-400: #ffb7ff; + --color-accent-pink-500: #ffa5ff; + --color-accent-pink-600: #cc84cc; + --color-accent-pink-700: #996399; + --color-accent-pink-800: #664266; + --color-accent-pink-900: #332133; + + --color-accent-emerald-0: #f2fcf9; + --color-accent-emerald-100: #ccf4e4; + --color-accent-emerald-200: #99e9ca; + --color-accent-emerald-300: #66ddb0; + --color-accent-emerald-400: #33d196; + --color-accent-emerald-500: #009f67; + --color-accent-emerald-600: #007f52; + --color-accent-emerald-700: #005f3e; + --color-accent-emerald-800: #004029; + --color-accent-emerald-900: #002015; + + --color-accent-citrus-0: #fcfff2; + --color-accent-citrus-100: #f1ffbf; + --color-accent-citrus-200: #e4ff80; + --color-accent-citrus-300: #d6ff40; + --color-accent-citrus-400: #caff20; + --color-accent-citrus-500: #ccff38; + --color-accent-citrus-600: #a4cc2d; + --color-accent-citrus-700: #7c991f; + --color-accent-citrus-800: #536613; + --color-accent-citrus-900: #2b3307; + + --color-accent-olive-0: #fafaed; + --color-accent-olive-100: #e6e5ba; + --color-accent-olive-200: #d1cf86; + --color-accent-olive-300: #bdb950; + --color-accent-olive-400: #9e9900; + --color-accent-olive-500: #585600; + --color-accent-olive-600: #464400; + --color-accent-olive-700: #343300; + --color-accent-olive-800: #232200; + --color-accent-olive-900: #111100; + + --color-semantic-red-0: #fff5f5; + --color-semantic-red-100: #ffd6d6; + --color-semantic-red-200: #ffafaf; + --color-semantic-red-300: #ff8686; + --color-semantic-red-400: #ff5c5c; + --color-semantic-red-500: #ff4d4f; + --color-semantic-red-600: #cc3e3f; + --color-semantic-red-700: #992f2f; + --color-semantic-red-800: #661f20; + --color-semantic-red-900: #330f10; + + --color-semantic-amber-0: #fffdf2; + --color-semantic-amber-100: #fff1b8; + --color-semantic-amber-200: #ffe58f; + --color-semantic-amber-300: #ffd666; + --color-semantic-amber-400: #ffc53d; + --color-semantic-amber-500: #faad14; + --color-semantic-amber-600: #d48806; + --color-semantic-amber-700: #ad6800; + --color-semantic-amber-800: #874d00; + --color-semantic-amber-900: #613400; + + --color-semantic-green-0: #f6fff1; + --color-semantic-green-100: #d9f7be; + --color-semantic-green-200: #b7eb8f; + --color-semantic-green-300: #95de64; + --color-semantic-green-400: #73d13d; + --color-semantic-green-500: #52c41a; + --color-semantic-green-600: #3d9914; + --color-semantic-green-700: #2e7010; + --color-semantic-green-800: #1e470a; + --color-semantic-green-900: #0f2305; + + --color-semantic-blue-0: #f0f9ff; + --color-semantic-blue-100: #c6e4ff; + --color-semantic-blue-200: #91caff; + --color-semantic-blue-300: #5bafff; + --color-semantic-blue-400: #2896ff; + --color-semantic-blue-500: #1890ff; + --color-semantic-blue-600: #1373cc; + --color-semantic-blue-700: #0e5799; + --color-semantic-blue-800: #093c66; + --color-semantic-blue-900: #051f33; +} diff --git a/desktop/public/flower.png b/desktop/public/flower.png new file mode 100644 index 000000000..0618beea5 Binary files /dev/null and b/desktop/public/flower.png differ diff --git a/desktop/public/fonts/Booton-Medium.woff2 b/desktop/public/fonts/Booton-Medium.woff2 new file mode 100755 index 000000000..5f5bfcc56 Binary files /dev/null and b/desktop/public/fonts/Booton-Medium.woff2 differ diff --git a/desktop/public/fonts/Booton-Regular.woff2 b/desktop/public/fonts/Booton-Regular.woff2 new file mode 100755 index 000000000..fac416934 Binary files /dev/null and b/desktop/public/fonts/Booton-Regular.woff2 differ diff --git a/desktop/public/fonts/Booton-SemiBold.woff2 b/desktop/public/fonts/Booton-SemiBold.woff2 new file mode 100755 index 000000000..1c8ed8fe6 Binary files /dev/null and b/desktop/public/fonts/Booton-SemiBold.woff2 differ diff --git a/desktop/public/fonts/JetBrainsMono-Bold.woff2 b/desktop/public/fonts/JetBrainsMono-Bold.woff2 new file mode 100644 index 000000000..4917f4341 Binary files /dev/null and b/desktop/public/fonts/JetBrainsMono-Bold.woff2 differ diff --git a/desktop/public/fonts/JetBrainsMono-Medium.woff2 b/desktop/public/fonts/JetBrainsMono-Medium.woff2 new file mode 100644 index 000000000..669d04cdf Binary files /dev/null and b/desktop/public/fonts/JetBrainsMono-Medium.woff2 differ diff --git a/desktop/public/fonts/JetBrainsMono-Regular.woff2 b/desktop/public/fonts/JetBrainsMono-Regular.woff2 new file mode 100644 index 000000000..40da42765 Binary files /dev/null and b/desktop/public/fonts/JetBrainsMono-Regular.woff2 differ diff --git a/desktop/public/fonts/JetBrainsMono-SemiBold.woff2 b/desktop/public/fonts/JetBrainsMono-SemiBold.woff2 new file mode 100644 index 000000000..5ead7b0d6 Binary files /dev/null and b/desktop/public/fonts/JetBrainsMono-SemiBold.woff2 differ diff --git a/desktop/public/index.css b/desktop/public/index.css new file mode 100644 index 000000000..65ab39fd2 --- /dev/null +++ b/desktop/public/index.css @@ -0,0 +1,235 @@ +* { + box-sizing: border-box; +} + +:root { + --border-radius-xs: 0.0625rem; + --border-radius-sm: 0.125rem; + --border-radius-md: 0.25rem; + --border-radius-lg: 0.5rem; + --border-radius-full: 99rem; +} + +html { + height: 100%; + width: 100%; + /* Prevent rubber-band effect when scrolling via touchpad on macOS. */ + overscroll-behavior: none; + cursor: default; + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; +} + +body { + height: 100%; + width: 100%; + margin: 0; + padding: 0; + color: var(--color-text-primary); + background-color: var(--color-surface-base); +} + +a, +a:link, +a:visited, +a:hover, +a:active, +a:focus { + color: inherit; + text-decoration: none; + background: transparent; + cursor: pointer; +} + +a { + display: inline; + font-weight: inherit; + font-style: inherit; + font-size: inherit; + line-height: inherit; + font-family: inherit; +} + +::selection { + background: var(--color-feedback-warning-border); + color: var(--color-text-primary); +} + +.global-flex { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.global-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + border-radius: var(--border-radius-sm); + color: var(--color-text-secondary); + cursor: pointer; +} +.global-icon-button:hover, +.global-icon-button:focus-visible { + background-color: var(--color-surface-subtle); + color: var(--color-text-primary); +} + +.global-tab { + padding-left: 0.5rem; + border-left: 1px solid var(--color-border-mid); + margin-left: 1rem; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + column-gap: 0.5rem; +} + +.global-chip { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-surface-strong); + border-radius: var(--border-radius-sm); + height: 1.5rem; + padding: 0 0.5rem; + min-width: 1.5rem; + font: var(--txt-body-s-regular); + flex-shrink: 0; +} + +.global-counter-badge { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + min-width: 1.25rem; + padding: 0.125rem 0.25rem; + border-radius: 1px; + background-color: var(--color-surface-alpha-subtle); + color: var(--color-text-secondary); + font: var(--txt-body-s-regular); + flex-shrink: 0; +} + +.global-link { + color: var(--color-text-secondary); + text-decoration: none; +} +.global-link:hover { + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 2px; +} + +:root { + .global-os-theme-radicle { + --os-size: 16px; + --os-handle-border-radius: 4px; + --os-padding-perpendicular: 4px; + --os-padding-axis: 2px; + --os-handle-bg: var(--color-border-mid); + --os-handle-bg-hover: var(--color-border-mid); + --os-handle-bg-active: var(--color-border-mid); + --os-track-bg: transparent; + } +} + +:root { + --elevation-low: 0 0 48px 0 #000000ee; +} + +[data-theme="light"] { + --elevation-low: 0 0 16px 0 #00000022; +} + +/* + Breakpoints + =========== + mobile 0px - 719.98px + small desktop 720px - 1010.98px + medium desktop 1011px - 1349.98px + desktop 1350px - ∞ px +*/ + +@media (max-width: 719.98px) { + body { + min-width: 0; + } + .global-hide-on-mobile-down { + display: none !important; + } +} + +@media (max-width: 1010.98px) { + .global-hide-on-small-desktop-down { + display: none !important; + } +} + +@media (max-width: 1349.98px) { + .global-hide-on-medium-desktop-down { + display: none !important; + } +} + +@media (min-width: 720px) { + .global-hide-on-small-desktop-up { + display: none !important; + } +} + +@media (min-width: 1011px) { + .global-hide-on-medium-desktop-up { + display: none !important; + } +} + +@media (min-width: 1350px) { + .global-hide-on-desktop-up { + display: none !important; + } +} + +/* Feeds the app's tokens into `@pierre/diffs`. Its shadow roots read these + custom properties, which inherit across the boundary, so anything hosting a + Pierre diff — `PierreDiff` for a whole revision, `PierreSnippet` for the few + lines a comment is about — carries this class. Instance-specific values + (`--diffs-line-height`, the sticky offsets) stay inline on the element. */ +.global-pierre-surface { + /* Code font and sizing, matching `--txt-code-regular`. The line height is set + on the element itself as a whole-pixel value that mirrors Pierre's + `itemMetrics.lineHeight`, so rendered rows and its model agree. */ + --diffs-font-family: var(--font-family-code); + --diffs-font-size: 0.875rem; + + /* Chrome colours from our design tokens. Syntax token colours still come from + the Shiki theme (github light/dark); these only retint the diff surfaces, + line numbers, and add/delete/modify accents so the frame matches the app + without changing the highlighting look and feel. + + The base *surface* cannot be set here: the Shiki theme writes + `--diffs-light-bg`/`--diffs-dark-bg` directly onto the shadow `:host`, which + beats any value inherited from a light-DOM ancestor. So `--diffs-bg` is + forced to the app's view background in `cardUnsafeCSS` (a higher `@layer`) + instead — every neutral surface and the change-tint mixes derive from it. */ + --diffs-light: var(--color-text-primary); + --diffs-dark: var(--color-text-primary); + /* Line numbers. */ + --diffs-fg-number-override: var(--color-text-tertiary); + --diffs-addition-color-override: var(--color-diff-addition); + --diffs-deletion-color-override: var(--color-diff-deletion); + --diffs-modified-color-override: var(--color-text-brand); + --diffs-fg-number-addition-override: var(--color-feedback-success-text); + --diffs-fg-number-deletion-override: var(--color-feedback-error-text); + /* Mixed into the background of every line a comment refers to. */ + --app-diff-comment-tint: var(--color-surface-brand-secondary); + + /* The app disables selection globally on `html` and opts in per element. + `user-select` inherits across the shadow boundary, so opt the whole Pierre + subtree back in; its own CSS keeps gutters and line numbers unselectable. */ + -webkit-user-select: text; + user-select: text; +} diff --git a/desktop/public/prettylights.css b/desktop/public/prettylights.css new file mode 100644 index 000000000..d445ea7ff --- /dev/null +++ b/desktop/public/prettylights.css @@ -0,0 +1,188 @@ +/* This is a theme distributed by `starry-night`. + * It’s based on what GitHub uses on their site. + * See for more info. */ +:root { + --color-prettylights-syntax-comment: #8b949e; + --color-prettylights-syntax-constant: #79c0ff; + --color-prettylights-syntax-entity: #d2a8ff; + --color-prettylights-syntax-storage-modifier-import: #c9d1d9; + --color-prettylights-syntax-entity-tag: #7ee787; + --color-prettylights-syntax-keyword: #ff7b72; + --color-prettylights-syntax-string: #a5d6ff; + --color-prettylights-syntax-variable: #ffa657; + --color-prettylights-syntax-brackethighlighter-unmatched: #f85149; + --color-prettylights-syntax-invalid-illegal-text: #f0f6fc; + --color-prettylights-syntax-invalid-illegal-bg: #8e1519; + --color-prettylights-syntax-carriage-return-text: #f0f6fc; + --color-prettylights-syntax-carriage-return-bg: #b62324; + --color-prettylights-syntax-string-regexp: #7ee787; + --color-prettylights-syntax-markup-list: #f2cc60; + --color-prettylights-syntax-markup-heading: #1f6feb; + --color-prettylights-syntax-markup-italic: #c9d1d9; + --color-prettylights-syntax-markup-bold: #c9d1d9; + --color-prettylights-syntax-markup-deleted-text: #ffdcd7; + --color-prettylights-syntax-markup-deleted-bg: #67060c; + --color-prettylights-syntax-markup-inserted-text: #aff5b4; + --color-prettylights-syntax-markup-inserted-bg: #033a16; + --color-prettylights-syntax-markup-changed-text: #ffdfb6; + --color-prettylights-syntax-markup-changed-bg: #5a1e02; + --color-prettylights-syntax-markup-ignored-text: #c9d1d9; + --color-prettylights-syntax-markup-ignored-bg: #1158c7; + --color-prettylights-syntax-meta-diff-range: #d2a8ff; + --color-prettylights-syntax-brackethighlighter-angle: #8b949e; + --color-prettylights-syntax-sublimelinter-gutter-mark: #484f58; + --color-prettylights-syntax-constant-other-reference-link: #a5d6ff; +} + +[data-theme="light"] { + --color-prettylights-syntax-comment: #6e7781; + --color-prettylights-syntax-constant: #0550ae; + --color-prettylights-syntax-entity: #8250df; + --color-prettylights-syntax-storage-modifier-import: #24292f; + --color-prettylights-syntax-entity-tag: #116329; + --color-prettylights-syntax-keyword: #cf222e; + --color-prettylights-syntax-string: #0a3069; + --color-prettylights-syntax-variable: #953800; + --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; + --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; + --color-prettylights-syntax-invalid-illegal-bg: #82071e; + --color-prettylights-syntax-carriage-return-text: #f6f8fa; + --color-prettylights-syntax-carriage-return-bg: #cf222e; + --color-prettylights-syntax-string-regexp: #116329; + --color-prettylights-syntax-markup-list: #3b2300; + --color-prettylights-syntax-markup-heading: #0550ae; + --color-prettylights-syntax-markup-italic: #24292f; + --color-prettylights-syntax-markup-bold: #24292f; + --color-prettylights-syntax-markup-deleted-text: #82071e; + --color-prettylights-syntax-markup-deleted-bg: #ffebe9; + --color-prettylights-syntax-markup-inserted-text: #116329; + --color-prettylights-syntax-markup-inserted-bg: #dafbe1; + --color-prettylights-syntax-markup-changed-text: #953800; + --color-prettylights-syntax-markup-changed-bg: #ffd8b5; + --color-prettylights-syntax-markup-ignored-text: #eaeef2; + --color-prettylights-syntax-markup-ignored-bg: #0550ae; + --color-prettylights-syntax-meta-diff-range: #8250df; + --color-prettylights-syntax-brackethighlighter-angle: #57606a; + --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f; + --color-prettylights-syntax-constant-other-reference-link: #0a3069; +} + +.pl-c { + color: var(--color-prettylights-syntax-comment); +} + +.pl-c1, +.pl-s .pl-v { + color: var(--color-prettylights-syntax-constant); +} + +.pl-e, +.pl-en { + color: var(--color-prettylights-syntax-entity); +} + +.pl-smi, +.pl-s .pl-s1 { + color: var(--color-prettylights-syntax-storage-modifier-import); +} + +.pl-ent { + color: var(--color-prettylights-syntax-entity-tag); +} + +.pl-k { + color: var(--color-prettylights-syntax-keyword); +} + +.pl-s, +.pl-pds, +.pl-s .pl-pse .pl-s1, +.pl-sr, +.pl-sr .pl-cce, +.pl-sr .pl-sre, +.pl-sr .pl-sra { + color: var(--color-prettylights-syntax-string); +} + +.pl-v, +.pl-smw { + color: var(--color-prettylights-syntax-variable); +} + +.pl-bu { + color: var(--color-prettylights-syntax-brackethighlighter-unmatched); +} + +.pl-ii { + color: var(--color-prettylights-syntax-invalid-illegal-text); + background-color: var(--color-prettylights-syntax-invalid-illegal-bg); +} + +.pl-c2 { + color: var(--color-prettylights-syntax-carriage-return-text); + background-color: var(--color-prettylights-syntax-carriage-return-bg); +} + +.pl-sr .pl-cce { + font-weight: bold; + color: var(--color-prettylights-syntax-string-regexp); +} + +.pl-ml { + color: var(--color-prettylights-syntax-markup-list); +} + +.pl-mh, +.pl-mh .pl-en, +.pl-ms { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-heading); +} + +.pl-mi { + font-style: italic; + color: var(--color-prettylights-syntax-markup-italic); +} + +.pl-mb { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-bold); +} + +.pl-md { + color: var(--color-prettylights-syntax-markup-deleted-text); + background-color: var(--color-prettylights-syntax-markup-deleted-bg); +} + +.pl-mi1 { + color: var(--color-prettylights-syntax-markup-inserted-text); + background-color: var(--color-prettylights-syntax-markup-inserted-bg); +} + +.pl-mc { + color: var(--color-prettylights-syntax-markup-changed-text); + background-color: var(--color-prettylights-syntax-markup-changed-bg); +} + +.pl-mi2 { + color: var(--color-prettylights-syntax-markup-ignored-text); + background-color: var(--color-prettylights-syntax-markup-ignored-bg); +} + +.pl-mdr { + font-weight: bold; + color: var(--color-prettylights-syntax-meta-diff-range); +} + +.pl-ba { + color: var(--color-prettylights-syntax-brackethighlighter-angle); +} + +.pl-sg { + color: var(--color-prettylights-syntax-sublimelinter-gutter-mark); +} + +.pl-corl { + text-decoration: underline; + color: var(--color-prettylights-syntax-constant-other-reference-link); +} diff --git a/desktop/public/radicle.svg b/desktop/public/radicle.svg new file mode 100644 index 000000000..add0c464d --- /dev/null +++ b/desktop/public/radicle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/desktop/public/twemoji/.gitkeep b/desktop/public/twemoji/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/desktop/public/typography.css b/desktop/public/typography.css new file mode 100644 index 000000000..1d1d172f1 --- /dev/null +++ b/desktop/public/typography.css @@ -0,0 +1,206 @@ +@font-face { + font-family: "Booton"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("fonts/Booton-Regular.woff2"); +} + +@font-face { + font-family: "Booton"; + font-weight: 468; + font-display: swap; + src: url("fonts/Booton-Medium.woff2"); +} + +@font-face { + font-family: "Booton"; + font-weight: 600; + font-display: swap; + src: url("fonts/Booton-SemiBold.woff2"); +} + +@font-face { + font-family: "JetBrains Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("fonts/JetBrainsMono-Regular.woff2"); +} + +@font-face { + font-family: "JetBrains Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("fonts/JetBrainsMono-Medium.woff2"); +} + +@font-face { + font-family: "JetBrains Mono"; + font-weight: 600; + font-display: swap; + src: url("fonts/JetBrainsMono-SemiBold.woff2"); +} + +@font-face { + font-family: "JetBrains Mono"; + font-weight: 700; + font-display: swap; + src: url("fonts/JetBrainsMono-Bold.woff2"); +} + +:root { + --font-size: 16px; + + --txt-heading-xxs: 600 0.75rem/1rem Booton, sans-serif; + --txt-heading-xs: 600 0.875rem/1.25rem Booton, sans-serif; + --txt-heading-s: 600 1rem/1.25rem Booton, sans-serif; + --txt-heading-m: 600 1.125rem/1.25rem Booton, sans-serif; + --txt-heading-l: 600 1.375rem/1.75rem Booton, sans-serif; + --txt-heading-xl: 600 1.75rem/2rem Booton, sans-serif; + --txt-heading-xxl: 600 2rem/2.25rem Booton, sans-serif; + + --txt-body-s-regular: 400 0.75rem/1rem Booton, sans-serif; + --txt-body-s-medium: 468 0.75rem/1rem Booton, sans-serif; + --txt-body-s-semibold: 600 0.75rem/1rem Booton, sans-serif; + + --txt-body-m-regular: 400 0.875rem/1.25rem Booton, sans-serif; + --txt-body-m-medium: 468 0.875rem/1.25rem Booton, sans-serif; + --txt-body-m-semibold: 600 0.875rem/1.25rem Booton, sans-serif; + + --txt-body-l-regular: 400 1rem/1.5rem Booton, sans-serif; + --txt-body-l-medium: 468 1rem/1.5rem Booton, sans-serif; + --txt-body-l-semibold: 600 1rem/1.5rem Booton, sans-serif; +} + +[data-codefont="system"] { + --font-family-code: monospace; + --txt-code-small: 400 0.75rem/1rem monospace; + --txt-code-regular: 400 0.875rem/1.25rem monospace; + --txt-code-semibold: 600 0.875rem/1.25rem monospace; +} + +[data-codefont="jetbrains"] { + --font-family-code: "JetBrains Mono"; + --txt-code-small: 400 0.75rem/1rem "JetBrains Mono"; + --txt-code-regular: 400 0.875rem/1.25rem "JetBrains Mono"; + --txt-code-semibold: 600 0.875rem/1.25rem "JetBrains Mono"; +} + +html { + -ms-text-size-adjust: 100%; + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: 100%; + --font-family-ui: Booton, sans-serif; + font-family: var(--font-family-ui); + /* The root element font size has to be set in px, + * otherwise Safari breaks. */ + font-size: var(--font-size); + font-weight: 400; + line-height: 1.25; +} + +.txt-heading-xxs { + font: var(--txt-heading-xxs); +} + +.txt-heading-xs { + font: var(--txt-heading-xs); +} + +.txt-heading-s { + font: var(--txt-heading-s); +} + +.txt-heading-m { + font: var(--txt-heading-m); +} + +.txt-heading-l { + font: var(--txt-heading-l); +} + +.txt-heading-xl { + font: var(--txt-heading-xl); +} + +.txt-heading-xxl { + font: var(--txt-heading-xxl); +} + +.txt-body-s-regular { + font: var(--txt-body-s-regular); +} + +.txt-body-s-medium { + font: var(--txt-body-s-medium); +} + +.txt-body-s-semibold { + font: var(--txt-body-s-semibold); +} + +.txt-body-m-regular { + font: var(--txt-body-m-regular); +} + +.txt-body-m-medium { + font: var(--txt-body-m-medium); +} + +.txt-body-m-semibold { + font: var(--txt-body-m-semibold); +} + +.txt-body-l-regular { + font: var(--txt-body-l-regular); +} + +.txt-body-l-medium { + font: var(--txt-body-l-medium); +} + +.txt-body-l-semibold { + font: var(--txt-body-l-semibold); +} + +.txt-code-small { + font: var(--txt-code-small); +} + +.txt-code-regular { + font: var(--txt-code-regular); +} + +.txt-code-semibold { + font: var(--txt-code-semibold); +} + +.txt-id { + color: var(--color-text-tertiary); + font: var(--txt-code-regular); +} + +.txt-missing { + color: var(--color-text-secondary); +} + +.txt-selectable { + -webkit-touch-callout: initial; + -webkit-user-select: text; + user-select: text; +} + +.txt-emoji { + height: 1em; + width: 1em; + margin: 0 0.05em 0 0.1em; + vertical-align: -0.1em; +} + +.txt-overflow { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/desktop/rust-toolchain.toml b/desktop/rust-toolchain.toml new file mode 100644 index 000000000..b8b33a0a0 --- /dev/null +++ b/desktop/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.97.1" +profile = "default" +components = [ "rust-src" ] diff --git a/desktop/scripts/appimage-build b/desktop/scripts/appimage-build new file mode 100755 index 000000000..da4ef1f0e --- /dev/null +++ b/desktop/scripts/appimage-build @@ -0,0 +1,92 @@ +#!/bin/bash +set -euo pipefail +# The build container (Dockerfile.ubuntu) runs as native ARM64, but Tauri's +# AppImage bundler downloads and runs linuxdeploy-x86_64.AppImage — an x86_64 +# binary that bundles the app's shared library dependencies (GTK, WebKit, …) +# into the AppDir so the AppImage is self-contained on any x86_64 Linux system. +# Without intervention the ARM64 kernel cannot execute it and returns "Exec +# format error". binfmt_misc is a Linux kernel feature that maps file magic +# bytes to an interpreter; by registering a handler here, the kernel +# automatically prepends QEMU to any x86_64 ELF execution, making x86_64 +# binaries work transparently inside the ARM64 container without any changes to +# the code that invokes them. +# +# Register an x86_64 binfmt_misc handler so the ARM64 kernel transparently +# routes x86_64 ELF executables through QEMU. This is needed because Tauri's +# AppImage bundler downloads and runs linuxdeploy-x86_64.AppImage. +# +# binfmt_misc registration format: +# :name:type:offset:magic:mask:interpreter:flags +# +# The magic matches an x86_64 ELF executable header: +# \x7fELF — ELF magic +# \x02 — 64-bit (EI_CLASS) +# \x01 — little-endian (EI_DATA) +# \x01 — version +# \x00... — OS/ABI + padding (bytes 7-15) +# \x02\x00 — ET_EXEC (executable) +# \x3e\x00 — x86-64 machine type +# +# The mask has \x00 for bytes 7-15 (the padding area). A zero mask bit means +# "don't check this byte". This is necessary because AppImages embed the +# two-byte marker 'AI' in bytes 8-9 of the ELF padding. The standard x86_64 +# mask checks those bytes exactly (\xff), so it rejects AppImages. With the +# permissive mask both plain ELFs and AppImages are intercepted. +# +# echo (not printf): the \x.. sequences must reach the kernel binfmt_misc +# parser as literal text (e.g. the four characters \, x, 7, f), which the +# kernel then interprets as byte values. printf would expand them to raw bytes +# first, the parser would misread the entry, and the broken handler would +# intercept ARM64 binaries and route them through QEMU, breaking the container. +mount binfmt_misc -t binfmt_misc /proc/sys/fs/binfmt_misc 2>/dev/null || true +echo ':qemu-x86_64-appimage:M::\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x3e\x00:\xff\xff\xff\xff\xff\xfe\xfe\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xff\xff\xff:/usr/bin/qemu-x86_64-static:' \ + > /proc/sys/fs/binfmt_misc/register 2>/dev/null || true +export APPIMAGE_EXTRACT_AND_RUN=1 +# linuxdeploy-plugin-gtk.sh calls pkg-config to find GTK 3; on an ARM64 host +# with amd64 multiarch the default search path misses the amd64 .pc files. +export PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig +# Ubuntu 24.04 installs gtk-query-immodules-3.0 under the t64 directory. +# The plugin needs it in PATH; binfmt-misc handles it as x86_64 via QEMU. +export PATH=/usr/lib/x86_64-linux-gnu/libgtk-3-0t64:$PATH +npm exec -- tauri build \ + --config '{"build":{"beforeBuildCommand":""}}' \ + --target x86_64-unknown-linux-gnu \ + --bundles appimage + +appimage=(target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage) +src="${appimage[0]}" + +# A type-2 AppImage is an ELF runtime followed by a squashfs image. Reuse the +# runtime (bytes before --appimage-offset) and rebuild only the squashfs. +offset=$("$src" --appimage-offset) +head -c "$offset" "$src" > AppImage.runtime +rm -rf squashfs-root +# -no-xattrs: the build container's filesystem can't restore them. +unsquashfs -no-xattrs -o "$offset" -d squashfs-root "$src" >/dev/null + +# linuxdeploy bundles libgobject/libgio but not libglib/libgmodule. On hosts +# with a newer glib (e.g. Fedora) the mismatched bundled libs crash GTK at +# startup. Drop them so the whole glib stack resolves from the host. +rm -f squashfs-root/usr/lib/libgobject-2.0.so* \ + squashfs-root/usr/lib/libgio-2.0.so* + +# Fail the build here, not at a user's launch, if the bundle is wrong. +if [ ! -f squashfs-root/usr/lib/libjpeg.so.8 ]; then + echo "error: libjpeg.so.8 is not bundled in the AppImage" >&2 + exit 1 +fi +for lib in libgobject-2.0.so libgio-2.0.so; do + if compgen -G "squashfs-root/usr/lib/$lib*" >/dev/null; then + echo "error: $lib is still bundled; it must resolve from the host" >&2 + exit 1 + fi +done + +# Reassemble: runtime + new squashfs, matching the runtime's zstd/128K. +mksquashfs squashfs-root AppImage.squashfs \ + -comp zstd -b 131072 -root-owned -noappend -no-progress -no-xattrs +cat AppImage.runtime AppImage.squashfs > "$src" +chmod +x "$src" +rm -rf squashfs-root AppImage.runtime AppImage.squashfs + +cp "$src" /output/ diff --git a/desktop/scripts/build-artifacts b/desktop/scripts/build-artifacts new file mode 100755 index 000000000..c64a42d68 --- /dev/null +++ b/desktop/scripts/build-artifacts @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# +# Build release artifacts for all platforms. +# +# Usage: scripts/build-artifacts [OPTIONS] +# +# Options: +# --only-dmg Build macOS aarch64 DMG only +# --only-deb Build Linux amd64 deb only +# --only-appimage Build Linux amd64 AppImage only +# --rebuild-image Force rebuild of podman container images +# --clean Wipe target-release/, node_modules/ and Linux build volumes +# --help Show this help + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# ---- Flags ---- + +BUILD_DMG=true +BUILD_DEB=true +BUILD_APPIMAGE=true +REBUILD_IMAGE=false +CLEAN=false + +for arg in "$@"; do + case "$arg" in + --only-dmg) BUILD_DMG=true; BUILD_DEB=false; BUILD_APPIMAGE=false ;; + --only-deb) BUILD_DMG=false; BUILD_DEB=true; BUILD_APPIMAGE=false ;; + --only-appimage) BUILD_DMG=false; BUILD_DEB=false; BUILD_APPIMAGE=true ;; + --rebuild-image) REBUILD_IMAGE=true ;; + --clean) CLEAN=true; BUILD_DMG=false; BUILD_DEB=false; BUILD_APPIMAGE=false ;; + --help|-h) + head -20 "$0" | grep '^#' | sed 's/^# \?//' + exit 0 + ;; + *) echo "Unknown option: $arg" >&2; exit 1 ;; + esac +done + +# ---- Helpers ---- + +step() { echo; echo "==> $*"; } + +# Builds a container image if it does not already exist (or --rebuild-image). +# $1: image name, $2: dockerfile path (relative to PROJECT_DIR), $3: platform +ensure_image() { + local name="$1" + local dockerfile="$2" + local platform="$3" + if [[ "$REBUILD_IMAGE" == true ]] || ! podman image exists "$name" 2>/dev/null; then + echo " Building container image: $name" + podman build \ + --platform "$platform" \ + -t "$name" \ + -f "$PROJECT_DIR/$dockerfile" \ + "$PROJECT_DIR" + fi +} + +# ---- Version and output directory ---- + +VERSION="$(jq -r '.version' "$PROJECT_DIR/crates/radicle-tauri/tauri.conf.json")" +RELEASE_SHA="$(git -C "$PROJECT_DIR" rev-parse HEAD)" +SHORT_SHA="$(git -C "$PROJECT_DIR" rev-parse --short=8 HEAD)" +RELEASE_DIR="$PROJECT_DIR/releases/v$VERSION" + +step "Building release v$VERSION (${SHORT_SHA})" +echo " Output: $RELEASE_DIR" +mkdir -p "$RELEASE_DIR" + +# ---- Preflight ---- +# +# The Linux deb/AppImage builds run in Podman; the macOS DMG does not. Fail +# fast here (before the slow native DMG build) if a Linux build is requested +# but Podman is unavailable. + +if [[ "$BUILD_DEB" == true || "$BUILD_APPIMAGE" == true ]]; then + command -v podman >/dev/null 2>&1 || { echo "Error: podman not found" >&2; exit 1; } + podman info >/dev/null 2>&1 || { + echo "Error: Podman is not running — start it (e.g. 'podman machine start')" >&2 + exit 1 + } +fi + +# ---- Clean ---- + +if [[ "$CLEAN" == true ]]; then + step "Cleaning build artifacts" + rm -rf "$PROJECT_DIR/target-release" + rm -rf "$PROJECT_DIR/node_modules" + podman volume rm radicle-desktop-linux-target radicle-desktop-linux-nm radicle-desktop-linux-cargo 2>/dev/null || true + podman image rm radicle-desktop-release-builder 2>/dev/null || true + echo " Cleaned" +fi + +# ---- macOS aarch64 DMG ---- + +if [[ "$BUILD_DMG" == true ]]; then + step "Building macOS aarch64 DMG" + cd "$PROJECT_DIR" + npm install + CARGO_TARGET_DIR="$PROJECT_DIR/target-release" \ + npm exec -- tauri build --bundles dmg + cp "target-release/release/bundle/dmg/Radicle_${VERSION}_aarch64.dmg" \ + "$RELEASE_DIR/radicle-desktop-aarch64.dmg" + echo " radicle-desktop-aarch64.dmg" +fi + +# ---- Linux amd64 deb ---- +# +# Runs in the native ARM64 Ubuntu container, which cross-compiles Rust to +# x86_64. Build artifacts are cached in named podman volumes so subsequent +# runs only recompile what changed. +# +# When to clear volumes: +# After a Rust toolchain bump or dependency change that confuses incremental +# compilation: podman volume rm radicle-desktop-linux-target radicle-desktop-linux-cargo +# After a major npm dependency change: podman volume rm radicle-desktop-linux-nm + +if [[ "$BUILD_DEB" == true ]]; then + step "Building Linux amd64 deb" + ensure_image "radicle-desktop-release-builder" "Dockerfile.release" "linux/arm64" + + podman run --rm \ + -v "$PROJECT_DIR:/workspace:z" \ + -v radicle-desktop-linux-target:/workspace/target:z \ + -v radicle-desktop-linux-nm:/workspace/node_modules:z \ + -v radicle-desktop-linux-cargo:/root/.cargo/registry:z \ + -v "$RELEASE_DIR:/output:z" \ + -w /workspace \ + -e CI=true \ + radicle-desktop-release-builder \ + bash -c " + set -euo pipefail + npm ci + npm exec -- tauri build --target x86_64-unknown-linux-gnu --bundles deb + cp \"target/x86_64-unknown-linux-gnu/release/bundle/deb/radicle-desktop_${VERSION}_amd64.deb\" /output/ + " + + echo " radicle-desktop_${VERSION}_amd64.deb" +fi + +# ---- Linux amd64 AppImage ---- +# +# See scripts/appimage-build for the full setup. Key points: +# - --privileged to mount binfmt_misc for x86_64 emulation via QEMU +# - APPIMAGE_EXTRACT_AND_RUN=1 so linuxdeploy-x86_64.AppImage runs without FUSE +# - PKG_CONFIG_PATH for the GTK plugin to find amd64 packages +# - PATH includes libgtk-3-0t64 dir where gtk-query-immodules-3.0 lives +# The script is mounted from the workspace (heredocs pipe to host, not container) + +if [[ "$BUILD_APPIMAGE" == true ]]; then + step "Building Linux amd64 AppImage" + ensure_image "radicle-desktop-release-builder" "Dockerfile.release" "linux/arm64" + + podman run --rm \ + --privileged \ + -v "$PROJECT_DIR:/workspace:z" \ + -v radicle-desktop-linux-target:/workspace/target:z \ + -v radicle-desktop-linux-nm:/workspace/node_modules:z \ + -v radicle-desktop-linux-cargo:/root/.cargo/registry:z \ + -v "$RELEASE_DIR:/output:z" \ + -w /workspace \ + -e CI=true \ + radicle-desktop-release-builder \ + bash /workspace/scripts/appimage-build + + # Strip the version so the filename matches what the website serves + # (the DMG above is renamed the same way). + mv "$RELEASE_DIR/radicle-desktop_${VERSION}_amd64.AppImage" \ + "$RELEASE_DIR/radicle-desktop-amd64.AppImage" + echo " radicle-desktop-amd64.AppImage" +fi + +if [[ "$CLEAN" == false ]]; then + # ---- latest.json ---- + + step "Generating latest.json" + printf '{"sha": "%s", "version": "%s"}' "$RELEASE_SHA" "$VERSION" \ + > "$RELEASE_DIR/latest.json" + echo " latest.json" + + # ---- Summary ---- + + echo + echo "Done. Artifacts in $RELEASE_DIR:" + ls -lh "$RELEASE_DIR" +fi diff --git a/desktop/scripts/check-ci b/desktop/scripts/check-ci new file mode 100755 index 000000000..4a0a8b797 --- /dev/null +++ b/desktop/scripts/check-ci @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Runs the same checks as CI and keeps the full output with per-step timings in +# tests/tmp/check-ci.log. That log lives outside tests/artifacts, which +# Playwright clears when the e2e run starts. +set -uo pipefail + +cd "$(dirname "$0")/.." || exit + +log=tests/tmp/check-ci.log +mkdir -p "$(dirname "$log")" +echo "check-ci started $(date -u +%Y-%m-%dT%H:%M:%SZ) at $(git rev-parse --short HEAD)" >"$log" + +if [ -t 1 ]; then + bold=$'\e[1m' dim=$'\e[2m' green=$'\e[32m' red=$'\e[31m' reset=$'\e[0m' +else + bold="" dim="" green="" red="" reset="" +fi + +# Prints to the terminal with styling and to the log without. +say() { + printf '%s\n' "$1" + printf '%s\n' "$2" >>"$log" +} + +width=$(tput cols 2>/dev/null || echo 80) +[ "$width" -gt 100 ] && width=100 + +# A full-width rule with a title, like "━━ check-js ━━━━━━". +rule() { + local text="━━ $1 " + local fill=$((width - ${#text})) + [ "$fill" -lt 3 ] && fill=3 + printf '%s%s' "$text" "$(printf '━%.0s' $(seq 1 "$fill"))" +} + +steps=( + "check-js|scripts/check-js" + "check-rs|scripts/check-rs" + "test:unit|npm run test:unit" + "install-binaries|scripts/install-binaries" + "test:e2e|npm run test:e2e" +) +summary=() +status=0 +total_start=$SECONDS + +for step in "${steps[@]}"; do + name=${step%%|*} + command=${step#*|} + if [ "$status" -ne 0 ]; then + summary+=("$(printf '%s- %-18s skipped%s' "$dim" "$name" "$reset")|$(printf -- '- %-18s skipped' "$name")") + continue + fi + + say "" "" + say "${bold}$(rule "$name")${reset}" "$(rule "$name")" + say "${dim} \$ $command${reset}" " \$ $command" + say "" "" + start=$SECONDS + bash -c "$command" 2>&1 | awk '{ print " " $0; fflush() }' | tee -a "$log" + status=${PIPESTATUS[0]} + took=$((SECONDS - start)) + + if [ "$status" -eq 0 ]; then + summary+=("$(printf '%s✓%s %-18s %4ss' "$green" "$reset" "$name" "$took")|$(printf '✓ %-18s %4ss' "$name" "$took")") + else + summary+=("$(printf '%s✗ %-18s %4ss exit %s%s' "$red" "$name" "$took" "$status" "$reset")|$(printf '✗ %-18s %4ss exit %s' "$name" "$took" "$status")") + fi +done + +say "" "" +say "${bold}$(rule summary)${reset}" "$(rule summary)" +say "" "" +for line in "${summary[@]}"; do + say " ${line%%|*}" " ${line#*|}" +done +say "" "" + +took=$((SECONDS - total_start)) +if [ "$status" -eq 0 ]; then + say " ${bold}${green}All checks passed in ${took}s${reset} ${dim}· log: $log${reset}" " All checks passed in ${took}s" +else + say " ${bold}${red}Checks failed after ${took}s${reset} ${dim}· log: $log${reset}" " Checks failed after ${took}s" +fi +exit "$status" diff --git a/desktop/scripts/check-js b/desktop/scripts/check-js new file mode 100755 index 000000000..28af04567 --- /dev/null +++ b/desktop/scripts/check-js @@ -0,0 +1,7 @@ +#!/bin/sh +set -e + +# svelte-check also type-checks the .ts files in tsconfig.json, like tsc would. +npx svelte-check --tsconfig tsconfig.json --fail-on-warnings --compiler-warnings options_missing_custom_element:ignore +npx eslint --cache --cache-location node_modules/.cache/eslint --max-warnings 0 . +npx prettier "**/*.@(ts|js|svelte|json|css|html|yml)" "!crates/**/*" --ignore-path .gitignore --check --cache diff --git a/desktop/scripts/check-rs b/desktop/scripts/check-rs new file mode 100755 index 000000000..f2b08a328 --- /dev/null +++ b/desktop/scripts/check-rs @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +mkdir -p "$(dirname "$0")/../build" + +cargo fmt --check +cargo clippy --workspace --all-targets -- -Dwarnings +cargo test --workspace diff --git a/desktop/scripts/copy-katex-assets b/desktop/scripts/copy-katex-assets new file mode 100755 index 000000000..aa83b2535 --- /dev/null +++ b/desktop/scripts/copy-katex-assets @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +echo "Copying katex assets into bundle directory" + +cp -r node_modules/katex/dist/katex.min.css public/katex.min.css +cp -r node_modules/katex/dist/fonts/* public/fonts/ diff --git a/desktop/scripts/install-binaries b/desktop/scripts/install-binaries new file mode 100755 index 000000000..763216ebc --- /dev/null +++ b/desktop/scripts/install-binaries @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -e + +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD") +RELEASE=$(cat "$REPO_ROOT/tests/support/heartwood-release") +BINARY_PATH=$REPO_ROOT/tests/tmp/bin +OS=$(uname) + +install() { + if test -d "$BINARY_PATH/$1/$2"; then + echo ✅ "Folder $BINARY_PATH/$1/$2 exists already skipping download." + else + mkdir -p "$BINARY_PATH/$1/$2" + case "$OS" in + Darwin) + ARCH="aarch64-apple-darwin" + ;; + Linux) + ARCH="x86_64-unknown-linux-musl" + ;; + *) + echo "There are no precompiled binaries for your OS: $OS, compile $1 manually and make sure it's in PATH." && exit 1 + ;; + esac + case "$1" in + heartwood) + FETCH_URL="https://files.radicle.dev/releases/$2/radicle-$ARCH.tar.xz" + FILENAME="radicle-$2-$ARCH" + ;; + *) + echo "No precompiled binary found with the name $1." && exit 1 + ;; + esac + + echo Downloading "$1" v"$2" from "$FETCH_URL into /tests/tmp/bin/$1/$2" + curl --fail -s "$FETCH_URL" | tar -xJ --strip-components=2 -C "$BINARY_PATH/$1/$2" "$FILENAME/bin/" || (echo "Download failed" && exit 1) + fi +} + +show_usage() { + echo + echo "Installs binaries required for running e2e test suite." + echo + echo "USAGE:" + echo " install-binaries [-h]" + echo + echo "OPTIONS:" + echo " -h --help Print this Help." + echo +} + +while [ $# -ne 0 ]; do + case "$1" in + --help | -h) + show_usage + exit + ;; + esac +done + +install "heartwood" "$RELEASE" + +echo diff --git a/desktop/scripts/install-twemoji-assets b/desktop/scripts/install-twemoji-assets new file mode 100755 index 000000000..96a48b219 --- /dev/null +++ b/desktop/scripts/install-twemoji-assets @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -Eeou pipefail + +version="$(node -e 'console.log(require("twemoji/package.json").version)')" + +echo "Installing Twemoji SVG assets v${version}" + +curl -sSL "https://github.com/twitter/twemoji/archive/refs/tags/v${version}.tar.gz" \ + | tar -x -z -C public/twemoji/ --strip-components=3 "twemoji-${version}/assets/svg" diff --git a/desktop/scripts/release b/desktop/scripts/release new file mode 100755 index 000000000..b07f40ced --- /dev/null +++ b/desktop/scripts/release @@ -0,0 +1,390 @@ +#!/usr/bin/env bash +# +# Interactive, phased release orchestrator for radicle-desktop. +# +# Automates the mechanical steps of the release process and pauses at the human +# gates (CI/patch approval, DMG smoke-test, the cross-repo apt signing, delegate +# tag approval, Zulip announce). Each phase prints what to do next and the exact +# flag to resume with. State is not persisted: the version comes from --version +# (prep) or tauri.conf.json (later phases), and commit SHAs are derived from git. +# +# Usage: +# scripts/release --phase prep --version X.Y.Z +# scripts/release --phase build +# scripts/release --phase apt +# scripts/release --phase publish +# scripts/release --phase tag +# +# scripts/release --list List the phases in order +# scripts/release --help Show this help +# +# Add --dry-run (-n) to a phase to print the commands it would run without +# executing them (read-only lookups still run, so the preview is realistic). +# +# Config is auto-detected and overridable via environment variables: +# FULL_NAME (default: git config user.name) +# EMAIL (default: git config user.email) +# USERNAME (default: $USER) +# SSH_KEY (default: $HOME/.ssh/id_ed25519) +# SIGNING_KEY (default: $HOME/.ssh/apt_signing_key) +# FILES_HOST (default: files.radicle.dev) + +set -Eeuo pipefail + +DRY_RUN=false +CURRENT_PHASE="" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +TAURI_CONF="$PROJECT_DIR/crates/radicle-tauri/tauri.conf.json" +CHANGELOG="$PROJECT_DIR/CHANGELOG.md" +APT_REPO_DIR="$(cd "$PROJECT_DIR/../radicle-apt-repo" 2>/dev/null && pwd || true)" + +PHASES=(prep build apt publish tag) + +# ---- Output helpers ---- + +bold() { printf '\033[1m%s\033[0m\n' "$*"; } +step() { echo; bold "==> $*"; } +info() { echo " $*"; } +warn() { echo " WARNING: $*" >&2; } +die() { echo "Error: $*" >&2; trap - ERR; exit 1; } + +# Fires on any uncaught command failure inside a phase. Points at --dry-run, +# which prints the phase's full command sequence (with real values) so the +# remaining steps can be finished by hand. +on_error() { + local code=$? + trap - ERR + echo >&2 + echo "Error: phase '$CURRENT_PHASE' did not finish (exit $code)." >&2 + echo "Fix the problem above, then review the phase's commands to continue" >&2 + echo "manually from where it stopped:" >&2 + echo >&2 + echo " scripts/release --phase $CURRENT_PHASE --dry-run" >&2 +} + +confirm() { + local prompt="$1" reply + if [[ "$DRY_RUN" == true ]]; then + info "$prompt [dry-run: assuming yes]" + return 0 + fi + read -r -p " $prompt [y/N] " reply + [[ "$reply" =~ ^[Yy]$ ]] +} + +# Run a mutating command, or just print it under --dry-run. +run() { + if [[ "$DRY_RUN" == true ]]; then + printf ' [dry-run] %s\n' "$*" + else + "$@" + fi +} + +# Abort on a missing prerequisite — but only warn under --dry-run, so later +# phases can still be previewed without their inputs present. +fail() { + if [[ "$DRY_RUN" == true ]]; then warn "$*"; else die "$*"; fi +} + +podman_available() { command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1; } + +# build-artifacts enforces a running Podman itself on a real build. This mirror +# only surfaces it under --dry-run (build-artifacts isn't executed then), so the +# preview reports a down/absent Podman too. +require_podman() { + [[ "$DRY_RUN" == true ]] || return 0 + podman_available || warn "Podman is not available (a real build needs it for the Linux artifacts)" +} + +# Every phase runs on the default branch (prep branches off it; later phases run +# after the patch is merged back). Bail with a clear message if we're elsewhere, +# so the user can switch and re-run. Only a warning under --dry-run. +require_branch() { + local current + current="$(git -C "$PROJECT_DIR" rev-parse --abbrev-ref HEAD)" + [[ "$current" == "$EXPECTED_BRANCH" ]] && return 0 + if [[ "$DRY_RUN" == true ]]; then + warn "on branch '$current', expected '$EXPECTED_BRANCH'" + else + die "on branch '$current', expected '$EXPECTED_BRANCH' — run 'git checkout $EXPECTED_BRANCH', then re-run" + fi +} + +# Print the command(s) the user should run next to resume the flow. +# Under --dry-run the hint carries the flag through, so previewing one phase +# suggests previewing the next. +next() { + echo + bold "Next:" + local cmd + for cmd in "$@"; do + [[ "$DRY_RUN" == true ]] && cmd+=" --dry-run" + printf ' %s\n' "$cmd" + done +} + +# ---- Config (auto-detect, env-overridable) ---- + +FULL_NAME="${FULL_NAME:-$(git -C "$PROJECT_DIR" config user.name)}" +EMAIL="${EMAIL:-$(git -C "$PROJECT_DIR" config user.email)}" +USERNAME="${USERNAME:-${USER:-}}" +SSH_KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519}" +SIGNING_KEY="${SIGNING_KEY:-$HOME/.ssh/apt_signing_key}" +FILES_HOST="${FILES_HOST:-files.radicle.dev}" +EXPECTED_BRANCH="${EXPECTED_BRANCH:-main}" + +# ---- Version helpers ---- + +# Reads the current version from tauri.conf.json. +conf_version() { + jq -r '.version' "$TAURI_CONF" +} + +# The SHA of the release commit for $1, looked up by its commit subject. +release_commit_sha() { + git -C "$PROJECT_DIR" log --grep="^Release v$1\$" -1 --format=%H +} + +# ---- Phases ---- + +phase_prep() { + local version="$1" + [[ -n "$version" ]] || die "prep requires --version X.Y.Z" + + step "Preparing release v$version" + + info "Syncing and fetching" + run rad sync + run git -C "$PROJECT_DIR" fetch + + info "Creating branch release/v$version" + run git -C "$PROJECT_DIR" checkout -b "release/v$version" + + step "Changelog material (for the v$version entry)" + local prev + prev="$(git -C "$PROJECT_DIR" log --grep='^Release v' -1 --format=%H || true)" + if [[ -n "$prev" ]]; then + info "Commits since $(git -C "$PROJECT_DIR" log -1 --format='%s' "$prev"):" + echo + git -C "$PROJECT_DIR" log "$prev"..HEAD --format='- %s (%an <%ae>)' + echo + bold " External contributors (map these to DID links for the shout-out):" + echo + git -C "$PROJECT_DIR" log "$prev"..HEAD --format='%an <%ae>' \ + | sort -u | grep -v -F "$EMAIL" || true + echo + info "Format the list into the user-facing changes for the CHANGELOG entry." + else + warn "no previous 'Release v*' commit found; writing the entry from scratch" + fi + + if ! confirm "Continue and open CHANGELOG.md in \$EDITOR?"; then + die "Aborted before editing CHANGELOG." + fi + + step "Bumping version to $version" + run sed -i '' -E "s/^( \"version\": \")[^\"]+(\",)$/\1$version\2/" "$TAURI_CONF" + [[ "$DRY_RUN" == true ]] || info "tauri.conf.json -> $(conf_version)" + + step "Editing CHANGELOG.md" + info "Add the v$version entry (Features, Bug fixes, Shout-out block)." + run "${EDITOR:-vi}" "$CHANGELOG" + + step "Creating Release Commit" + run git -C "$PROJECT_DIR" commit -a -m "Release v$version" + local sha + sha="$(git -C "$PROJECT_DIR" rev-parse HEAD)" + info "Release commit: $sha" + + step "Pushing Release Patch" + if confirm "Push the Release Patch now (git push rad HEAD:refs/patches)?"; then + run git -C "$PROJECT_DIR" push rad HEAD:refs/patches + info "Wait for CI to pass, get the patch approved, and merge it into main." + else + info "Skipped. Push it yourself when ready: git push rad HEAD:refs/patches" + fi + next "scripts/release --phase build" +} + +phase_build() { + local version + version="$(conf_version)" + require_podman + step "Building release artifacts for v$version" + run "$SCRIPT_DIR/build-artifacts" + + step "Build complete" + info "Install releases/v$version/radicle-desktop-aarch64.dmg and verify it runs." + next "scripts/release --phase apt" +} + +phase_apt() { + local version + version="$(conf_version)" + local deb="radicle-desktop_${version}_amd64.deb" + local src="$PROJECT_DIR/releases/v$version/$deb" + + step "Staging the Debian package for signing" + [[ -f "$src" ]] || fail "$src not found — run --phase build first" + [[ -n "$APT_REPO_DIR" ]] || fail "../radicle-apt-repo not found" + run cp "$src" "${APT_REPO_DIR:-../radicle-apt-repo}/" + info "Copied $deb -> ${APT_REPO_DIR:-../radicle-apt-repo}/" + + step "Sign and publish the package from radicle-apt-repo" + info "Run these in $APT_REPO_DIR:" + echo + cat < $version" + run ssh -i "$key" "$target" "ln -sfn '$version' '$remote/latest'" + + step "Published" + info "https://$FILES_HOST/releases/radicle-desktop/$version/" + info "latest now resolves to $version (update checker reads latest/latest.json)." + next "scripts/release --phase tag" +} + +phase_tag() { + local version + version="$(conf_version)" + step "Tagging canonical release v$version" + + local sha + sha="$(release_commit_sha "$version")" + [[ -n "$sha" ]] || fail "could not find the 'Release v$version' commit (is the patch merged into main?)" + info "Tagging commit: ${sha:-}" + + run git -C "$PROJECT_DIR" tag "releases/$version" "${sha:-}" -m "Release $version" + run git -C "$PROJECT_DIR" push rad --tags + run rad sync + + step "Canonical tag pushed — needs delegate approval" + info "Ask another delegate to approve the tag by running:" + echo + printf ' %s\n' \ + "git push -f self $USERNAME/tags/releases/$version:refs/tags/releases/$version" \ + "rad sync -a" + + step "Announce on Zulip (once the tag is approved)" + local entry + entry="$(awk -v v="### v$version" ' + $0 == v {grab=1} + grab && NR>1 && /^### v/ && $0 != v {exit} + grab {print} + ' "$CHANGELOG")" + [[ -n "$entry" ]] || fail "no '### v$version' section found in CHANGELOG.md" + + echo + bold " Copy the message below into the announcements channel:" + echo " --------------------------------------------------------------------" + echo "@**all** A new Radicle Desktop release is out :point_right: [give it a try](https://radicle.network/desktop)!" + echo + echo "$entry" + echo " --------------------------------------------------------------------" + echo + info "Post it under a topic named: radicle-desktop v$version" + info "Then resolve the previous release topic." + echo + bold "Release v$version complete." +} + +# ---- Dispatch ---- + +usage() { + awk 'NR==1 {next} /^#/ {sub(/^# ?/, ""); print; next} {exit}' "$0" +} + +list_phases() { + echo "Phases (run in order):" + printf ' %s\n' "${PHASES[@]}" +} + +main() { + local phase="" version="" + while [[ $# -gt 0 ]]; do + case "$1" in + --phase) [[ $# -ge 2 ]] || die "--phase needs a value (try --list)" + phase="$2"; shift 2 ;; + --version) [[ $# -ge 2 ]] || die "--version needs a value, e.g. X.Y.Z" + version="$2"; shift 2 ;; + --dry-run|-n) DRY_RUN=true; shift ;; + --list) list_phases; exit 0 ;; + --help|-h) usage; exit 0 ;; + *) die "Unknown option: $1 (try --help)" ;; + esac + done + + [[ -n "$phase" ]] || { usage; exit 1; } + + [[ "$DRY_RUN" == true ]] && bold "DRY RUN — no commands will be executed" + + require_branch + + CURRENT_PHASE="$phase" + trap on_error ERR + + case "$phase" in + prep) phase_prep "$version" ;; + build) phase_build ;; + apt) phase_apt ;; + publish) phase_publish ;; + tag) phase_tag ;; + *) die "Unknown phase: $phase (try --list)" ;; + esac +} + +main "$@" diff --git a/desktop/src/App.svelte b/desktop/src/App.svelte new file mode 100644 index 000000000..ae7663568 --- /dev/null +++ b/desktop/src/App.svelte @@ -0,0 +1,299 @@ + + + + + + + +{#if $activeRouteStore.resource === "booting"} + {#if startup.error?.code === "IdentityError.MissingProfile"} + + {:else if startup.error?.code === "PassphraseError.InvalidPassphrase" && profile} + + {:else if startup.error} + + We were unable to load your Radicle identity, or your Radicle installation + is outdated. +
+ If you have an existing Radicle installation, make sure you have + + the latest version. + +
+
+
+ To migrate your Radicle storage, make sure to restart your radicle-node + or run: + +
+
+ {/if} +{:else} +
+ +
+ {#if $activeRouteStore.resource === "inbox"} + + {:else if $activeRouteStore.resource === "guide"} + + {:else if $activeRouteStore.resource === "repo.home"} + + {:else if $activeRouteStore.resource === "repo.commits"} + + {:else if $activeRouteStore.resource === "repo.commit"} + + {:else if $activeRouteStore.resource === "repo.identity"} + + {:else if $activeRouteStore.resource === "repo.issue"} + + {:else if $activeRouteStore.resource === "repo.issues"} + + {:else if $activeRouteStore.resource === "repo.patch"} + + {:else if $activeRouteStore.resource === "repo.patches"} + + {:else if $activeRouteStore.resource === "repo.release"} + {#key $activeRouteStore.params.release.id} + + {/key} + {:else if $activeRouteStore.resource === "repo.releases"} + + {:else} + {unreachable($activeRouteStore)} + {/if} +
+
+{/if} diff --git a/desktop/src/components/AddRepoButton.svelte b/desktop/src/components/AddRepoButton.svelte new file mode 100644 index 000000000..04ae13185 --- /dev/null +++ b/desktop/src/components/AddRepoButton.svelte @@ -0,0 +1,257 @@ + + + + + + + + {#snippet toggle(onclick)} + + {/snippet} + + {#snippet popover()} +
+
+ + +
+ + {#if tab.value === "seed"} + +
+ You can look for Radicle repos on + + radicle.network + . +
+
+
+
+
+
+ + +
+ {#if validationMessage} +
+ + {validationMessage} +
+ {/if} +
+
+
+
+ By seeding a repository, your node fetches it from the network, + allowing you to interact with it locally while also making it + available to others. +
+ {#if !$nodeRunning} +
+
+ Your node is Offline. You can still add repos, but they will only + be fetched once your node is back online. +
+
+ {/if} + {:else if tab.value === "publish"} +

+ Navigate to an existing Git repo in your terminal + + cd path/to/your/repo + + and run the following command: +

+ + + +

+ Follow the setup prompts to initialize the repo and publish it on the + Radicle network: +

+ +
    +
  • + Repository Name: + The name of your repo. +
  • +
  • + Description: + A brief summary of what your repo does. +
  • + +
  • + Default Branch: + Typically + main + or + master. +
  • +
  • + Visibility: + Choose + public + to share with others or + private + to not publish it to the network yet. +
  • +
+

+ That's it! Your repo is now on the Radicle network. +

+ {/if} +
+ {/snippet} +
diff --git a/desktop/src/components/AnnounceSwitch.svelte b/desktop/src/components/AnnounceSwitch.svelte new file mode 100644 index 000000000..ab8fb9a63 --- /dev/null +++ b/desktop/src/components/AnnounceSwitch.svelte @@ -0,0 +1,61 @@ + + + + + + +
+ + + +
diff --git a/desktop/src/components/AppSidebar.svelte b/desktop/src/components/AppSidebar.svelte new file mode 100644 index 000000000..a4937f77b --- /dev/null +++ b/desktop/src/components/AppSidebar.svelte @@ -0,0 +1,738 @@ + + + + +
+ +
diff --git a/desktop/src/components/ArtifactDownloadButton.svelte b/desktop/src/components/ArtifactDownloadButton.svelte new file mode 100644 index 000000000..5354c5249 --- /dev/null +++ b/desktop/src/components/ArtifactDownloadButton.svelte @@ -0,0 +1,419 @@ + + + + + + {#snippet toggle(onclick)} + + {/snippet} + + {#snippet popover()} +
+ {#if !authorTrusted} +
+ +
+ {/if} +
+ + + +
+ + {#if activeTab === "app"} + + {#if nodeRunning === false} +
+ +
+ {/if} +
+ + {#if !seeding} + + {/if} +
+ + {#if running} +
+
+
+
+ {phaseLabel} + {#if progress?.offset !== undefined} + + {formatBytes(progress.offset)}{progress.total + ? ` / ${formatBytes(progress.total)}` + : ""} + + {/if} +
+ {:else if finished} +
+ + {finished === "download" ? "Saved" : "Fetched"}{seeding + ? " and seeding" + : ""}. +
+ {/if} + + {#if downloadError} +
{downloadError}
+ {/if} + + {#if !seeding} +
+ Seed after fetching +
+ {/if} + {:else if activeTab === "cli"} + {#if nodeRunning === false} + +
+ +
+ {/if} + + + {:else} +
+ + These downloads are not checked against the CID. +
+ {#if authorTrusted && linksByOthers} +
+ +
+ {/if} +
+ {#each webLocations as location (`${location.user.did}:${location.url}`)} +
+ {#if delegateIds.has(location.user.did)} + + {/if} + {location.url} + +
+ {/each} +
+ {/if} +
+ {/snippet} +
diff --git a/desktop/src/components/ArtifactNodeStatusButton.svelte b/desktop/src/components/ArtifactNodeStatusButton.svelte new file mode 100644 index 000000000..f45949d63 --- /dev/null +++ b/desktop/src/components/ArtifactNodeStatusButton.svelte @@ -0,0 +1,275 @@ + + + + + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} +
+ {#if status} + {#if status.relayUnreachable} +
+ + No relay connected: peers may not reach this node. +
+ {/if} +
+ Endpoint + + + + {#if status.version} + Node version + {status.version} + {/if} + {#if binaries?.cliVersion} + CLI version + {binaries.cliVersion} + {/if} + Uptime + {uptime} + Seeding + + {status.seededCount} + {status.seededCount === 1 ? "artifact" : "artifacts"} + ({formatBytes(status.seededBytesLogical)}) + + Store + + + + Connections + {status.connectionsActive} active + Traffic + + {formatBytes(status.inBytes)} in, {formatBytes(status.outBytes)} out + + Relay + {#if status.relays[0]} + {@const relay = status.relays[0]} + + {relay.url} + + {#if relay.connected} + Connected{relay.latencyMs !== undefined + ? `, ${relay.latencyMs}ms` + : ""} + {:else} + Disconnected + {/if} + + + {:else} + None + {/if} + {#if status.pkarrUri} + Pkarr key + + + + {/if} + {#if status.pkarrRelays.length > 0} + Pkarr relays + + {#each status.pkarrRelays as relay (relay)} + + + + {/each} + + {/if} +
+ {:else if running === false && installed !== undefined} +
+ {#if installed} + The artifact node is not running, so artifacts cannot be downloaded + or seeded from this app. + {:else if binaries?.cli} + The artifact seeding daemon is not installed, so artifacts cannot be + downloaded or seeded from this app. + {:else} + The artifact tools are not installed, so artifacts cannot be + downloaded or seeded from this app. + {/if} +
+ {installed ? "Start it with:" : "Install them with:"} +
+ {#if installed} + + {:else} + + {/if} +
+
+
+ {:else} + Checking the artifact node… + {/if} +
+ {/snippet} +
diff --git a/desktop/src/components/ArtifactRow.svelte b/desktop/src/components/ArtifactRow.svelte new file mode 100644 index 000000000..834c3090c --- /dev/null +++ b/desktop/src/components/ArtifactRow.svelte @@ -0,0 +1,1167 @@ + + + + +
+
+ + {#if size} + {size} + {/if} +
+ {#if seeding} + + {:else if !artifact.redacted} + {#if reseedError} + + {reseedError} + + {/if} + + {/if} + +
+
+ +
+ + + + + + {#if delegateIds.has(artifact.author.did)} + + {/if} + + {#if trust} + + + {trust} + + {/if} + {#if unseedFailed} + Could not unseed. + {/if} +
+ + {#if open} +
+
+
+ Metadata + {metadata.length} + {#if editable && editing !== ""} + + {/if} +
+ + {#if metadata.length === 0 && editing !== ""} +
No metadata
+ {/if} + + {#each metadata as [key, value] (key)} + {#if editing === key} +
+ + + + + saveMetadata(key)} /> + + + +
+ {:else} +
+ {key} + + {displayMetadataValue(value)} + + {#if editable} + + + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} +
+
+
+ Remove "{key}"? +
+
+ The entry is dropped from the release for everyone + who replicates it. You can set it again afterwards. +
+
+
+ + +
+
+ {/snippet} +
+
+ {/if} +
+ {/if} + {/each} + + {#if editing === ""} +
+ + + + + saveMetadata("")} /> + + + +
+ {/if} + + {#if metadataError} +
{metadataError}
+ {/if} +
+ +
+
+ Attestations + {attestations.length} + {#if canAttest(artifact, ownDid)} + + {/if} +
+ {#if attestError} +
{attestError}
+ {/if} + {#if attestations.length === 0} +
Nobody has attested to this artifact
+ {:else} +
+ {#each attestations as node (node.did)} +
+ + + {#if delegateIds.has(node.did)} + + {/if} +
+ {/each} +
+ {/if} +
+ +
+
+ Redactions + {redactions.length} + + {#if editable && !redactions.some(r => r.user.did === ownDid)} + + {/if} +
+ {#if redactions.length === 0} +
No redactions
+ {:else} +
+ {#each redactions as redaction (redaction.user.did)} +
+ + {#if delegateIds.has(redaction.user.did)} + + {/if} + + {redaction.reason || "No reason"} + +
+ {/each} +
+ {/if} +
+ +
+
+ Locations + {artifact.locations.length} + {#if !artifact.redacted && !addingLocation} + + {/if} +
+ {#if addingLocation} +
+ + + + + +
+ {/if} + {#if locationError} +
{locationError}
+ {/if} + {#if artifact.locations.length === 0} + {#if !addingLocation} +
No locations
+ {/if} + {:else} +
+ {#each locations as group (group.user.did)} +
+
+ + {#if delegateIds.has(group.user.did)} + + {/if} +
+ {#each group.urls as url (url)} +
+ + + + {#if group.user.did === ownDid} + + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} + + {@const unseeds = + url.startsWith("radiroh:") && seeding} + {@const busy = savingLocation || unseeding} +
+
+
+ {unseeds + ? "Unseed this artifact?" + : "Remove this location?"} +
+
+ {#if unseeds} + This is your node's location. Removing it + also unseeds it from your node seeding the + artifact. + {:else} + Peers stop downloading the artifact from it. + {/if} +
+
+
+ + +
+
+ {/snippet} +
+
+ {/if} +
+ {/each} +
+ {/each} +
+ {/if} +
+
+ {/if} +
diff --git a/desktop/src/components/Asciidoc.svelte b/desktop/src/components/Asciidoc.svelte new file mode 100644 index 000000000..707787bbb --- /dev/null +++ b/desktop/src/components/Asciidoc.svelte @@ -0,0 +1,367 @@ + + + + +{#if error} +
+ Not able to render AsciiDoc: {error} +
+{:else} +
+ {@html html} +
+{/if} diff --git a/desktop/src/components/AssigneeInput.svelte b/desktop/src/components/AssigneeInput.svelte new file mode 100644 index 000000000..0bb8462ee --- /dev/null +++ b/desktop/src/components/AssigneeInput.svelte @@ -0,0 +1,427 @@ + + + + +{#if preview} +
+ + {#each updatedAssignees as assignee} + + + + {/each} +
+{:else} +
+ {#if showInput} +
+
+ (dismissed = false)} + onFocus={() => { + focused = true; + dismissed = false; + }} + onBlur={() => (focused = false)} + onKeydown={handleKeydown} + onSubmit={addAssignee} /> +
+ {#if panelVisible} + +
event.preventDefault()}> + {#if showError} +
+ {validationMessage} +
+ {/if} + {#each suggestions as suggestion, index (suggestion.did)} + {@const nid = publicKeyFromDid(suggestion.did)} + + {:else} + {#if noMatches} +
{noMatchesMessage}
+ {/if} + {/each} +
+ {/if} + +
+ {:else} + + {/if} + + {#if allowedToEdit} + {#each updatedAssignees as assignee} + + {/each} + {:else} + {#each updatedAssignees as assignee} + + {/each} + {/if} +
+{/if} diff --git a/desktop/src/components/BadgeCounterSwitch.svelte b/desktop/src/components/BadgeCounterSwitch.svelte new file mode 100644 index 000000000..11f55e129 --- /dev/null +++ b/desktop/src/components/BadgeCounterSwitch.svelte @@ -0,0 +1,45 @@ + + + + + + +
+ + +
diff --git a/desktop/src/components/Button.svelte b/desktop/src/components/Button.svelte new file mode 100644 index 000000000..c40b96039 --- /dev/null +++ b/desktop/src/components/Button.svelte @@ -0,0 +1,197 @@ + + + + + +
+ {@render children()} +
diff --git a/desktop/src/components/Changes.svelte b/desktop/src/components/Changes.svelte new file mode 100644 index 000000000..f62c71785 --- /dev/null +++ b/desktop/src/components/Changes.svelte @@ -0,0 +1,886 @@ + + + + +{#snippet commitsColumn()} + {#if commitList.length > 0} +
+ + {#snippet leftHeader()} +
+ {commitList.length} + {pluralize("commit", commitList.length)} on base + +
+ {/snippet} +
+ {#each commitList as commit} + {@const active = isActiveCommit(commit.id)} + {@const toggle = () => { + if (active) { + // Keep the sole commit selected; there's nothing else to show. + if (commitList.length === 1) return; + selectRevision({ + headId: revision.head, + baseId: revision.base, + }); + } else { + selectRevision({ + headId: commit.id, + baseId: commit.parents[0], + commitId: commit.id, + commit, + }); + } + void scrollToDiff(); + }} + + +
+ + {#if commit.parents.length > 0} + + {@const statsPromise = cachedDiffStats( + rid, + commit.parents[0], + commit.id, + )} + {#await statsPromise then commitStats} + + + {commitStats.filesChanged} + +{commitStats.insertions} + -{commitStats.deletions} + + {/await} + {/if} + {#if commit.id === revision.head} + + {/if} + + {#if active && commitList.length > 1} + + + + {/if} +
+ {/each} +
+
+
+ {/if} +{/snippet} + +{#snippet diffHeader()} +
+ {#if chrome} + {@render chrome()} + {/if} +
+{/snippet} + +{#snippet filesHeader()} + {#if showRevisionDescription || canEditDescription} + {#if editingDescription} +
+ { + await onSaveDescription?.(comment, Array.from(embeds.values())); + editingDescription = false; + }} + close={() => (editingDescription = false)} /> +
+ {:else} +
+
+ {#if showRevisionDescription} + { + await onSaveDescription( + content, + revision.description.slice(-1)[0]?.embeds ?? [], + ); + } + : undefined} /> + {:else} + No description + {/if} +
+ {#if descriptionOverflows} +
+ +
+ {/if} + {#if canEditDescription} +
+ +
+ {/if} +
+ {/if} + {/if} +
+
+ {#if diffFailed} + + Failed to load the changes. + + {:else if stats} + {stats.filesChanged} + {pluralize("file", stats.filesChanged)} modified with + + {stats.insertions} + {pluralize("insertion", stats.insertions)} + + and + + {stats.deletions} + {pluralize("deletion", stats.deletions)} + + {:else} + Loading… + {/if} +
+
+ +
+ {#if selectedCommitData} +
+
+ {selectedCommitData.summary} +
+ {#if selectedCommitData.message.trim() !== selectedCommitData.summary.trim()} +
{selectedCommitData.message
+              .replace(selectedCommitData.summary, "")
+              .trim()}
+ {/if} +
+ {/if} +
+{/snippet} + + getDiffText(rid, base, head, 3, path)} + {collapsedPaths} + {reviewedPaths} + onToggleReviewed={draftReviewId + ? path => draftReviewStorage.toggleCheckedFile(draftReviewId, path) + : undefined} + {commentCounts} + {highlightedCommentId} + codeComments={diffCodeComments} + commentCommit={isRevisionDiff ? head : undefined} + header={diffHeader} + overlayLeft={commitsColumn} + {filesHeader} + stickyTop={tabs} /> diff --git a/desktop/src/components/Checkbox.svelte b/desktop/src/components/Checkbox.svelte new file mode 100644 index 000000000..ddc31da24 --- /dev/null +++ b/desktop/src/components/Checkbox.svelte @@ -0,0 +1,88 @@ + + + + + diff --git a/desktop/src/components/CheckoutPatchButton.svelte b/desktop/src/components/CheckoutPatchButton.svelte new file mode 100644 index 000000000..dc34b2665 --- /dev/null +++ b/desktop/src/components/CheckoutPatchButton.svelte @@ -0,0 +1,56 @@ + + + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} +
+ + To checkout this patch in your working copy, run: + + +
+ {/snippet} +
diff --git a/desktop/src/components/CheckoutRepoButton.svelte b/desktop/src/components/CheckoutRepoButton.svelte new file mode 100644 index 000000000..97b606057 --- /dev/null +++ b/desktop/src/components/CheckoutRepoButton.svelte @@ -0,0 +1,45 @@ + + + + {#snippet toggle(onclick)} + + {/snippet} + + {#snippet popover()} +
+ + To checkout a working copy of this repo, run: + + +
+ {/snippet} +
diff --git a/desktop/src/components/Clipboard.svelte b/desktop/src/components/Clipboard.svelte new file mode 100644 index 000000000..50bf11739 --- /dev/null +++ b/desktop/src/components/Clipboard.svelte @@ -0,0 +1,122 @@ + + + + + + + + !noPopover && setVisible(true)} + onmouseleave={() => !noPopover && setVisible(false)}> + + + + {#if visible} +
+ + {tooltip} +
+ {/if} +
diff --git a/desktop/src/components/CobCacheWarning.svelte b/desktop/src/components/CobCacheWarning.svelte new file mode 100644 index 000000000..3837e4365 --- /dev/null +++ b/desktop/src/components/CobCacheWarning.svelte @@ -0,0 +1,62 @@ + + + + +
+ +
diff --git a/desktop/src/components/CobCommitTeaser.svelte b/desktop/src/components/CobCommitTeaser.svelte new file mode 100644 index 000000000..927b52f39 --- /dev/null +++ b/desktop/src/components/CobCommitTeaser.svelte @@ -0,0 +1,202 @@ + + + + + + +
+ {#if stacked} +
+ +
+ {#if !commit.summary} + No commit message + {:else} + + {/if} +
+
+
+ {@render children?.()} + + + + + {formatTimestamp(commit.committer.time * 1000)} + +
+ {:else} +
+
+
+ {#if !commit.summary} + No commit message + {:else} + + {/if} +
+ {#if commit.message.trim() !== commit.summary.trim()} +
+ +
+ {/if} +
+ {#if commitMessageVisible} +
+
{commit.message.replace(commit.summary, "").trim()}
+
+ {/if} +
+
+ {@render children?.()} + + + +
+ {/if} +
diff --git a/desktop/src/components/CodeFontSwitch.svelte b/desktop/src/components/CodeFontSwitch.svelte new file mode 100644 index 000000000..7a5e2eb05 --- /dev/null +++ b/desktop/src/components/CodeFontSwitch.svelte @@ -0,0 +1,48 @@ + + + + + + +
+ + + +
diff --git a/desktop/src/components/Command.svelte b/desktop/src/components/Command.svelte new file mode 100644 index 000000000..3004acbc8 --- /dev/null +++ b/desktop/src/components/Command.svelte @@ -0,0 +1,51 @@ + + + + +
+ +
clipboard.copy()}> + + {showPrompt ? "$ " : ""}{command} + + +
+
diff --git a/desktop/src/components/Comment.svelte b/desktop/src/components/Comment.svelte new file mode 100644 index 000000000..950e3b3ac --- /dev/null +++ b/desktop/src/components/Comment.svelte @@ -0,0 +1,443 @@ + + + + + + +
+
+
+ + {caption} + {#if draft} + + Draft + + {/if} + {#if origin} + {#if origin.onclick} + + {:else} + {origin.text} + {/if} + {/if} + {#if beforeTimestamp} + {@render beforeTimestamp()} + {/if} + {#if hoverNote} + {@render hoverNote()} + {/if} + {#if timestamp} + + {utils.formatTimestamp(timestamp)} + + {/if} + {#if lastEdit} + + {/if} + {#if resolved} + + Resolved + + {/if} +
+ {#if reactions && reactOnComment} + { + try { + await reactOnComment(authors, emoji); + } finally { + closeFocused(); + } + }} /> + {/if} + {@render actions?.()} + {#if menuActions.length > 0} + + {#snippet toggle(onclick)} + + + + {/snippet} + {#snippet popover()} + + {/snippet} + + {/if} +
+
+
+ + {#if (body === undefined || body?.trim() === "") && mode === "read"} +
+ + No description. + +
+ {:else} +
+ {#if editComment && mode !== "read"} +
+ { + mode = "submit"; + try { + await editComment(comment, Array.from(embeds.values())); + } finally { + mode = "read"; + } + }} + close={async () => { + body = body; + await tick(); + mode = "read"; + }} /> +
+ {:else} +
+
+ { + await editComment( + content, + Array.from(embeds?.values() ?? []), + ); + } + : undefined} /> +
+
+ {/if} +
+ {/if} + {#if reactions && reactions.length > 0} +
+ + {#if reactOnComment} +
+ { + try { + await reactOnComment(authors, emoji); + } finally { + closeFocused(); + } + }} /> +
+ {/if} +
+ {/if} +
diff --git a/desktop/src/components/CommentAnnotation.svelte b/desktop/src/components/CommentAnnotation.svelte new file mode 100644 index 000000000..576d9f10b --- /dev/null +++ b/desktop/src/components/CommentAnnotation.svelte @@ -0,0 +1,194 @@ + + + + +
+ {#each threads as thread (thread.root.id)} + {#if state.comments} + {@const comments = state.comments} + {@const isDraftThread = + comments.draftThreadIds?.includes(thread.root.id) ?? false} + {@const anchor = anchorLabel(thread)} + +
state.onHoverThread(thread.root.id)} + onmouseleave={() => state.onHoverThread(undefined)}> + { + await comments.createComment(body, embeds, thread.root.id); + } + : undefined} + editComment={comments.editComment} + canModifyComment={partial( + roles.isDelegateOrAuthor, + comments.config.publicKey, + comments.repoDelegates.map(delegate => delegate.did), + )} + deleteComment={comments.deleteComment} + changeCommentStatus={isDraftThread + ? undefined + : comments.changeCommentStatus} + canResolve={!isDraftThread && + Boolean(comments.changeCommentStatus) && + (comments.canResolveComment?.(thread.root.id) ?? false)}> + {#snippet hoverNote()} + {#if anchor} + + on + {anchor} + + {/if} + {/snippet} + +
+ {/if} + {/each} + + {#if composer && state.comments && state.commit} + {@const comments = state.comments} + {@const location = locationOf(state.commit, composer)} +
+ state.composerBody, + value => { + state.composerBody = value; + state.onComposerInput(value); + } + } + disallowEmptyBody + rid={comments.rid} + onclose={() => state.onCloseComposer()} + focus + placeholder="Leave a comment" + submitCaption={comments.newCommentCaption} + submitDescription={comments.newCommentDescription} + disableAttachments={comments.disableAttachments} + submit={async (body, embeds) => { + try { + await comments.createComment(body, embeds, undefined, location); + } catch (error) { + console.error("Comment creation failed", error); + } finally { + state.onCloseComposer(); + } + }} + secondarySubmit={comments.addCodeCommentDirect + ? { + caption: comments.addCodeCommentDirectCaption ?? "Just comment", + description: comments.addCodeCommentDirectDescription, + submit: async (body, embeds) => { + try { + await comments.addCodeCommentDirect?.(body, embeds, location); + } catch (error) { + console.error("Comment creation failed", error); + } finally { + state.onCloseComposer(); + } + }, + } + : undefined} /> +
+ {/if} +
diff --git a/desktop/src/components/CommentToggleInput.svelte b/desktop/src/components/CommentToggleInput.svelte new file mode 100644 index 000000000..990db43ee --- /dev/null +++ b/desktop/src/components/CommentToggleInput.svelte @@ -0,0 +1,137 @@ + + + + +{#if state !== "collapsed"} + { + if (onclose !== undefined) { + onclose(); + } else { + state = "collapsed"; + } + }} + {disableAttachments} + submit={async ({ comment, embeds }) => { + try { + state = "submit"; + await submit(comment, Array.from(embeds.values())); + } finally { + state = "collapsed"; + } + }} + secondarySubmit={secondarySubmit + ? { + caption: secondarySubmit.caption, + description: secondarySubmit.description, + submit: async ({ comment, embeds }) => { + try { + state = "submit"; + await secondarySubmit.submit( + comment, + Array.from(embeds.values()), + ); + } finally { + state = "collapsed"; + } + }, + } + : undefined} /> +{:else} + +
{ + e.preventDefault(); + e.stopPropagation(); + + state = "expanded"; + if (onexpand !== undefined) { + onexpand(); + } + }}> +
+ {placeholder} +
+
+{/if} diff --git a/desktop/src/components/CommitActivityItem.svelte b/desktop/src/components/CommitActivityItem.svelte new file mode 100644 index 000000000..b7cd67aba --- /dev/null +++ b/desktop/src/components/CommitActivityItem.svelte @@ -0,0 +1,100 @@ + + + + +
+
+ +
+
+ {#if !hideAuthor} + {commit.author.name} + {/if} +
+ {#if !hideAuthor} + committed + {/if} + {commit.summary} +
+
+
+ +
+
+ {formatTimestamp(committedAt)} +
+
+
+
diff --git a/desktop/src/components/CommitAuthors.svelte b/desktop/src/components/CommitAuthors.svelte new file mode 100644 index 000000000..ce36ea404 --- /dev/null +++ b/desktop/src/components/CommitAuthors.svelte @@ -0,0 +1,255 @@ + + + + +{#snippet role(label: string, members: GitIdentity[])} +
+
{label}
+ {#each members as who (utils.identityKey(who))} +
+ +
+ {#if who.name} + {who.name} + {/if} + {#if who.email} + + {/if} +
+
+ {/each} +
+{/snippet} + + + {#snippet toggle()} +
+ {#each stacked as who, index (utils.identityKey(who))} + + {/each} + {#if overflow > 0} + +{overflow} + {/if} +
+ {/snippet} + + {#snippet popover()} +
+ {#if authorIsCommitter} + {@render role("Author & committer", [commit.author])} + {:else} + {@render role("Author", [commit.author])} + {@render role("Committer", [commit.committer])} + {/if} + {#if coAuthors.length > 0} + {@render role( + coAuthors.length === 1 ? "Co-author" : "Co-authors", + coAuthors, + )} + {/if} +
+ {/snippet} +
diff --git a/desktop/src/components/CommitsContainer.svelte b/desktop/src/components/CommitsContainer.svelte new file mode 100644 index 000000000..3345e794b --- /dev/null +++ b/desktop/src/components/CommitsContainer.svelte @@ -0,0 +1,42 @@ + + + + +
+
+ + {@render leftHeader()} +
+ {@render children()} +
diff --git a/desktop/src/components/CompactCommitAuthorship.svelte b/desktop/src/components/CompactCommitAuthorship.svelte new file mode 100644 index 000000000..59be56759 --- /dev/null +++ b/desktop/src/components/CompactCommitAuthorship.svelte @@ -0,0 +1,33 @@ + + + + +
+ + {@render children()} +
+ {utils.formatTimestamp(commit.committer.time * 1000)} +
+
diff --git a/desktop/src/components/ConfirmClear.svelte b/desktop/src/components/ConfirmClear.svelte new file mode 100644 index 000000000..26dff5191 --- /dev/null +++ b/desktop/src/components/ConfirmClear.svelte @@ -0,0 +1,45 @@ + + +{#if count > 0} + {#if closed} + + {:else} +
+
+ + +
+
+ {/if} +{/if} diff --git a/desktop/src/components/ConfirmDeleteButton.svelte b/desktop/src/components/ConfirmDeleteButton.svelte new file mode 100644 index 000000000..703788475 --- /dev/null +++ b/desktop/src/components/ConfirmDeleteButton.svelte @@ -0,0 +1,114 @@ + + + + + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} +
+
+
Delete this {noun} from your node?
+
+ Only your copy is removed. You won't be able to restore it here, and + peers who have already replicated the {noun} keep theirs. +
+
+
+ + +
+
+ {/snippet} +
diff --git a/desktop/src/components/ContextMenu.svelte b/desktop/src/components/ContextMenu.svelte new file mode 100644 index 000000000..6446f61b2 --- /dev/null +++ b/desktop/src/components/ContextMenu.svelte @@ -0,0 +1,195 @@ + + + + + diff --git a/desktop/src/components/CopyableId.svelte b/desktop/src/components/CopyableId.svelte new file mode 100644 index 000000000..04dbc64f8 --- /dev/null +++ b/desktop/src/components/CopyableId.svelte @@ -0,0 +1,52 @@ + + + + + +
clipboard.copy()} + class:inline + class="copyable-id global-flex txt-code-regular" + style:font={styleFont}> + {#if children} + {@render children()} + {:else} + {id} + {/if} + +
diff --git a/desktop/src/components/DelegateBadge.svelte b/desktop/src/components/DelegateBadge.svelte new file mode 100644 index 000000000..a7d4abac3 --- /dev/null +++ b/desktop/src/components/DelegateBadge.svelte @@ -0,0 +1,26 @@ + + + + + + {#snippet badge()} + + + + {/snippet} + diff --git a/desktop/src/components/DiffActions.svelte b/desktop/src/components/DiffActions.svelte new file mode 100644 index 000000000..43f4af817 --- /dev/null +++ b/desktop/src/components/DiffActions.svelte @@ -0,0 +1,86 @@ + + + + {#snippet toggle(onclick)} + + {/snippet} + + {#snippet popover()} +
+ + {#snippet item(action)} + run(action)}> + + {action.label} + + {/snippet} + +
+ {/snippet} +
diff --git a/desktop/src/components/DiffFileHeader.svelte b/desktop/src/components/DiffFileHeader.svelte new file mode 100644 index 000000000..acbc59821 --- /dev/null +++ b/desktop/src/components/DiffFileHeader.svelte @@ -0,0 +1,181 @@ + + + + +
+ {#if state.note === "binary"} + + {:else if state.note === "empty"} + + {:else} + + {/if} + + {#if isRename && prevName} + + → + + {:else} + + {/if} + + {#if statusLabel} + {statusLabel} + {/if} + + + {#if !state.note && stats.additions + stats.deletions > 0} + +{stats.additions} + -{stats.deletions} + {/if} + + + {#if state.unresolvedComments > 0} + + + {state.unresolvedComments} + + {/if} + {#if state.resolvedComments > 0} + + + {state.resolvedComments} + + {/if} + + {#if state.reviewed !== undefined} + + {/if} + + {#if text !== undefined} + + {/if} +
diff --git a/desktop/src/components/DiffStatBadge.svelte b/desktop/src/components/DiffStatBadge.svelte new file mode 100644 index 000000000..8d93b282a --- /dev/null +++ b/desktop/src/components/DiffStatBadge.svelte @@ -0,0 +1,38 @@ + + + + +
+
+{stats.insertions}
+
-{stats.deletions}
+
diff --git a/desktop/src/components/Discussion.svelte b/desktop/src/components/Discussion.svelte new file mode 100644 index 000000000..086dad86a --- /dev/null +++ b/desktop/src/components/Discussion.svelte @@ -0,0 +1,253 @@ + + + + + + +
+
+
0}> + {#each runs as run, runIndex (runIndex)} + {#if run.kind === "thread"} + delegate.did), + )} + {editComment} + {deleteComment} + createReply={createComment} + {reactOnComment} /> +
+ {:else if run.kind === "single" && renderActivity} + {@render renderActivity(run.entry.data, { + hideAuthor: run.repeatsAuthor, + })} +
+ {:else if run.kind === "group" && renderActivity} + {#if !run.repeatsAuthor} +
+ +
+ {/if} +
+ {#each run.entries as entry (entry.key)} + {@render renderActivity(entry.data, { hideAuthor: true })} +
+ {/each} +
+ {/if} + {/each} + + {@render afterActivity?.()} +
+ +
+ {#key commentFormKey} + { + focusReply = false; + commentFormKey += 1; + }} + submit={async ({ comment, embeds }) => { + try { + await createComment(comment, Array.from(embeds.values())); + } finally { + focusReply = false; + commentFormKey += 1; + } + }} /> + {/key} +
+
+
diff --git a/desktop/src/components/DraftReviewBar.svelte b/desktop/src/components/DraftReviewBar.svelte new file mode 100644 index 000000000..6dd31e7d9 --- /dev/null +++ b/desktop/src/components/DraftReviewBar.svelte @@ -0,0 +1,816 @@ + + + + +
+ + +
+
+ {#if publishError} + + + Not published — your draft is safe. Try again. + + {:else} + Draft saved + {/if} + {#if filesTotal !== undefined && filesChecked !== undefined} + + + {filesChecked}/{filesTotal} + + {/if} + + + {pendingCommentCount} + {#if showCommentsTooltip && pendingCommentCount > 0} + + {/if} + +
+ +
+ + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} +
+
+
+ Discard this draft review? Your comments and verdict will be + lost. +
+
+ + +
+
+
+ {/snippet} +
+ +
+ + + + + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} +
+ {#each verdictOptions as option (option.label)} + +
{ + verdict = option.value; + draftReviewStorage.update(draftReview.id, { + summary, + verdict: option.value, + labels: draftReview.labels, + }); + closeFocused(); + void onChange(); + }}> +
+ {#if verdict === option.value} + + {/if} +
+
+ {option.label} + + {option.description} + +
+
+ {/each} +
+ {/snippet} +
+
+
+
+
diff --git a/desktop/src/components/DropdownList.svelte b/desktop/src/components/DropdownList.svelte new file mode 100644 index 000000000..267a63297 --- /dev/null +++ b/desktop/src/components/DropdownList.svelte @@ -0,0 +1,66 @@ + + + + + diff --git a/desktop/src/components/DropdownListItem.svelte b/desktop/src/components/DropdownListItem.svelte new file mode 100644 index 000000000..32f6613f0 --- /dev/null +++ b/desktop/src/components/DropdownListItem.svelte @@ -0,0 +1,81 @@ + + + + + +
{ + if (disabled || !onclick) { + return; + } + onclick(); + }}> + {@render children()} +
diff --git a/desktop/src/components/EditableTitle.svelte b/desktop/src/components/EditableTitle.svelte new file mode 100644 index 000000000..63978afa2 --- /dev/null +++ b/desktop/src/components/EditableTitle.svelte @@ -0,0 +1,107 @@ + + + + +
+ {#if editingTitle} + 0} + bind:value={newTitle} + autofocus + onSubmit={save} + onDismiss={() => { + newTitle = title; + editingTitle = false; + }} /> +
+ + +
+ {:else} +
+ +
{ + if (allowedToEdit) { + editingTitle = true; + } + }} + tabindex="0"> + + {#if allowedToEdit} +
+ {/if} +
+
+ {/if} +
diff --git a/desktop/src/components/ExplorerMenu.svelte b/desktop/src/components/ExplorerMenu.svelte new file mode 100644 index 000000000..6dbf9e167 --- /dev/null +++ b/desktop/src/components/ExplorerMenu.svelte @@ -0,0 +1,87 @@ + + + + + + {#if url} + + + Open in {explorerHost(config)} + + + + {/if} + + {#if seedRid} + + + {/if} + diff --git a/desktop/src/components/ExtendedTextarea.svelte b/desktop/src/components/ExtendedTextarea.svelte new file mode 100644 index 000000000..fc529430c --- /dev/null +++ b/desktop/src/components/ExtendedTextarea.svelte @@ -0,0 +1,625 @@ + + + + + +
{ + if (!preview) return; + if (matchesShortcut(event, "submit")) { + event.preventDefault(); + triggerSubmit(); + } + }}> +
+ {#if preview} +
+ {#if body.trim().length === 0} + Nothing to preview. + {:else} + + {/if} +
+ {:else} + + {#if draggingOver} +
+ Drop files to add them as embeds. Embeds are limited to 10MB. +
+ {/if} +
diff --git a/desktop/src/components/ThemeSwitch.svelte b/desktop/src/components/ThemeSwitch.svelte new file mode 100644 index 000000000..7dc8827bf --- /dev/null +++ b/desktop/src/components/ThemeSwitch.svelte @@ -0,0 +1,78 @@ + + + + + + +
+ + + +
diff --git a/desktop/src/components/Thread.svelte b/desktop/src/components/Thread.svelte new file mode 100644 index 000000000..248b12fa7 --- /dev/null +++ b/desktop/src/components/Thread.svelte @@ -0,0 +1,318 @@ + + + + +{#snippet repliesSnippet()} +
+ {#each replies as reply} +
+ 1 ? reply.edits.at(-1) : undefined} + id={reply.id} + author={reply.author} + caption="replied" + resolved={reply.resolved} + resolvedCaption={resolvedCaption(reply.id)} + reactions={reply.reactions} + timestamp={reply.edits[0].timestamp} + body={reply.edits.slice(-1)[0].body} + embeds={embedsOf(reply)} + editComment={canModifyComment(reply.author.did) && + editComment?.bind(null, reply.id)} + reactOnComment={reactOnComment?.bind(null, reply.id)} + deleteComment={canModifyComment(reply.author.did) + ? deleteComment?.bind(null, reply.id) + : undefined} /> +
+ {/each} + {#if createReply && showReplyForm} +
+ (showReplyForm = false)} + submit={async ({ comment, embeds }) => { + try { + submitInProgress = true; + await createReply(comment, Array.from(embeds.values()), root.id); + } finally { + showReplyForm = false; + submitInProgress = false; + } + }} /> +
+ {/if} +
+{/snippet} + +
+
+ 1 ? root.edits.at(-1) : undefined} + author={root.author} + reactions={root.reactions} + timestamp={root.edits.slice(-1)[0].timestamp} + body={root.edits.slice(-1)[0].body} + embeds={embedsOf(root)} + editComment={canModifyComment(root.author.did) && + editComment?.bind(null, root.id)} + reactOnComment={reactOnComment?.bind(null, root.id)} + deleteComment={canModifyComment(root.author.did) + ? deleteComment?.bind(null, root.id) + : undefined}> + {#snippet actions()} + {#if changeCommentStatus && canResolve} + + + changeCommentStatus(root.id, !root.resolved)} /> + + {/if} + {#if createReply} + + + + {/if} + {/snippet} + +
+ {#if replies.length > 0 || (createReply && showReplyForm)} +
+ {@render repliesSnippet()} +
+ {/if} +
diff --git a/desktop/src/components/TooltipBadge.svelte b/desktop/src/components/TooltipBadge.svelte new file mode 100644 index 000000000..4afbacd3b --- /dev/null +++ b/desktop/src/components/TooltipBadge.svelte @@ -0,0 +1,49 @@ + + + + + + + {#snippet toggle()} + {@render badge()} + {/snippet} + {#snippet popover()} + {tooltip} + {/snippet} + + diff --git a/desktop/src/components/Topbar.svelte b/desktop/src/components/Topbar.svelte new file mode 100644 index 000000000..ca7a31c53 --- /dev/null +++ b/desktop/src/components/Topbar.svelte @@ -0,0 +1,27 @@ + + + + +
+ {@render children()} +
diff --git a/desktop/src/components/Tree.svelte b/desktop/src/components/Tree.svelte new file mode 100644 index 000000000..08435b20f --- /dev/null +++ b/desktop/src/components/Tree.svelte @@ -0,0 +1,68 @@ + + + entry.path} + estimatedItemSize={28}> + {#snippet row(entry)} + {#if entry.kind === "tree"} + + {:else} + onSelect(entry.path)} + active={entry.path === currentPath} /> + {/if} + {/snippet} + diff --git a/desktop/src/components/UntrustedWarning.svelte b/desktop/src/components/UntrustedWarning.svelte new file mode 100644 index 000000000..b3efcbb9c --- /dev/null +++ b/desktop/src/components/UntrustedWarning.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/desktop/src/components/UpdateSwitch.svelte b/desktop/src/components/UpdateSwitch.svelte new file mode 100644 index 000000000..60c52c5ba --- /dev/null +++ b/desktop/src/components/UpdateSwitch.svelte @@ -0,0 +1,28 @@ + + + + +
+ + + +
diff --git a/desktop/src/components/UserAvatar.svelte b/desktop/src/components/UserAvatar.svelte new file mode 100644 index 000000000..e928d77bc --- /dev/null +++ b/desktop/src/components/UserAvatar.svelte @@ -0,0 +1,26 @@ + + +{#if dataUri} + Avatar +{/if} diff --git a/desktop/src/components/VirtualList.svelte b/desktop/src/components/VirtualList.svelte new file mode 100644 index 000000000..2d56d3d01 --- /dev/null +++ b/desktop/src/components/VirtualList.svelte @@ -0,0 +1,221 @@ + + +{#if viewport} +
+ + {#snippet children(item: T, index: number)} + {@render row(item, index)} + {/snippet} + +{/if} diff --git a/desktop/src/components/VisibilityBadge.svelte b/desktop/src/components/VisibilityBadge.svelte new file mode 100644 index 000000000..ae99cc474 --- /dev/null +++ b/desktop/src/components/VisibilityBadge.svelte @@ -0,0 +1,87 @@ + + + + + + {#if !seeded} + + Local + {:else} + + {capitalize(type)} + {#if type === "public" && seeds !== undefined} + {seeds} + {/if} + {/if} + diff --git a/desktop/src/components/YouBadge.svelte b/desktop/src/components/YouBadge.svelte new file mode 100644 index 000000000..ec137dda3 --- /dev/null +++ b/desktop/src/components/YouBadge.svelte @@ -0,0 +1,26 @@ + + + + + + {#snippet badge()} + You + {/snippet} + diff --git a/desktop/src/components/commentAnnotationState.svelte.ts b/desktop/src/components/commentAnnotationState.svelte.ts new file mode 100644 index 000000000..779cff679 --- /dev/null +++ b/desktop/src/components/commentAnnotationState.svelte.ts @@ -0,0 +1,33 @@ +import type { CodeComments } from "@app/lib/codeComments"; +import type { LineAnnotation } from "@app/lib/pierreComments"; + +// Reactive state for a single `CommentAnnotation`. One instance is created per +// annotation slot Pierre renders and mutated in place as the diff changes, so +// the threads inside it update without remounting (see `PierreDiff.svelte`). +export class CommentAnnotationState { + annotation = $state.raw(undefined); + comments = $state.raw(undefined); + // The commit a new comment is anchored against — the head of what is on + // screen. + commit = $state(undefined); + // What has been typed into the composer. Held by the owner too, so scrolling + // the file out of the diff's render window — which unmounts this component — + // does not throw the draft away. + composerBody = $state(""); + // A thread singled out from elsewhere — the review sidebar scrolling to one — + // so it can say which comment you arrived at. Pushed in on every slot sync, so + // a comment that mounts after the scroll still picks it up. + highlightedCommentId = $state(undefined); + + // Not `$state`: read only at call time. Set per annotation in `PierreDiff`. + onComposerInput: (body: string) => void = () => { + // Replaced per annotation. + }; + onCloseComposer: () => void = () => { + // Replaced per annotation. + }; + // Which thread the pointer is over, so the lines it refers to can be tinted. + onHoverThread: (threadId: string | undefined) => void = () => { + // Replaced per annotation. + }; +} diff --git a/desktop/src/components/diffFileHeaderState.svelte.ts b/desktop/src/components/diffFileHeaderState.svelte.ts new file mode 100644 index 000000000..66a187a6d --- /dev/null +++ b/desktop/src/components/diffFileHeaderState.svelte.ts @@ -0,0 +1,32 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; + +export type FileStatus = "added" | "deleted" | "modified" | "moved" | "copied"; +export type FileNote = "binary" | "empty"; + +// Reactive state for a single `DiffFileHeader`. One instance is created per +// pooled CodeView item element and mutated in place as the element is recycled +// for different files while scrolling (see `PierreDiff.svelte`), so the header +// updates without remounting. +export class DiffFileHeaderState { + fileDiff = $state.raw(undefined); + status = $state(undefined); + note = $state(undefined); + collapsed = $state(false); + // `undefined` hides the actions menu. + text = $state<(() => Promise) | undefined>(undefined); + // `undefined` hides the reviewed toggle, which only exists while a draft + // review is open. + reviewed = $state(undefined); + // Code-comment threads anchored in this file. Only threads that can be + // resolved are counted, so a file never shows permanently unresolved work + // (see `CodeComments.canResolveComment`). + resolvedComments = $state(0); + unresolvedComments = $state(0); + // Not `$state`: read only at click time. Set per file in `syncHeaderSlots`. + onToggleCollapse: () => void = () => { + // Replaced per file. + }; + onToggleReviewed: () => void = () => { + // Replaced per file. + }; +} diff --git a/desktop/src/components/usePinnedDragReorder.svelte.ts b/desktop/src/components/usePinnedDragReorder.svelte.ts new file mode 100644 index 000000000..2580f59c6 --- /dev/null +++ b/desktop/src/components/usePinnedDragReorder.svelte.ts @@ -0,0 +1,153 @@ +import type { RepoSummary } from "@bindings/repo/RepoSummary"; + +interface Options { + pinnedRepos: () => RepoSummary[]; + getOrder: () => string[]; + setOrder: (rids: string[]) => void; +} + +export const PINNED_LIST_CLASS = "pinned-list"; +export const DRAG_RID_ATTRIBUTE = "data-drag-rid"; + +const THRESHOLD_PX = 4; +const BODY_DRAGGING_CLASS = "dragging-pinned-repo"; + +export default function usePinnedDragReorder(options: Options) { + let dragStart: { rid: string; x: number; y: number } | undefined; + let suppressClick = false; + let draggingRid = $state(undefined); + let dropTargetRid = $state(undefined); + let dropPosition = $state<"before" | "after" | undefined>(undefined); + let ghostX = $state(0); + let ghostY = $state(0); + + const draggedRepo = $derived( + draggingRid !== undefined + ? options.pinnedRepos().find(r => r.rid === draggingRid) + : undefined, + ); + + $effect(() => { + if (draggingRid !== undefined) { + document.body.classList.add(BODY_DRAGGING_CLASS); + return () => document.body.classList.remove(BODY_DRAGGING_CLASS); + } + }); + + $effect(() => { + return () => { + window.removeEventListener("mousemove", onWindowMouseMove); + window.removeEventListener("mouseup", onWindowMouseUp); + }; + }); + + function onMouseDown(e: MouseEvent, rid: string) { + if (e.button !== 0) return; + const target = e.target as HTMLElement; + if (target.closest("button, .clipboard")) return; + suppressClick = false; + dragStart = { rid, x: e.clientX, y: e.clientY }; + window.addEventListener("mousemove", onWindowMouseMove); + window.addEventListener("mouseup", onWindowMouseUp); + } + + function onWindowMouseMove(e: MouseEvent) { + if (!dragStart) return; + if (!draggingRid) { + const dx = e.clientX - dragStart.x; + const dy = e.clientY - dragStart.y; + if (Math.hypot(dx, dy) < THRESHOLD_PX) return; + draggingRid = dragStart.rid; + } + ghostX = e.clientX; + ghostY = e.clientY; + updateDropTarget(e.clientX, e.clientY); + } + + function updateDropTarget(x: number, y: number) { + const el = document.elementFromPoint(x, y) as HTMLElement | null; + const list = el?.closest(`.${PINNED_LIST_CLASS}`) as HTMLElement | null; + if (!list) { + dropTargetRid = undefined; + dropPosition = undefined; + return; + } + let row = el?.closest(`[${DRAG_RID_ATTRIBUTE}]`) as HTMLElement | null; + if (!row) { + const rows = Array.from( + list.querySelectorAll(`[${DRAG_RID_ATTRIBUTE}]`), + ) as HTMLElement[]; + let nearestDistance = Infinity; + for (const r of rows) { + const rect = r.getBoundingClientRect(); + const distance = Math.abs(y - (rect.top + rect.height / 2)); + if (distance < nearestDistance) { + nearestDistance = distance; + row = r; + } + } + } + if (!row) return; + const rid = row.getAttribute(DRAG_RID_ATTRIBUTE); + if (!rid) return; + const rect = row.getBoundingClientRect(); + dropTargetRid = rid; + dropPosition = y - rect.top < rect.height / 2 ? "before" : "after"; + } + + function onWindowMouseUp() { + window.removeEventListener("mousemove", onWindowMouseMove); + window.removeEventListener("mouseup", onWindowMouseUp); + + const dragging = draggingRid; + const target = dropTargetRid; + const position = dropPosition; + + if (dragging && target && position && dragging !== target) { + const without = options.getOrder().filter(r => r !== dragging); + const targetIdx = without.indexOf(target); + if (targetIdx !== -1) { + const insertAt = position === "after" ? targetIdx + 1 : targetIdx; + without.splice(insertAt, 0, dragging); + options.setOrder(without); + } + } + + suppressClick = dragging !== undefined; + dragStart = undefined; + draggingRid = undefined; + dropTargetRid = undefined; + dropPosition = undefined; + } + + function onClick(e: MouseEvent) { + if (suppressClick) { + e.preventDefault(); + e.stopPropagation(); + suppressClick = false; + } + } + + return { + get draggingRid() { + return draggingRid; + }, + get dropTargetRid() { + return dropTargetRid; + }, + get dropPosition() { + return dropPosition; + }, + get draggedRepo() { + return draggedRepo; + }, + get ghostX() { + return ghostX; + }, + get ghostY() { + return ghostY; + }, + onMouseDown, + onClick, + }; +} diff --git a/desktop/src/global.d.ts b/desktop/src/global.d.ts new file mode 100644 index 000000000..be2c7fe71 --- /dev/null +++ b/desktop/src/global.d.ts @@ -0,0 +1,10 @@ +declare global { + interface Window { + // eslint-disable-next-line @typescript-eslint/naming-convention + __TAURI_INTERNALS__: Record; + // eslint-disable-next-line @typescript-eslint/naming-convention + __TEST_HTTP_API_PORT__?: number; + } +} + +export {}; diff --git a/desktop/src/lib/appearance.svelte.ts b/desktop/src/lib/appearance.svelte.ts new file mode 100644 index 000000000..8d3e6171e --- /dev/null +++ b/desktop/src/lib/appearance.svelte.ts @@ -0,0 +1,35 @@ +export const fontSettings = $state({ size: loadFontSize() }); +const step = 2; +const minFontSize = 14; +const maxFontSize = 24; + +export function increaseFontSize() { + if (fontSettings.size + step <= maxFontSize) { + setFontSize(fontSettings.size + step); + } +} + +export function decreaseFontSize() { + if (fontSettings.size - step >= minFontSize) { + setFontSize(fontSettings.size - step); + } +} + +export function resetFontSize() { + setFontSize(16); +} + +function loadFontSize(): number { + const storedFontSize = localStorage ? localStorage.getItem("fontSize") : "16"; + + if (storedFontSize === null) { + return 16; + } else { + return parseInt(storedFontSize); + } +} + +function setFontSize(size: number) { + fontSettings.size = size; + localStorage.setItem("fontSize", size.toString()); +} diff --git a/desktop/src/lib/artifactPickers.ts b/desktop/src/lib/artifactPickers.ts new file mode 100644 index 000000000..ab963f84b --- /dev/null +++ b/desktop/src/lib/artifactPickers.ts @@ -0,0 +1,15 @@ +import { invoke } from "@app/lib/invoke"; + +export async function pickFiles(): Promise { + return invoke("pick_artifact_files"); +} + +export async function pickFolder(): Promise { + return (await invoke("pick_artifact_directory")) ?? undefined; +} + +export async function pickLike( + directory: boolean, +): Promise { + return directory ? pickFolder() : (await pickFiles())[0]; +} diff --git a/desktop/src/lib/asciidoc.ts b/desktop/src/lib/asciidoc.ts new file mode 100644 index 000000000..44b8ffc5d --- /dev/null +++ b/desktop/src/lib/asciidoc.ts @@ -0,0 +1,203 @@ +import type { Blob } from "@bindings/source/Blob"; + +import dompurify from "dompurify"; + +import { invoke } from "@app/lib/invoke"; + +// AsciiDoc output needs a wider allowlist than markdown's, and `setConfig` +// on the shared DOMPurify singleton would leak into every other consumer of +// it, so it gets its own instance. +const purify = dompurify(window); + +purify.setConfig({ + /* eslint-disable @typescript-eslint/naming-convention */ + ALLOWED_ATTR: [ + "alt", + "class", + "colspan", + "data-lang", + "href", + "id", + "rel", + "rowspan", + "scope", + "src", + "start", + "target", + "title", + "type", + "width", + ], + ALLOWED_TAGS: [ + "a", + "b", + "blockquote", + "br", + "caption", + "cite", + "code", + "col", + "colgroup", + "dd", + "del", + "div", + "dl", + "dt", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "i", + "img", + "kbd", + "li", + "mark", + "ol", + "p", + "pre", + "q", + "span", + "strong", + "sub", + "sup", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "u", + "ul", + ], + /* eslint-enable @typescript-eslint/naming-convention */ +}); + +export interface AsciidocSource { + rid: string; + sha: string; + path: string; +} + +// Asciidoctor resolves include targets against `base_dir` and reads them with +// the Fetch API. Pointing `base_dir` at a synthetic origin and registering a +// cache for it turns every include into a repository blob lookup instead of a +// network request, while leaving the tag, line-range and nesting handling to +// Asciidoctor itself. +const includeOrigin = "https://include.radicle.invalid"; + +// The repository a conversion may read from, keyed by a token that only that +// conversion's `base_dir` carries. Keeping the repository out of the URL means +// a `..` target cannot reach a different repository or commit by rewriting the +// path: escaping past the token just drops the entry, and the read is refused. +const includeScopes = new Map< + string, + { source: AsciidocSource; reads: number } +>(); + +// Asciidoctor neither deduplicates includes nor bounds their total, so a file +// that includes itself twice would issue reads exponential in the include +// depth. Every read past this budget is refused. +export const maxIncludeReads = 256; + +async function readInclude(uri: string): Promise { + let url: URL; + try { + url = new URL(uri); + } catch { + return new Response(undefined, { status: 400 }); + } + + if (url.origin !== includeOrigin) { + return new Response(undefined, { status: 403 }); + } + + const [, token, ...rest] = url.pathname.split("/").map(decodeURIComponent); + const scope = includeScopes.get(token); + const path = rest.join("/"); + if (!scope || !path) { + return new Response(undefined, { status: 403 }); + } + if (++scope.reads > maxIncludeReads) { + return new Response(undefined, { status: 429 }); + } + + try { + const blob = await invoke("repo_blob", { + rid: scope.source.rid, + sha: scope.source.sha, + path, + }); + if (blob.binary) { + return new Response(undefined, { status: 415 }); + } + + return new Response(blob.content); + } catch { + return new Response(undefined, { status: 404 }); + } +} + +let asciidoctor: Promise | undefined; + +function loadAsciidoctor() { + if (!asciidoctor) { + asciidoctor = import("@asciidoctor/core").then(module => { + const cache = new module.HttpCache(); + cache.read = readInclude; + module.HttpCacheManager.setCache(cache); + // Asciidoctor reports problems such as unresolved includes inline in + // the rendered document, so logging them to the console as well only + // adds noise. + module.LoggerManager.setLogger(module.NullLogger.create()); + + return module; + }); + } + + return asciidoctor; +} + +export async function renderAsciidoc( + content: string, + source: AsciidocSource, +): Promise { + const { convert } = await loadAsciidoctor(); + + const token = crypto.randomUUID(); + const dir = source.path.split("/").slice(0, -1).join("/"); + includeScopes.set(token, { source, reads: 0 }); + + let html; + try { + html = await convert(content, { + // `server` keeps docinfo and absolute paths disabled while allowing + // includes; every read they perform goes through `readInclude`. + safe: "server", + standalone: false, + /* eslint-disable @typescript-eslint/naming-convention */ + to_file: false, + base_dir: [includeOrigin, token, dir].filter(Boolean).join("/"), + attributes: { + showtitle: true, + "allow-uri-read": "", + "cache-uri": "", + // Cross references to other documents link to their source files, + // which is what the app shows, rather than to rendered `.html` pages. + relfilesuffix: ".adoc", + }, + /* eslint-enable @typescript-eslint/naming-convention */ + }); + } finally { + includeScopes.delete(token); + } + + if (typeof html !== "string") { + throw new Error("Asciidoctor did not return a converted document"); + } + + return purify.sanitize(html); +} diff --git a/desktop/src/lib/assigneeSuggestions.ts b/desktop/src/lib/assigneeSuggestions.ts new file mode 100644 index 000000000..f3c77e3f5 --- /dev/null +++ b/desktop/src/lib/assigneeSuggestions.ts @@ -0,0 +1,71 @@ +import type { AliasSuggestion } from "@bindings/cob/AliasSuggestion"; +import type { Author } from "@bindings/cob/Author"; + +export interface AssigneeSuggestion { + did: string; + alias?: string; + badge?: "delegate" | "following" | "you"; +} + +export function matchesAssignee( + query: string, + { did, alias }: { did: string; alias?: string }, +): boolean { + const needle = query.trim().toLowerCase(); + const key = needle.replace(/^did:key:/, ""); + + return ( + did + .replace(/^did:key:/, "") + .toLowerCase() + .startsWith(key) || + (alias?.toLowerCase().includes(needle) ?? false) + ); +} + +export function rankAssigneeSuggestions({ + query, + aliases, + delegates, + assignees, + limit, +}: { + query: string; + aliases: AliasSuggestion[]; + delegates: Author[]; + assignees: Author[]; + limit: number; +}): AssigneeSuggestion[] { + const assigned = new Set(assignees.map(({ did }) => did)); + const seen = new Set(); + + const matchingDelegates = delegates.filter(delegate => + matchesAssignee(query, delegate), + ); + + const rows = [...matchingDelegates, ...aliases].flatMap(author => { + if (assigned.has(author.did) || seen.has(author.did)) return []; + seen.add(author.did); + const match = aliases.find(({ did }) => did === author.did); + const badge: AssigneeSuggestion["badge"] = match?.isSelf + ? "you" + : delegates.some(({ did }) => did === author.did) + ? "delegate" + : match?.followed + ? "following" + : undefined; + + return [{ did: author.did, alias: author.alias, badge }]; + }); + + const others = rows.filter( + row => row.badge !== "you" && row.badge !== "delegate", + ); + + return [ + ...rows.filter(row => row.badge === "you"), + ...rows.filter(row => row.badge === "delegate"), + ...others.filter(row => row.alias !== undefined), + ...others.filter(row => row.alias === undefined), + ].slice(0, limit); +} diff --git a/desktop/src/lib/auth.svelte.ts b/desktop/src/lib/auth.svelte.ts new file mode 100644 index 000000000..84e3a9f27 --- /dev/null +++ b/desktop/src/lib/auth.svelte.ts @@ -0,0 +1,41 @@ +import type { ErrorWrapper } from "@bindings/error/ErrorWrapper"; + +import { get } from "svelte/store"; + +import { deepLinksReady } from "@app/lib/deepLink"; +import { dynamicInterval } from "@app/lib/interval"; +import { invoke } from "@app/lib/invoke"; +import * as router from "@app/lib/router"; + +export const startup = $state<{ error?: ErrorWrapper }>({ error: undefined }); + +let lock = false; + +export async function checkAuth() { + try { + if (lock) { + return; + } + lock = true; + await invoke("authenticate", { passphrase: "" }); + dynamicInterval( + "auth", + checkAuth, + import.meta.env.VITE_AUTH_LONG_DELAY || 30_000, + ); + if (get(router.activeRouteStore).resource === "booting") { + void router.push({ resource: "inbox" }).finally(deepLinksReady); + } else { + deepLinksReady(); + } + } catch (err) { + const error = err as ErrorWrapper; + startup.error = error; + if (get(router.activeRouteStore).resource !== "booting") { + void router.push({ resource: "booting" }); + } + dynamicInterval("auth", checkAuth, 5_000); + } finally { + lock = false; + } +} diff --git a/desktop/src/lib/avatar.ts b/desktop/src/lib/avatar.ts new file mode 100644 index 000000000..8953a22ff --- /dev/null +++ b/desktop/src/lib/avatar.ts @@ -0,0 +1,932 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { cached } from "@app/lib/cached"; + +type AtomType = "A" | "B" | "C" | "D"; +type ShapeModel = + "rose" | "starburst" | "ringed" | "tip" | "notched" | "hollow"; +type AtomMode = "bands-ABC" | "angle-stripes" | "parity-ACB" | "balanced-rand"; + +const PALETTE = [ + "#00D4DA", // teal + "#886BF2", // purple + "#FFA5FF", // pink + "#009F67", // green + "#CCFF38", // lime + "#585600", // olive +]; + +const REPO_CONFIG = { + GRID_SIZE: 16, + CELL_SIZE: 32, + PIXEL_DENSITY: 2, + GLYPH: { + WIDTH: 5, + HEIGHT: 7, + SPACING: 2, + SCALE_2X_WIDTH: 10, + SCALE_2X_HEIGHT: 14, + }, + ATOMS: { + CIRCLE_B_RATIO: 0.55, + CIRCLE_C_RATIO: 0.67, + }, +} as const; + +const USER_CONFIG = { + TILE_SIZE: 32, + DEFAULT_GRID: 10, + PIXEL_DENSITY: 2, + ATOMS: { + ELLIPSE_B_SIZE: 17, + ELLIPSE_C_SIZE: 21, + }, + TOLERANCE: { + ANGLE_NEAR_BASE: Math.PI / 28, + ANGLE_FAR_BASE: Math.PI / 7, + NEAR_RANGE: { MIN: 0.7, MAX: 1.2 }, + FAR_RANGE: { MIN: 0.7, MAX: 1.2 }, + }, + SOFTNESS: { + MIN: 1.2, + MAX: 4.2, + }, + RING_PHASE: { + MIN: 0.2, + MAX: 0.8, + }, +} as const; + +function hash32(str: string): number { + let h = 2166136261 >>> 0; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +function xmur3(str: string): () => number { + let h = 1779033703 ^ str.length; + for (let i = 0; i < str.length; i++) { + h = Math.imul(h ^ str.charCodeAt(i), 3432918353); + h = (h << 13) | (h >>> 19); + } + return function () { + h = Math.imul(h ^ (h >>> 16), 2246822507); + h = Math.imul(h ^ (h >>> 13), 3266489909); + return (h ^= h >>> 16) >>> 0; + }; +} + +function mulberry32(a: number): () => number { + return function () { + let t = (a += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function makeRNG(key: string): () => number { + return mulberry32(xmur3(key)()); +} + +function chooseK(arr: T[], k: number, rnd: () => number): T[] { + const pool = arr.slice(); + const out = []; + for (let i = 0; i < k; i++) { + const idx = Math.floor(rnd() * pool.length); + out.push(pool.splice(idx, 1)[0]); + } + return out; +} + +function pick(rng: () => number, arr: T[]): T { + return arr[Math.floor(rng() * arr.length)]; +} + +function createOffscreenCanvas( + w: number, + h: number, + density: number = 2, +): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } { + const canvas = document.createElement("canvas"); + canvas.width = w * density; + canvas.height = h * density; + const ctx = canvas.getContext("2d")!; + ctx.scale(density, density); + ctx.imageSmoothingEnabled = false; + return { canvas, ctx }; +} + +function fillCircle( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + diameter: number, + color: string, +): void { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(x, y, diameter / 2, 0, Math.PI * 2); + ctx.fill(); +} + +function fillEllipse( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + color: string, +): void { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.ellipse(x, y, w / 2, h / 2, 0, 0, Math.PI * 2); + ctx.fill(); +} + +interface AtomDrawConfig { + cellSize: number; + circleB: number; + circleC: number; + useEllipse?: boolean; +} + +function createAtomRenderer(config: AtomDrawConfig) { + const drawCircleOrEllipse = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + color: string, + ) => { + if (config.useEllipse) { + fillEllipse(ctx, x, y, w, h, color); + } else { + fillCircle(ctx, x, y, w, color); // w is diameter for circles + } + }; + + return { + drawAtomA( + ctx: CanvasRenderingContext2D, + gx: number, + gy: number, + c1: string, + ): void { + const x = gx * config.cellSize; + const y = gy * config.cellSize; + ctx.fillStyle = c1; + ctx.fillRect(x, y, config.cellSize, config.cellSize); + }, + + drawAtomB( + ctx: CanvasRenderingContext2D, + gx: number, + gy: number, + c1: string, + c2: string, + ): void { + const x = gx * config.cellSize; + const y = gy * config.cellSize; + ctx.fillStyle = c1; + ctx.fillRect(x, y, config.cellSize, config.cellSize); + drawCircleOrEllipse( + ctx, + x + config.cellSize / 2, + y + config.cellSize / 2, + config.circleB, + config.circleB, + c2, + ); + }, + + drawAtomC( + ctx: CanvasRenderingContext2D, + gx: number, + gy: number, + c2: string, + c3: string, + ): void { + const x = gx * config.cellSize; + const y = gy * config.cellSize; + ctx.fillStyle = c2; + ctx.fillRect(x, y, config.cellSize, config.cellSize); + drawCircleOrEllipse( + ctx, + x + config.cellSize / 2, + y + config.cellSize / 2, + config.circleC, + config.circleC, + c3, + ); + }, + + drawAtomD( + ctx: CanvasRenderingContext2D, + gx: number, + gy: number, + c3: string, + ): void { + const x = gx * config.cellSize; + const y = gy * config.cellSize; + ctx.fillStyle = c3; + ctx.fillRect(x, y, config.cellSize, config.cellSize); + }, + + drawAtom( + ctx: CanvasRenderingContext2D, + atom: AtomType, + gx: number, + gy: number, + c1: string, + c2: string, + c3: string, + ): void { + switch (atom) { + case "A": + this.drawAtomA(ctx, gx, gy, c1); + break; + case "B": + this.drawAtomB(ctx, gx, gy, c1, c2); + break; + case "C": + this.drawAtomC(ctx, gx, gy, c2, c3); + break; + case "D": + this.drawAtomD(ctx, gx, gy, c3); + break; + } + }, + }; +} + +// 5x7 pixel font glyphs (cached at module level for performance) +function createGlyphs5x7(): Record { + const L: Record = {}; + const r = (s: string[]) => + s.map(row => row.split("").map(ch => (ch === "1" ? 1 : 0))); + L["A"] = r(["01110", "10001", "10001", "11111", "10001", "10001", "10001"]); + L["B"] = r(["11110", "10001", "10001", "11110", "10001", "10001", "11110"]); + L["C"] = r(["01111", "10000", "10000", "10000", "10000", "10000", "01111"]); + L["D"] = r(["11110", "10001", "10001", "10001", "10001", "10001", "11110"]); + L["E"] = r(["11111", "10000", "10000", "11110", "10000", "10000", "11111"]); + L["F"] = r(["11111", "10000", "10000", "11110", "10000", "10000", "10000"]); + L["G"] = r(["01111", "10000", "10000", "10111", "10001", "10001", "01111"]); + L["H"] = r(["10001", "10001", "10001", "11111", "10001", "10001", "10001"]); + L["I"] = r(["11111", "00100", "00100", "00100", "00100", "00100", "11111"]); + L["J"] = r(["11111", "00001", "00001", "00001", "10001", "10001", "01110"]); + L["K"] = r(["10001", "10010", "10100", "11000", "10100", "10010", "10001"]); + L["L"] = r(["10000", "10000", "10000", "10000", "10000", "10000", "11111"]); + L["M"] = r(["10001", "11011", "10101", "10001", "10001", "10001", "10001"]); + L["N"] = r(["10001", "11001", "10101", "10011", "10001", "10001", "10001"]); + L["O"] = r(["01110", "10001", "10001", "10001", "10001", "10001", "01110"]); + L["P"] = r(["11110", "10001", "10001", "11110", "10000", "10000", "10000"]); + L["Q"] = r(["01110", "10001", "10001", "10001", "10101", "10010", "01101"]); + L["R"] = r(["11110", "10001", "10001", "11110", "10100", "10010", "10001"]); + L["S"] = r(["01111", "10000", "11110", "00001", "00001", "10001", "11110"]); + L["T"] = r(["11111", "00100", "00100", "00100", "00100", "00100", "00100"]); + L["U"] = r(["10001", "10001", "10001", "10001", "10001", "10001", "01110"]); + L["V"] = r(["10001", "10001", "10001", "01010", "01010", "00100", "00100"]); + L["W"] = r(["10001", "10001", "10001", "10101", "10101", "11011", "10001"]); + L["X"] = r(["10001", "01010", "00100", "00100", "00100", "01010", "10001"]); + L["Y"] = r(["10001", "01010", "00100", "00100", "00100", "00100", "00100"]); + L["Z"] = r(["11111", "00001", "00010", "00100", "01000", "10000", "11111"]); + L["0"] = r(["01110", "10001", "10011", "10101", "11001", "10001", "01110"]); + L["1"] = r(["00100", "01100", "00100", "00100", "00100", "00100", "01110"]); + L["2"] = r(["01110", "10001", "00001", "00110", "01000", "10000", "11111"]); + L["3"] = r(["11110", "00001", "01110", "00001", "00001", "00001", "11110"]); + L["4"] = r(["10010", "10010", "10010", "11111", "00010", "00010", "00010"]); + L["5"] = r(["11111", "10000", "11110", "00001", "00001", "00001", "11110"]); + L["6"] = r(["01110", "10000", "11110", "10001", "10001", "10001", "01110"]); + L["7"] = r(["11111", "00001", "00010", "00100", "01000", "01000", "01000"]); + L["8"] = r(["01110", "10001", "01110", "10001", "10001", "10001", "01110"]); + L["9"] = r(["01110", "10001", "10001", "01111", "00001", "00001", "11110"]); + L["?"] = r(["11111", "00001", "01110", "00000", "00100", "00000", "00100"]); + return L; +} + +const LETTER_5X7 = createGlyphs5x7(); + +function getInitials(name: string): string[] { + if (!name || typeof name !== "string") return ["?"]; + const cleaned = name.trim().replace(/\s+/g, " "); + const parts = cleaned.split(/[^A-Za-z0-9]+/).filter(Boolean); + const first = parts[0] ? parts[0][0].toUpperCase() : "?"; + const second = parts[1] ? parts[1][0].toUpperCase() : null; + return second ? [first, second] : [first]; +} + +function polarFromCell( + gx: number, + gy: number, + cx: number, + cy: number, +): { r: number; a: number } { + const x = gx - cx + 0.5; + const y = gy - cy + 0.5; + const r = Math.hypot(x, y); + let a = Math.atan2(y, x); + if (a < 0) a += 2 * Math.PI; + return { r, a }; +} + +function shapeRose(theta: number, petals: number, tol: number): boolean { + const sector = Math.PI / petals; + const nearest = Math.round(theta / sector) * sector; + let diff = Math.abs(theta - nearest); + diff = Math.min(diff, 2 * Math.PI - diff); + return diff <= tol; +} + +function shapeStarburst( + theta: number, + petals: number, + softness: number, +): boolean { + const period = (2 * Math.PI) / petals; + const local = theta % period; + const d = Math.min(local, period - local) / (period / 2); + const response = Math.pow(Math.cos((d * Math.PI) / 2), softness); + return response > 0.5; +} + +function shapeRinged( + theta: number, + petals: number, + ringPhase: number, + tol: number, +): boolean { + const sector = (2 * Math.PI) / petals; + const k = Math.floor(theta / sector); + const center = k * sector + sector * ringPhase; + let diff = Math.abs(theta - center); + diff = Math.min(diff, 2 * Math.PI - diff); + return diff <= tol; +} + +function shapeTip( + theta: number, + petals: number, + tol: number, + t: number, +): boolean { + return shapeRose(theta, petals, tol * (0.5 + 1.0 * t)) && t > 0.45; +} + +function shapeNotched( + theta: number, + petals: number, + tol: number, + notchDepth: number = 0.25, +): boolean { + const sector = (2 * Math.PI) / petals; + const local = (theta % sector) / sector; + const notch = Math.abs(local - 0.5); + return shapeRose(theta, petals, tol) && notch > notchDepth; +} + +function shapeHollow( + theta: number, + petals: number, + tol: number, + t: number, + inner: number = 0.28, + outer: number = 0.9, +): boolean { + return shapeRose(theta, petals, tol) && t > inner && t < outer; +} + +function sectorGate(theta: number, petals: number, mask: boolean[]): boolean { + const sector = (2 * Math.PI) / petals; + const k = Math.floor(theta / sector); + return mask[k % mask.length]; +} + +export const cachedRepoAvatar = cached( + async (key: string) => renderRepoAvatar(key), + (key: string) => key, + { max: 2000 }, +); + +export const cachedUserAvatar = cached( + async (key: string) => renderUserAvatar(key), + (key: string) => key, + { max: 2000 }, +); + +function renderRepoAvatar(key: string): string { + if (typeof window === "undefined") { + return ""; + } + + { + // Color logic: + // A: square = Color1 + // B: square = Color1, circle = Color2 + // C: square = Color2, circle = Color3 + // D: square = Color3 + // Letters: solid-only (A or D). Background: other three atoms. + // Single initial: 2x2 expansion (10x14). Two initials: 5x7 each. + + const GRID = REPO_CONFIG.GRID_SIZE; + const CELL = REPO_CONFIG.CELL_SIZE; + const W = GRID * CELL; + const H = GRID * CELL; + + const { canvas, ctx } = createOffscreenCanvas( + W, + H, + REPO_CONFIG.PIXEL_DENSITY, + ); + + const atoms = createAtomRenderer({ + cellSize: CELL, + circleB: CELL * REPO_CONFIG.ATOMS.CIRCLE_B_RATIO, + circleC: CELL * REPO_CONFIG.ATOMS.CIRCLE_C_RATIO, + useEllipse: false, + }); + + function renderInitialsAvatar(nameKey: string = "color bright") { + const initials = getInitials(nameKey); + const seed = hash32(nameKey.toLowerCase()); + const rnd = mulberry32(seed); + + // Select three distinct colors deterministically (Color1, Color2, Color3) + const [c1, c2, c3] = chooseK(PALETTE, 3, rnd); + + // Choose letter solid atom: 'A' or 'D' (deterministic) + const letterSolidAtom: AtomType = ((seed >>> 7) & 1) === 0 ? "A" : "D"; + + const bgAtoms: AtomType[] = (["A", "B", "C", "D"] as AtomType[]).filter( + a => a !== letterSolidAtom, + ); + + function pickBgAtom(gx: number, gy: number): AtomType { + const k = (gy * 131 + gx * 197 + seed) >>> 0; + return bgAtoms[k % bgAtoms.length]; + } + + // 1) Background: fill with bgAtoms using strict color mapping + for (let gy = 0; gy < GRID; gy++) { + for (let gx = 0; gx < GRID; gx++) { + const atom = pickBgAtom(gx, gy); + atoms.drawAtom(ctx, atom, gx, gy, c1, c2, c3); + } + } + + // 2) Letters: solid-only atom across glyph pixels, strict mapping (A uses c1, D uses c3) + const glyphW = REPO_CONFIG.GLYPH.WIDTH; + const glyphH = REPO_CONFIG.GLYPH.HEIGHT; + + function placeSolidLetter( + glyph: number[][], + startX: number, + startY: number, + scale2x: boolean, + ) { + if (scale2x) { + // 2x2 expansion → 10x14 + for (let r = 0; r < glyphH; r++) { + for (let c = 0; c < glyphW; c++) { + if (!glyph[r][c]) continue; + const gx = startX + c * 2; + const gy = startY + r * 2; + if (letterSolidAtom === "A") { + atoms.drawAtomA(ctx, gx, gy, c1); + atoms.drawAtomA(ctx, gx + 1, gy, c1); + atoms.drawAtomA(ctx, gx, gy + 1, c1); + atoms.drawAtomA(ctx, gx + 1, gy + 1, c1); + } else { + atoms.drawAtomD(ctx, gx, gy, c3); + atoms.drawAtomD(ctx, gx + 1, gy, c3); + atoms.drawAtomD(ctx, gx, gy + 1, c3); + atoms.drawAtomD(ctx, gx + 1, gy + 1, c3); + } + } + } + } else { + // 1x scale + for (let r = 0; r < glyphH; r++) { + for (let c = 0; c < glyphW; c++) { + if (!glyph[r][c]) continue; + const gx = startX + c; + const gy = startY + r; + if (letterSolidAtom === "A") { + atoms.drawAtomA(ctx, gx, gy, c1); + } else { + atoms.drawAtomD(ctx, gx, gy, c3); + } + } + } + } + } + + if (initials.length === 1) { + // Single initial: 2x2 expansion (10x14), centered + const glyph = LETTER_5X7[initials[0]] || LETTER_5X7["?"]; + const startX = Math.floor( + (GRID - REPO_CONFIG.GLYPH.SCALE_2X_WIDTH) / 2, + ); + const startY = Math.floor( + (GRID - REPO_CONFIG.GLYPH.SCALE_2X_HEIGHT) / 2, + ); + placeSolidLetter(glyph, startX, startY, true); + } else { + // Two initials: 5x7 each, side-by-side + const leftGlyph = LETTER_5X7[initials[0]] || LETTER_5X7["?"]; + const rightGlyph = LETTER_5X7[initials[1]] || LETTER_5X7["?"]; + const spacing = REPO_CONFIG.GLYPH.SPACING; + const totalW = REPO_CONFIG.GLYPH.WIDTH * 2 + spacing; + const totalH = REPO_CONFIG.GLYPH.HEIGHT; + const startX = Math.floor((GRID - totalW) / 2); + const startY = Math.floor((GRID - totalH) / 2); + placeSolidLetter(leftGlyph, startX, startY, false); + placeSolidLetter( + rightGlyph, + startX + REPO_CONFIG.GLYPH.WIDTH + spacing, + startY, + false, + ); + } + + return canvas.toDataURL(); + } + + return renderInitialsAvatar(key); + } +} + +function renderUserAvatar(key: string): string { + if (typeof window === "undefined") { + return ""; + } + + { + const TILE = USER_CONFIG.TILE_SIZE; + const DEFAULT_GRID = USER_CONFIG.DEFAULT_GRID; + + const atoms = createAtomRenderer({ + cellSize: TILE, + circleB: USER_CONFIG.ATOMS.ELLIPSE_B_SIZE, + circleC: USER_CONFIG.ATOMS.ELLIPSE_C_SIZE, + useEllipse: true, + }); + + // Edge-to-edge integer placement + function drawAt( + fn: (x: number, y: number) => void, + gx: number, + gy: number, + ): void { + fn(gx * TILE, gy * TILE); + } + + // Strict 4-way symmetry (quadrant mirroring) + function drawQuad( + fn: (x: number, y: number) => void, + gx: number, + gy: number, + grid: number, + ): void { + const N = grid - 1; + drawAt(fn, gx, gy); + drawAt(fn, N - gx, gy); + drawAt(fn, gx, N - gy); + drawAt(fn, N - gx, N - gy); + } + + // Draw by atom type (converts pixel coordinates to grid coordinates) + function drawAtomByType( + ctx: CanvasRenderingContext2D, + type: AtomType, + x: number, + y: number, + c1: string, + c2: string, + c3: string, + ): void { + const gx = x / TILE; + const gy = y / TILE; + atoms.drawAtom(ctx, type, gx, gy, c1, c2, c3); + } + + // Make assigner among active petal atoms (exclude background atom) + function makeAssigner( + mode: AtomMode, + activeAtoms: AtomType[], + ): (rCell: number, theta?: number, sectorIdx?: number) => AtomType { + if (mode === "bands-ABC") + return (rCell: number) => activeAtoms[rCell % 3]; + if (mode === "angle-stripes") + return (_rCell: number, _theta?: number, sectorIdx?: number) => + activeAtoms[(sectorIdx || 0) % activeAtoms.length]; + if (mode === "parity-ACB") + return (rCell: number) => activeAtoms[rCell % 2 ? 1 : 0]; + if (mode === "balanced-rand") + return (rCell: number, theta?: number, sectorIdx?: number) => { + const v = + (Math.sin( + (theta || 0) * 13.37 + rCell * 2.17 + (sectorIdx || 0) * 0.73, + ) + + 1) / + 2; + if (v < 0.33) return activeAtoms[0]; + if (v < 0.66) return activeAtoms[1]; + return activeAtoms[2]; + }; + return (rCell: number) => activeAtoms[rCell % 3]; + } + + function generateFlower( + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + key: string, + grid: number = DEFAULT_GRID, + ): void { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const rng = makeRNG(key); + + const picked = PALETTE.slice().sort(() => rng() - 0.5); + const [c1, c2, c3] = picked.slice(0, 3); + + const allAtoms: AtomType[] = ["A", "B", "C", "D"]; + const bgAtom: AtomType = pick(rng, allAtoms); + const petalAtoms: AtomType[] = allAtoms + .filter(a => a !== bgAtom) + .sort(() => rng() - 0.5); + + const cx = Math.floor(grid / 2), + cy = Math.floor(grid / 2); + const maxR = Math.min(cx, cy); + const petals = pick(rng, [5, 6, 7, 8, 9, 10]); + const petalDepth = Math.max(5, Math.floor(maxR * (0.6 + 0.35 * rng()))); + const radialThickness = pick(rng, [1, 2, 2, 3, 3]); + const shapeModel: ShapeModel = pick(rng, [ + "rose", + "starburst", + "ringed", + "tip", + "notched", + "hollow", + ]); + const atomMode: AtomMode = pick(rng, [ + "bands-ABC", + "angle-stripes", + "parity-ACB", + "balanced-rand", + ]); + const assignAtom = makeAssigner(atomMode, petalAtoms); + + // Angle tolerances + const angleTolNear = + USER_CONFIG.TOLERANCE.ANGLE_NEAR_BASE * + (USER_CONFIG.TOLERANCE.NEAR_RANGE.MIN + + rng() * + (USER_CONFIG.TOLERANCE.NEAR_RANGE.MAX - + USER_CONFIG.TOLERANCE.NEAR_RANGE.MIN)); + const angleTolFar = + USER_CONFIG.TOLERANCE.ANGLE_FAR_BASE * + (USER_CONFIG.TOLERANCE.FAR_RANGE.MIN + + rng() * + (USER_CONFIG.TOLERANCE.FAR_RANGE.MAX - + USER_CONFIG.TOLERANCE.FAR_RANGE.MIN)); + const softness = + USER_CONFIG.SOFTNESS.MIN + + rng() * (USER_CONFIG.SOFTNESS.MAX - USER_CONFIG.SOFTNESS.MIN); + const ringPhase = + USER_CONFIG.RING_PHASE.MIN + + rng() * (USER_CONFIG.RING_PHASE.MAX - USER_CONFIG.RING_PHASE.MIN); + + // Sector gating mask (~70% sectors on), ensures bold shapes + const sectorMask = Array.from({ length: petals }, () => rng() > 0.3); + if (sectorMask.every(v => !v)) sectorMask[Math.floor(petals / 2)] = true; + + const drawBgAtom = (gx: number, gy: number) => { + drawAt( + (x: number, y: number) => + drawAtomByType(ctx, bgAtom, x, y, c1, c2, c3), + gx, + gy, + ); + }; + + // 0) Base pass: paint every tile once with background atom to avoid gaps + function renderBasePass() { + for (let gy = 0; gy < grid; gy++) { + for (let gx = 0; gx < grid; gx++) { + drawBgAtom(gx, gy); + } + } + } + + // 1) Outer edge circumference (1–2 tiles thick) reinforced in background atom + function renderEdgeReinforcement() { + const edgeThickness = pick(rng, [1, 1, 2]); + for (let t = 0; t < edgeThickness; t++) { + for (let i = 0; i < grid; i++) { + drawBgAtom(i, t); + drawBgAtom(i, grid - 1 - t); + drawBgAtom(t, i); + drawBgAtom(grid - 1 - t, i); + } + } + } + + // 2) Background atom structural accents inside (deterministic) + // - mid ring (optional) and a few gated spokes to help define silhouette + function renderStructuralAccents() { + // Mid ring + if (rng() < 0.7) { + const midR = Math.floor(petalDepth * (0.5 + 0.2 * rng())); + for (let i = 0; i < grid; i++) { + const coords: [number, number][] = [ + [cx - midR, i], + [cx + midR, i], + [i, cy - midR], + [i, cy + midR], + ]; + coords.forEach(([gx, gy]) => { + if (gx >= 0 && gy >= 0 && gx < grid && gy < grid) { + drawQuad( + (x: number, y: number) => + drawAtomByType(ctx, bgAtom, x, y, c1, c2, c3), + gx, + gy, + grid, + ); + } + }); + } + } + + // Gated spokes + if (rng() < 0.6) { + const gateEvery = pick(rng, [2, 3, 4]); + for (let s = 0; s < petals; s++) { + if (s % gateEvery !== 0) continue; + const theta = s * ((2 * Math.PI) / petals); + for (let r = 1; r <= petalDepth; r++) { + const gx = Math.round(cx + r * Math.cos(theta)); + const gy = Math.round(cy + r * Math.sin(theta)); + if (gx >= 0 && gy >= 0 && gx < grid && gy < grid) { + drawQuad( + (x: number, y: number) => + drawAtomByType(ctx, bgAtom, x, y, c1, c2, c3), + gx, + gy, + grid, + ); + } + } + } + } + } + + // 3) Strong center cluster (guarantee coverage; seed form) + function renderCenterCluster() { + const centerCluster: [number, number, AtomType][] = [ + [0, 0, "D"], + [0, -1, petalAtoms[0]], + [1, 0, petalAtoms[1]], + [0, 1, petalAtoms[2]], + [-1, 0, petalAtoms[0]], + ]; + centerCluster.forEach(([dx, dy, t]: [number, number, AtomType]) => { + const gx = cx + dx, + gy = cy + dy; + if (gx < 0 || gy < 0 || gx >= grid || gy >= grid) return; + drawQuad( + (x: number, y: number) => drawAtomByType(ctx, t, x, y, c1, c2, c3), + gx, + gy, + grid, + ); + }); + } + + // 4) Petals (TL quadrant → mirror) + function renderPetals() { + const half = Math.ceil(grid / 2); + for (let gy = 0; gy < half; gy++) { + for (let gx = 0; gx < half; gx++) { + const { r, a } = polarFromCell(gx, gy, cx, cy); + const rCell = Math.floor(r); + if (rCell === 0 || rCell > petalDepth) continue; + + const t = rCell / petalDepth; + const tol = angleTolNear * (1 - t) + angleTolFar * t; + + const sectorIdx = Math.floor(a / ((2 * Math.PI) / petals)); + if (!sectorGate(a, petals, sectorMask)) continue; + + let inside: boolean; + if (shapeModel === "rose") inside = shapeRose(a, petals, tol); + else if (shapeModel === "starburst") + inside = shapeStarburst(a, petals, softness); + else if (shapeModel === "ringed") + inside = shapeRinged(a, petals, ringPhase, tol * 0.7); + else if (shapeModel === "tip") inside = shapeTip(a, petals, tol, t); + else if (shapeModel === "notched") + inside = shapeNotched(a, petals, tol, 0.24); + else inside = shapeHollow(a, petals, tol, t, 0.28, 0.92); + + if (!inside) continue; + + const type = assignAtom(rCell, a, sectorIdx); + + // Draw with radial thickness and 4-way mirroring + for (let dr = 0; dr < radialThickness; dr++) { + const x1 = gx + dr, + y1 = gy + dr; + const coords: [number, number][] = [ + [x1, y1], + [grid - 1 - x1, y1], + [x1, grid - 1 - y1], + [grid - 1 - x1, grid - 1 - y1], + ]; + coords.forEach(([ix, iy]: [number, number]) => { + if (ix < 0 || iy < 0 || ix >= grid || iy >= grid) return; + drawAt( + (x: number, y: number) => + drawAtomByType(ctx, type, x, y, c1, c2, c3), + ix, + iy, + ); + }); + } + } + } + } + + // 5) Ensure all three petal atoms appear at least once + function renderAccents() { + const accents: [number, number, AtomType][] = [ + [cx, cy - 2, petalAtoms[0]], + [cx + 1, cy, petalAtoms[1]], + [cx, cy + 2, petalAtoms[2]], + ]; + accents.forEach(([gx, gy, t]: [number, number, AtomType]) => { + if (gx < 0 || gy < 0 || gx >= grid || gy >= grid) return; + drawQuad( + (x: number, y: number) => drawAtomByType(ctx, t, x, y, c1, c2, c3), + gx, + gy, + grid, + ); + }); + } + + // 6) Final safety: ensure center 6×6 px is covered via 2×2 cluster + function renderFinalCluster() { + const cluster: [number, number, AtomType][] = [ + [0, 0, "D"], + [1, 0, petalAtoms[1]], + [0, 1, petalAtoms[2]], + [1, 1, petalAtoms[0]], + ]; + cluster.forEach(([dx, dy, t]: [number, number, AtomType]) => { + const gx = cx + dx, + gy = cy + dy; + if (gx < 0 || gy < 0 || gx >= grid || gy >= grid) return; + drawQuad( + (x: number, y: number) => drawAtomByType(ctx, t, x, y, c1, c2, c3), + gx, + gy, + grid, + ); + }); + } + + renderBasePass(); + renderEdgeReinforcement(); + renderStructuralAccents(); + renderCenterCluster(); + renderPetals(); + renderAccents(); + renderFinalCluster(); + } + + function drawFlowerForKey( + key: string, + grid: number = DEFAULT_GRID, + ): string { + const canvasPx = grid * TILE; + + const { canvas, ctx } = createOffscreenCanvas( + canvasPx, + canvasPx, + USER_CONFIG.PIXEL_DENSITY, + ); + + generateFlower(ctx, canvas, key, grid); + + return canvas.toDataURL(); + } + + return drawFlowerForKey(key, DEFAULT_GRID); + } +} diff --git a/desktop/src/lib/cached.ts b/desktop/src/lib/cached.ts new file mode 100644 index 000000000..6565b6f20 --- /dev/null +++ b/desktop/src/lib/cached.ts @@ -0,0 +1,40 @@ +import { LRUCache } from "lru-cache"; + +export interface Cached { + (...args: Args): Promise; + /** Drop all cached entries, forcing the next call to re-fetch. */ + clear(): void; +} + +export function cached( + f: (...args: Args) => Promise, + makeKey: (...args: Args) => string, + options?: LRUCache.Options }, unknown>, + missTtl?: number, +): Cached { + const cache = new LRUCache(options || { max: 500 }); + const fn = function (...args: Args): Promise { + const key = makeKey(...args); + const hit = cache.get(key); + if (hit !== undefined) return hit.promise; + + const promise = f(...args); + // Evict on rejection so the next caller can retry with a fresh request. + promise.then( + value => { + if (missTtl !== undefined && (value === null || value === undefined)) { + const entry = cache.get(key); + if (entry?.promise === promise) + cache.set(key, entry, { ttl: missTtl }); + } + }, + () => { + cache.delete(key); + }, + ); + cache.set(key, { promise }); + return promise; + } as Cached; + fn.clear = () => cache.clear(); + return fn; +} diff --git a/desktop/src/lib/checkRadicleCLI.svelte.ts b/desktop/src/lib/checkRadicleCLI.svelte.ts new file mode 100644 index 000000000..1ade4d9f2 --- /dev/null +++ b/desktop/src/lib/checkRadicleCLI.svelte.ts @@ -0,0 +1,29 @@ +import { dynamicInterval } from "@app/lib/interval"; +import { invoke } from "@app/lib/invoke"; + +let lock = false; + +let installed = $state(false); + +export const radicleInstalled = () => installed; + +export async function checkRadicleCLI() { + try { + if (lock) { + return; + } + lock = true; + await invoke("check_radicle_cli"); + dynamicInterval( + "checkRadicleCLI", + checkRadicleCLI, + import.meta.env.VITE_CHECK_RADICLE_LONG_DELAY || 30_000, + ); + installed = true; + } catch { + dynamicInterval("checkRadicleCLI", checkRadicleCLI, 1_000); + installed = false; + } finally { + lock = false; + } +} diff --git a/desktop/src/lib/ciJobs.ts b/desktop/src/lib/ciJobs.ts new file mode 100644 index 000000000..b6d7c8965 --- /dev/null +++ b/desktop/src/lib/ciJobs.ts @@ -0,0 +1,161 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Job } from "@bindings/repo/Job"; +import type { Run } from "@bindings/repo/Run"; + +import { safeHttpUrl } from "@app/lib/utils"; + +// How the CI status chip groups and summarises a commit's job runs. + +export type Status = Run["status"]; + +export type RunView = { + run: Run; + label: string; + safeLog: string | undefined; +}; + +export type HostGroup = { + host: string; + runs: RunView[]; +}; + +export type Counts = { succeeded: number; failed: number; started: number }; + +export type NodeGroup = { + nodeKey: string; + author: Author; + hosts: HostGroup[]; + flatRuns?: RunView[]; + inlineHost?: string; + counts: Counts; + status: Status; +}; + +function parseUrl(s: string): URL | undefined { + try { + return new URL(s); + } catch { + return undefined; + } +} + +export function runLabel(run: Run, url: URL | undefined): string { + if (url && url.host === "github.com") { + const m = url.pathname.match(/\/actions\/runs\/(\d+)/); + if (m) return m[1]; + } + return run.runId.slice(0, 8); +} + +export function isTerminal(status: Status): boolean { + return status === "succeeded" || status === "failed"; +} + +export function aggregateStatus(c: Counts): Status { + if (c.failed) return "failed"; + if (c.started) return "started"; + return "succeeded"; +} + +export function statusLabel(c: Counts): string { + const parts: string[] = []; + if (c.succeeded) parts.push(`${c.succeeded} passed`); + if (c.failed) parts.push(`${c.failed} failed`); + if (c.started) parts.push(`${c.started} running`); + return parts.join(" · "); +} + +export function totalCounts(groups: NodeGroup[]): Counts { + const total: Counts = { succeeded: 0, failed: 0, started: 0 }; + for (const g of groups) { + total.succeeded += g.counts.succeeded; + total.failed += g.counts.failed; + total.started += g.counts.started; + } + return total; +} + +export function groupJobs(jobs: Job[]): NodeGroup[] { + const byNode: Record< + string, + { + author: Author; + byHost: Record; + seen: Map; + } + > = {}; + const nodeOrder: string[] = []; + for (const job of jobs) { + for (const run of job.runs) { + const url = parseUrl(run.log); + const host = url && url.host ? url.host : "(unknown host)"; + const safeLog = + url?.host !== "no.url.example.com" ? safeHttpUrl(run.log) : undefined; + const view: RunView = { + run, + label: runLabel(run, url), + safeLog, + }; + const key = run.node.did; + let entry = byNode[key]; + if (!entry) { + entry = { author: run.node, byHost: {}, seen: new Map() }; + byNode[key] = entry; + nodeOrder.push(key); + } + const existing = entry.seen.get(run.runId); + if (existing) { + // The same run can appear under multiple job COBs. Keep one view + // per runId, but let a terminal status override a stale "started" + // one when COBs polled at different times disagree. We mutate the + // existing view in place because it is the same object referenced + // from its host bucket below. + if (!isTerminal(existing.run.status) && isTerminal(run.status)) { + existing.run = run; + existing.label = view.label; + existing.safeLog = view.safeLog; + } + continue; + } + entry.seen.set(run.runId, view); + const bucket = entry.byHost[host] ?? (entry.byHost[host] = []); + bucket.push(view); + } + } + return nodeOrder.map(nodeKey => { + const { author, byHost } = byNode[nodeKey]; + const hosts: HostGroup[] = Object.entries(byHost).map(([host, runs]) => ({ + host, + runs, + })); + const counts: Counts = { succeeded: 0, failed: 0, started: 0 }; + for (const { runs } of hosts) { + for (const v of runs) counts[v.run.status]++; + } + const group: NodeGroup = { + nodeKey, + author, + hosts, + counts, + status: aggregateStatus(counts), + }; + if (hosts.length === 1) { + group.flatRuns = hosts[0].runs; + if (!aliasMatchesHost(author.alias, hosts[0].host)) { + group.inlineHost = hosts[0].host; + } + } + return group; + }); +} + +export function aliasMatchesHost( + alias: string | undefined, + host: string, +): boolean { + if (!alias) return false; + const a = alias.toLowerCase(); + if (host === a) return true; + if (host.endsWith("." + a)) return true; + return host.split(".").includes(a); +} diff --git a/desktop/src/lib/cobActivity.ts b/desktop/src/lib/cobActivity.ts new file mode 100644 index 000000000..9d72691d6 --- /dev/null +++ b/desktop/src/lib/cobActivity.ts @@ -0,0 +1,105 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Operation } from "@bindings/cob/Operation"; + +export interface ActivityItem { + key: string; + timestamp: number; + data: T; + /// Renders as a card or a filled band rather than a bare row of text. + /// Grouped rows are packed tight against each other, which reads as one + /// element when the things being packed have their own edges, so these are + /// never folded into a run. + standalone?: boolean; +} + +/// One action of an operation, with the operation's metadata and the previous +/// action of the same type, which the timeline diffs against. +export type FlattenedAction = A & { + id: string; + author: Author; + timestamp: number; + previous?: A; +}; + +/// What was added and removed between two lists. Items are compared by `key`, +/// since objects such as assignees are never the same instance twice. +export function itemDiff( + previous: T[], + next: T[], + key: (item: T) => unknown = item => item, +) { + const previousKeys = new Set(previous.map(key)); + const nextKeys = new Set(next.map(key)); + return { + removed: previous.filter(x => !nextKeys.has(key(x))), + added: next.filter(x => !previousKeys.has(key(x))), + }; +} + +function labelsOf(action: { type: string }): string[] | undefined { + if (action.type !== "label" || !("labels" in action)) return undefined; + return action.labels as string[]; +} + +/// Turn a COB's operations into one timeline entry per action, dropping the +/// actions that would render nothing: +/// +/// - types in `skip`, which the timeline shows elsewhere or not at all; +/// - the first `edit`, since there is nothing to diff it against; +/// - a `label` action that neither adds nor removes a label. +/// +/// Skipped actions still count as the previous action of their type. +/// `onSkipped` sees each action in `skip` and `onKept` each entry as it is +/// made, both in operation order, so a caller can fold edits into the entries +/// they belong to. +export function flattenActivity( + activity: Operation[], + options: { + skip: ReadonlySet; + onSkipped?: (action: A) => void; + onKept?: (op: FlattenedAction) => void; + }, +): ActivityItem>[] { + const previousByType = new Map(); + const items: ActivityItem>[] = []; + + for (const operation of activity) { + operation.actions.forEach((action, actionIndex) => { + const previous = previousByType.get(action.type); + previousByType.set(action.type, action); + + if (options.skip.has(action.type)) { + options.onSkipped?.(action); + return; + } + if (action.type === "edit" && !previous) { + return; + } + const labels = labelsOf(action); + if (labels) { + const { added, removed } = itemDiff( + (previous && labelsOf(previous)) ?? [], + labels, + ); + if (added.length === 0 && removed.length === 0) { + return; + } + } + + const op: FlattenedAction = { + ...action, + id: operation.id, + author: operation.author, + timestamp: operation.timestamp, + previous, + }; + options.onKept?.(op); + items.push({ + key: `${operation.id}:${actionIndex}`, + timestamp: operation.timestamp, + data: op, + }); + }); + } + return items; +} diff --git a/desktop/src/lib/cobSearch.ts b/desktop/src/lib/cobSearch.ts new file mode 100644 index 000000000..57f968eaa --- /dev/null +++ b/desktop/src/lib/cobSearch.ts @@ -0,0 +1,32 @@ +import type { Author } from "@bindings/cob/Author"; + +import fuzzysort from "fuzzysort"; + +interface Searchable { + id: string; + title: string; + labels: string[]; + assignees: Author[]; + author: Author; +} + +export function searchCobs( + query: string, + cobs: T[], +): T[] { + const index = cobs.map(cob => ({ + cob, + id: cob.id, + title: cob.title, + labels: cob.labels.join(" "), + assignees: cob.assignees.map(a => a.alias ?? "").join(" "), + author: cob.author.alias ?? "", + })); + return fuzzysort + .go(query, index, { + keys: ["title", "labels", "assignees", "author", "id"], + threshold: 0.5, + all: true, + }) + .map(result => result.obj.cob); +} diff --git a/desktop/src/lib/codeBlocks.ts b/desktop/src/lib/codeBlocks.ts new file mode 100644 index 000000000..c58827a8c --- /dev/null +++ b/desktop/src/lib/codeBlocks.ts @@ -0,0 +1,35 @@ +import { toDom } from "hast-util-to-dom"; + +import { highlight } from "@app/lib/syntax"; + +const languagePrefix = "language-"; + +/** + * Adds a copy button to every `pre code` block in `container` and replaces + * the blocks that name a language with highlighted code in the background. + */ +export function enhanceCodeBlocks(container: HTMLElement) { + for (const node of container.querySelectorAll("pre code")) { + const preElement = node.parentElement as HTMLElement; + const copyButton = document.createElement("radicle-clipboard"); + copyButton.setAttribute("text", node.textContent || ""); + const preWrapper = document.createElement("div"); + preWrapper.classList.add("pre-wrapper"); + preElement.parentNode?.insertBefore(preWrapper, preElement); + preWrapper.appendChild(preElement); + preWrapper.appendChild(copyButton); + + const className = Array.from(node.classList).find(name => + name.startsWith(languagePrefix), + ); + if (!className) continue; + + highlight(node.textContent ?? "", className.slice(languagePrefix.length)) + .then(tree => { + if (tree) { + node.replaceChildren(toDom(tree, { fragment: true })); + } + }) + .catch(e => console.warn("Not able to highlight code block", e)); + } +} diff --git a/desktop/src/lib/codeCommentActions.ts b/desktop/src/lib/codeCommentActions.ts new file mode 100644 index 000000000..4c95e2988 --- /dev/null +++ b/desktop/src/lib/codeCommentActions.ts @@ -0,0 +1,150 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Embed } from "@bindings/cob/thread/Embed"; + +import { draftReviewStorage } from "@app/lib/draftReviewStorage"; +import { invoke } from "@app/lib/invoke"; +import { publicKeyFromDid } from "@app/lib/utils"; + +// Which object a code comment hangs off. Editing, deleting, resolving and +// reacting all dispatch on this, because the protocol action differs per kind. +export type CommentOwner = + | { kind: "draft"; draftId: string } + | { kind: "review"; reviewId: string } + | { kind: "revision"; revisionId: string }; + +export interface CommentActionsContext { + rid: string; + patchId: string; + publicKey: string; + announce: boolean; + // Returns undefined for a comment this view doesn't know about, in which + // case the action is skipped rather than sent with a guessed target. + ownerOf: (commentId: string) => CommentOwner | undefined; + reload: () => Promise; +} + +export interface CommentActions { + editComment: ( + commentId: string, + body: string, + embeds: Embed[], + ) => Promise; + deleteComment: (commentId: string) => Promise; + changeCommentStatus: (commentId: string, resolved: boolean) => Promise; + reactOnComment: ( + commentId: string, + authors: Author[], + reaction: string, + ) => Promise; +} + +export function commentActions(ctx: CommentActionsContext): CommentActions { + const { rid, patchId, publicKey, announce, ownerOf, reload } = ctx; + const opts = { announce }; + + async function run( + label: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + action: any, + ): Promise { + try { + await invoke("edit_patch", { rid, cobId: patchId, action, opts }); + } catch (error) { + console.error(`${label} failed`, error); + } finally { + await reload(); + } + } + + return { + async editComment(commentId, body, embeds) { + const owner = ownerOf(commentId); + if (!owner) return; + if (owner.kind === "draft") { + draftReviewStorage.updateComment(owner.draftId, commentId, { body }); + await reload(); + return; + } + await run( + "Editing comment", + owner.kind === "review" + ? { + type: "review.comment.edit", + review: owner.reviewId, + comment: commentId, + body, + embeds, + } + : { + type: "revision.comment.edit", + revision: owner.revisionId, + comment: commentId, + body, + embeds, + }, + ); + }, + + async deleteComment(commentId) { + const owner = ownerOf(commentId); + if (!owner) return; + if (owner.kind === "draft") { + draftReviewStorage.deleteComment(owner.draftId, commentId); + await reload(); + return; + } + await run( + "Deleting comment", + owner.kind === "review" + ? { + type: "review.comment.redact", + review: owner.reviewId, + comment: commentId, + } + : { + type: "revision.comment.redact", + revision: owner.revisionId, + comment: commentId, + }, + ); + }, + + async changeCommentStatus(commentId, resolved) { + // Only review comments carry a resolved state; drafts and standalone + // revision comments have no review to attach the change to. + const owner = ownerOf(commentId); + if (owner?.kind !== "review") return; + await run("Changing comment status", { + type: resolved ? "review.comment.resolve" : "review.comment.unresolve", + review: owner.reviewId, + comment: commentId, + }); + }, + + async reactOnComment(commentId, authors, reaction) { + const owner = ownerOf(commentId); + if (!owner || owner.kind === "draft") return; + const active = !authors.find( + ({ did }) => publicKeyFromDid(did) === publicKey, + ); + await run( + "Reacting on comment", + owner.kind === "review" + ? { + type: "review.comment.react", + review: owner.reviewId, + comment: commentId, + reaction, + active, + } + : { + type: "revision.comment.react", + revision: owner.revisionId, + comment: commentId, + reaction, + active, + }, + ); + }, + }; +} diff --git a/desktop/src/lib/codeComments.ts b/desktop/src/lib/codeComments.ts new file mode 100644 index 000000000..40d03364c --- /dev/null +++ b/desktop/src/lib/codeComments.ts @@ -0,0 +1,79 @@ +import type { Author } from "@bindings/cob/Author"; +import type { CodeLocation } from "@bindings/cob/thread/CodeLocation"; +import type { Embed } from "@bindings/cob/thread/Embed"; +import type { Thread } from "@bindings/cob/thread/Thread"; +import type { Config } from "@bindings/config/Config"; + +import type { Resolution } from "@app/lib/commentResolutions"; + +/// Says where a comment lives when a view mixes comments from several places — +/// a review's own comments and comments left directly on a revision look alike +/// otherwise, but they differ in what can be done to them. +export interface CommentOrigin { + text: string; + title?: string; + onclick?: () => void; +} + +/// Everything a diff renderer needs to superimpose code comments on the changes +/// and act on them. The host resolves which object each comment hangs off (a +/// draft review, a published review, or the revision itself) and which of them +/// the reader chose to see, so the renderer only places what it is handed. +export interface CodeComments { + changeCommentStatus?: (commentId: string, resolved: boolean) => Promise; + // Whether this thread can be resolved and the current user may do it. + // Decided by the host: the protocol allows the comment author, the review + // author or the revision author, and only the host knows all three. + canResolveComment?: (commentId: string) => boolean; + // Who resolved a comment, for the resolved badge to name. Only the host can + // say: it comes from the patch's operation log rather than from the comment + // (see `resolutionsByComment`). + resolvedBy?: (commentId: string) => Resolution | undefined; + config: Config; + createComment: ( + body: string, + embeds: Embed[], + replyTo?: string, + location?: CodeLocation, + ) => Promise; + // Defaults to "Comment". + newCommentCaption?: string; + newCommentDescription?: string; + // When provided, the new-code-comment composer shows a second submit option + // in the split-button dropdown that posts a `revision.comment` directly + // (no review wrapping). The primary `createComment` continues to take + // whatever path the host wires (typically: stash into a draft review). + addCodeCommentDirect?: ( + body: string, + embeds: Embed[], + location: CodeLocation, + ) => Promise; + addCodeCommentDirectCaption?: string; + addCodeCommentDirectDescription?: string; + editComment: ( + commentId: string, + body: string, + embeds: Embed[], + ) => Promise; + reactOnComment?: ( + commentId: string, + authors: Author[], + reaction: string, + ) => Promise; + deleteComment?: (commentId: string) => Promise; + // Defaults to `true`. + canReply?: boolean; + // See `ExtendedTextarea`. + disableAttachments?: boolean | string; + // For hosts whose surrounding context already shows the file path. + hideThreadFileHeader?: boolean; + // Unpublished draft roots, which render alongside published threads and so + // have to be told apart. + draftThreadIds?: string[]; + // Root comment id -> where that thread lives, so comments merged from + // several sources stay distinguishable in one diff. + threadOrigins?: Record; + repoDelegates: Author[]; + rid: string; + threads: Thread[]; +} diff --git a/desktop/src/lib/commentNavigation.ts b/desktop/src/lib/commentNavigation.ts new file mode 100644 index 000000000..680eea293 --- /dev/null +++ b/desktop/src/lib/commentNavigation.ts @@ -0,0 +1,94 @@ +import type { FileStatus } from "@app/components/diffFileHeaderState.svelte"; +import type { CodeLocation } from "@bindings/cob/thread/CodeLocation"; +import type { Thread } from "@bindings/cob/thread/Thread"; + +import type { CommentAnchor } from "@app/lib/pierreComments"; +import { anchorOf, isCommentableStatus } from "@app/lib/pierreComments"; + +export interface OrderedComment { + id: string; + anchor: CommentAnchor; +} + +/// Every comment on a diff in the order it is rendered: by file, in the order +/// the files appear, then down the lines of each. A deletion renders above an +/// addition on the same line, and two comments on one line oldest first. +/// +/// Comments are left out for the same reasons the diff leaves them out: the file +/// isn't in this diff, or its content moved and an anchor in it is ambiguous. +export function orderComments( + threads: Thread[], + filePaths: string[], + statusOf: (path: string) => FileStatus | undefined, +): OrderedComment[] { + const fileOrder = new Map(filePaths.map((path, index) => [path, index])); + return threads + .flatMap(thread => { + const anchor = anchorOf(thread.root.location); + if (!anchor) return []; + const order = fileOrder.get(anchor.path); + if (order === undefined) return []; + if (!isCommentableStatus(statusOf(anchor.path))) return []; + return [ + { + id: thread.root.id, + anchor, + order, + side: anchor.side === "deletions" ? 0 : 1, + timestamp: thread.root.edits[0].timestamp, + }, + ]; + }) + .sort( + (a, b) => + a.order - b.order || + a.anchor.line - b.anchor.line || + a.side - b.side || + a.timestamp - b.timestamp, + ) + .map(({ id, anchor }) => ({ id, anchor })); +} + +/// The index a step of `delta` (`1` or `-1`) lands on in a list of `total`. +/// From nothing (`current` is `-1`), a step down starts at the first item and a +/// step up at the last. From somewhere, both wrap, so a walk never dead-ends. +export function stepIndex( + current: number, + total: number, + delta: number, +): number | undefined { + if (total === 0) return undefined; + const from = current >= 0 ? current : delta > 0 ? -1 : 0; + return (from + delta + total) % total; +} + +/// The commit a step of `delta` selects, clamped to the list. From no +/// selection, stepping back selects the last commit and forward the first. +export function stepCommitIndex( + current: number, + total: number, + delta: number, +): number { + const next = current === -1 ? (delta > 0 ? 0 : total - 1) : current + delta; + return Math.max(0, Math.min(next, total - 1)); +} + +/// File groups in diff order, each file's threads in line order. Groups for +/// files that aren't in the diff are dropped. +export function orderFileGroups( + groups: { path: string; threads: Thread[] }[], + filePaths: string[], +): { path: string; threads: Thread[] }[] { + const order = new Map(filePaths.map((path, index) => [path, index])); + return groups + .filter(group => order.has(group.path)) + .sort((a, b) => (order.get(a.path) ?? 0) - (order.get(b.path) ?? 0)) + .map(group => ({ + path: group.path, + threads: [...group.threads].sort( + (a, b) => + (anchorOf(a.root.location)?.line ?? 0) - + (anchorOf(b.root.location)?.line ?? 0), + ), + })); +} diff --git a/desktop/src/lib/commentResolutions.ts b/desktop/src/lib/commentResolutions.ts new file mode 100644 index 000000000..db233b203 --- /dev/null +++ b/desktop/src/lib/commentResolutions.ts @@ -0,0 +1,37 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Operation } from "@bindings/cob/Operation"; +import type { Action } from "@bindings/cob/patch/Action"; + +export interface Resolution { + author: Author; + timestamp: number; +} + +/// Who last resolved each comment, taken from the patch's own operation log. +/// +/// A `Comment` records only *that* it is resolved: `radicle`'s thread applies the +/// resolve op without keeping hold of its actor, so the comment itself cannot say +/// who. The op that did it is authored, though, so the log still can. +/// +/// Unresolving drops the entry again, which is what leaves a comment that was +/// resolved and then reopened without one — matching `resolved: false` on the +/// comment. A revision's `resolves` set is deliberately not read: it records what +/// a revision claims to address and never marks a comment resolved. +export function resolutionsByComment( + activity: Operation[], +): Map { + const resolutions = new Map(); + for (const op of [...activity].sort((a, b) => a.timestamp - b.timestamp)) { + for (const action of op.actions) { + if (action.type === "review.comment.resolve") { + resolutions.set(action.comment, { + author: op.author, + timestamp: op.timestamp, + }); + } else if (action.type === "review.comment.unresolve") { + resolutions.delete(action.comment); + } + } + } + return resolutions; +} diff --git a/desktop/src/lib/commentSources.ts b/desktop/src/lib/commentSources.ts new file mode 100644 index 000000000..043bb737e --- /dev/null +++ b/desktop/src/lib/commentSources.ts @@ -0,0 +1,51 @@ +import type { Revision } from "@bindings/cob/patch/Revision"; + +import { publicKeyFromDid } from "@app/lib/utils"; + +/// Identifies the bucket of code comments that belong to no review, so the +/// Changes tab can toggle them like it toggles a review. Not a review id, and +/// deliberately not shaped like one. +export const STANDALONE_COMMENTS = "standalone"; + +/// A togglable layer of code comments on the revision diff: one per review that +/// left any, plus a bucket for the comments that belong to no review. +export interface CommentSource { + id: string; + name: string; + count: number; + // A review has one author; the comments that belong to no review can have + // several, so the row stacks the first few avatars. + nids: string[]; +} + +export function commentSourcesOf(revision: Revision): CommentSource[] { + const sources: CommentSource[] = []; + for (const review of revision.reviews ?? []) { + const count = (review.comments ?? []).filter( + c => c.location && !c.replyTo, + ).length; + if (count === 0) continue; + sources.push({ + id: review.id, + name: + review.author.alias ?? + publicKeyFromDid(review.author.did).substring(0, 6), + count, + nids: [publicKeyFromDid(review.author.did)], + }); + } + // Standalone located comments are a category of their own: a code comment can + // be left on a revision without joining any review. + const standalone = (revision.discussion ?? []).filter( + c => c.location && !c.replyTo, + ); + if (standalone.length > 0) { + sources.push({ + id: STANDALONE_COMMENTS, + name: "Not part of a review", + count: standalone.length, + nids: [...new Set(standalone.map(c => publicKeyFromDid(c.author.did)))], + }); + } + return sources; +} diff --git a/desktop/src/lib/commitGroups.ts b/desktop/src/lib/commitGroups.ts new file mode 100644 index 000000000..e6e3e9ab2 --- /dev/null +++ b/desktop/src/lib/commitGroups.ts @@ -0,0 +1,82 @@ +import type { Commit } from "@bindings/repo/Commit"; + +export type CommitGroup = { + key: string; + label: string; + commits: Commit[]; +}; + +export type CommitRow = + | { type: "header"; key: string; label: string } + | { type: "commit"; commit: Commit; first: boolean; last: boolean }; + +/// The local calendar day of a timestamp in milliseconds, as `YYYY-MM-DD`. +export function dayKey(timestamp: number): string { + const date = new Date(timestamp); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + + return `${date.getFullYear()}-${month}-${day}`; +} + +/// "Today", "Yesterday", or the full date. +export function dayLabel(timestamp: number, now = Date.now()): string { + const today = new Date(now); + const yesterday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - 1, + ); + + if (dayKey(timestamp) === dayKey(today.getTime())) { + return "Today"; + } + if (dayKey(timestamp) === dayKey(yesterday.getTime())) { + return "Yesterday"; + } + + return new Intl.DateTimeFormat("en", { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }).format(new Date(timestamp)); +} + +/// Commits grouped by the day they were committed, in order of each day's first +/// commit. +export function groupCommitsByDay( + commits: Commit[], + now = Date.now(), +): CommitGroup[] { + const groups = new Map(); + for (const commit of commits) { + const timestamp = commit.committer.time * 1000; + const key = dayKey(timestamp); + const group = groups.get(key); + if (group) { + group.commits.push(commit); + } else { + groups.set(key, { + key, + label: dayLabel(timestamp, now), + commits: [commit], + }); + } + } + return [...groups.values()]; +} + +/// The groups as one stream of rows, so the list can be virtualized. `first` +/// and `last` mark a group's boundaries so its commits form a bordered card. +export function commitRowsOf(groups: CommitGroup[]): CommitRow[] { + return groups.flatMap(group => [ + { type: "header" as const, key: group.key, label: group.label }, + ...group.commits.map((commit, i) => ({ + type: "commit" as const, + commit, + first: i === 0, + last: i === group.commits.length - 1, + })), + ]); +} diff --git a/desktop/src/lib/debounced.svelte.ts b/desktop/src/lib/debounced.svelte.ts new file mode 100644 index 000000000..949b9915d --- /dev/null +++ b/desktop/src/lib/debounced.svelte.ts @@ -0,0 +1,18 @@ +export function debounced( + get: () => T, + ms: number, +): { readonly current: T } { + let current = $state(get()); + $effect(() => { + const value = get(); + const timer = setTimeout(() => { + current = value; + }, ms); + return () => clearTimeout(timer); + }); + return { + get current() { + return current; + }, + }; +} diff --git a/desktop/src/lib/deepLink.ts b/desktop/src/lib/deepLink.ts new file mode 100644 index 000000000..80c84d311 --- /dev/null +++ b/desktop/src/lib/deepLink.ts @@ -0,0 +1,148 @@ +import type { RepoRoute } from "@app/views/repo/router"; +import type { Config } from "@bindings/config/Config"; + +import { cachedConfig, cachedRepoById } from "@app/lib/invoke"; +import { referenceRoute } from "@app/lib/mentions"; +import { show } from "@app/lib/modal"; +import type { RadReference, RadUri } from "@app/lib/radUri"; +import { parseRadUri } from "@app/lib/radUri"; +import * as router from "@app/lib/router"; +import { explorerHost, explorerLink } from "@app/lib/utils"; + +import NotOnThisNode from "@app/modals/NotOnThisNode.svelte"; +import UnopenableLink from "@app/modals/UnopenableLink.svelte"; + +export type DeepLinkTarget = + | { type: "route"; route: RepoRoute } + | { type: "external"; url: string } + | { type: "missing"; rid: string; url?: string }; + +export function parseDeepLink( + link: string, +): { type: "uri"; uri: RadUri } | undefined { + // A RID copied as `rad:z…` is easily pasted after `rad:///`. + const uri = parseRadUri( + link.trim().replace(/^rad:(?:\/\/\/?)?rad:/i, "rad:"), + ); + + return uri && { type: "uri", uri }; +} + +export function deepLinkTarget( + reference: RadReference, + local: boolean, + config: Config, +): DeepLinkTarget | undefined { + const url = explorerLink(reference, config); + if (!local && reference.type === "uri") { + return { type: "missing", rid: `rad:${reference.uri.repo}`, url }; + } + + const route = referenceRoute(reference); + if (route) return { type: "route", route }; + + return url ? { type: "external", url } : undefined; +} + +// Links can arrive before the app has authenticated and routed, so they wait +// until it has, and are opened one at a time. +export function createLinkQueue(open: (link: string) => Promise) { + const pending: string[] = []; + let ready = false; + let flushing = false; + + async function flush() { + if (!ready || flushing) return; + flushing = true; + try { + for (let link = pending.shift(); link; link = pending.shift()) { + await open(link).catch(console.error); + } + } finally { + flushing = false; + } + } + + return { + receive(links: string[]) { + pending.push(...links); + void flush(); + }, + ready() { + ready = true; + void flush(); + }, + }; +} + +// An untrusted link only ever navigates; seeding a repo or leaving the app +// for the browser needs the user's confirmation. +async function openDeepLink(link: string) { + const reference = parseDeepLink(link); + if (!reference) { + show({ component: UnopenableLink, props: { link, reason: "invalid" } }); + return; + } + + const [config, local] = await Promise.all([ + cachedConfig(), + cachedRepoById(`rad:${reference.uri.repo}`).then( + repo => repo !== null, + () => false, + ), + ]); + const target = deepLinkTarget(reference, local, config); + if (target?.type === "route") { + await router.push(target.route); + } else if (target) { + show({ + component: NotOnThisNode, + props: { + url: target.url, + host: explorerHost(config), + rid: target.type === "missing" ? target.rid : undefined, + }, + }); + } else { + show({ component: UnopenableLink, props: { link, reason: "no-page" } }); + } +} + +const queue = createLinkQueue(openDeepLink); +const launchKey = "deepLink:launch"; + +export function deepLinksReady() { + queue.ready(); +} + +export async function listenForDeepLinks() { + if (!window.__TAURI_INTERNALS__) return; + + const { getCurrent, onOpenUrl } = + await import("@tauri-apps/plugin-deep-link"); + await onOpenUrl(links => queue.receive(links)); + + // A reload of the webview reports the same launch link again. + const launched = (await getCurrent()) ?? []; + const seen = readLaunch(); + if (launched.length > 0 && JSON.stringify(launched) !== seen) { + writeLaunch(JSON.stringify(launched)); + queue.receive(launched); + } +} + +function readLaunch(): string | null { + try { + return sessionStorage.getItem(launchKey); + } catch { + return null; + } +} + +function writeLaunch(value: string) { + try { + sessionStorage.setItem(launchKey, value); + } catch { + return; + } +} diff --git a/desktop/src/lib/diffOptions.svelte.ts b/desktop/src/lib/diffOptions.svelte.ts new file mode 100644 index 000000000..5cf1fdce5 --- /dev/null +++ b/desktop/src/lib/diffOptions.svelte.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +// User-configurable diff/view preferences, persisted to localStorage and shared +// across all commit views so they survive navigation between commits. +export const diffOptionsSchema = z.object({ + showTree: z.boolean(), + diffStyle: z.enum(["unified", "split"]), + wordWrap: z.boolean(), + indicators: z.enum(["classic", "bars", "none"]), + lineDiffType: z.enum(["word-alt", "word", "char", "none"]), +}); + +export type DiffOptions = z.infer; + +const KEY = "diffOptions"; + +const defaults: DiffOptions = { + showTree: true, + diffStyle: "unified", + wordWrap: false, + indicators: "bars", + lineDiffType: "word-alt", +}; + +function load(): DiffOptions { + const raw = globalThis.localStorage?.getItem(KEY); + if (!raw) { + return { ...defaults }; + } + try { + // Merge over defaults so a blob written by an older schema still loads. + const parsed = diffOptionsSchema.partial().safeParse(JSON.parse(raw)); + if (parsed.success) { + return { ...defaults, ...parsed.data }; + } + } catch { + // Ignore malformed storage. + } + return { ...defaults }; +} + +export const diffOptions = $state(load()); + +// Persist on any change. A detached root effect keeps this alive for the whole +// session (the store is a module singleton, so it is never torn down). +$effect.root(() => { + $effect(() => { + globalThis.localStorage?.setItem(KEY, JSON.stringify(diffOptions)); + }); +}); diff --git a/desktop/src/lib/diffText.ts b/desktop/src/lib/diffText.ts new file mode 100644 index 000000000..69910391e --- /dev/null +++ b/desktop/src/lib/diffText.ts @@ -0,0 +1,152 @@ +import type { + FileNote, + FileStatus, +} from "@app/components/diffFileHeaderState.svelte"; +import type { FileDiff } from "@bindings/diff/FileDiff"; +import type { Blob } from "@bindings/source/Blob"; +import type { GitStatusEntry } from "@pierre/trees"; + +import { isIgnoredPath } from "@app/lib/ignoredFiles"; +import { invoke } from "@app/lib/invoke"; + +// The repo-relative path identifying a file diff (the new side for renames +// and copies). +export function fileDiffPath(file: FileDiff): string { + return file.status === "moved" || file.status === "copied" + ? file.newPath + : file.path; +} + +/// How a file changed, worded for a chip beside its name. A plain modification +/// needs no chip — it is what a diff is by default. +export function fileStatusLabel(status: FileStatus): string | undefined { + switch (status) { + case "added": + return "Added"; + case "deleted": + return "Deleted"; + case "moved": + return "Moved"; + case "copied": + return "Copied"; + case "modified": + return undefined; + } +} + +/// The changed files in the shape `PierreTree` marks its rows with. A copy reads +/// as an addition, which is what it is on the new side; there is no "copied" for +/// the tree to show. +export function gitStatusEntries(files: FileDiff[]): GitStatusEntry[] { + return files.map((file): GitStatusEntry => { + const path = fileDiffPath(file); + switch (file.status) { + case "added": + return { path, status: "added" }; + case "deleted": + return { path, status: "deleted" }; + case "modified": + return { path, status: "modified" }; + case "moved": + return { path, status: "renamed" }; + case "copied": + return { path, status: "added" }; + } + }); +} + +export interface FileMeta { + // Files with no renderable text diff. Pierre has no binary concept and would + // render them as an empty body with a dead expand caret, so the header shows a + // note and drops the caret instead. + notes: Map; + statuses: Map; + // Lockfiles and generated manifests, which are noise in a review. + ignored: Set; +} + +/// The per-file information a `PierreDiff` header needs, keyed by the path that +/// identifies each file. Taken from the structured diff, which is still what +/// says whether a file is binary and how it changed — the patch text Pierre +/// renders from does not. +export function fileMetaOf(files: FileDiff[]): FileMeta { + const notes = new Map(); + const statuses = new Map(); + const ignored = new Set(); + for (const file of files) { + const path = fileDiffPath(file); + statuses.set(path, file.status); + if (file.diff.type === "binary") { + notes.set(path, "binary"); + } else if (file.diff.type === "empty") { + notes.set(path, "empty"); + } + if (isIgnoredPath(path)) { + ignored.add(path); + } + } + return { notes, statuses, ignored }; +} + +async function fetchBlob( + rid: string, + path: string, + sha: string, +): Promise { + const blob = await invoke("repo_blob", { rid, path, sha }); + return blob.binary ? "" : blob.content; +} + +/// Fetch a file's full old and new contents on demand, so Pierre's +/// context-expand markers can hydrate a file that was parsed from a patch and +/// therefore only holds the lines around each change. +/// +/// `base` is optional because a root commit has no parent to read the old side +/// from; added and deleted files only ever have one side either way. +export function fullFileLoader( + rid: string, + base: string | undefined, + head: string, + files: () => FileDiff[], +): (path: string) => Promise<{ oldContents: string; newContents: string }> { + return async (path: string) => { + const file = files().find(entry => fileDiffPath(entry) === path); + let oldContents = ""; + let newContents = ""; + if (!file) { + return { oldContents, newContents }; + } + const oldPath = + file.status === "moved" || file.status === "copied" + ? file.oldPath + : file.path; + const newPath = fileDiffPath(file); + const wantsOld = file.status !== "added" && base !== undefined; + const wantsNew = file.status !== "deleted"; + [oldContents, newContents] = await Promise.all([ + wantsOld && base ? fetchBlob(rid, oldPath, base) : "", + wantsNew ? fetchBlob(rid, newPath, head) : "", + ]); + return { oldContents, newContents }; + }; +} + +/// How many of the diff's files a reviewer has checked off. Checks left over +/// from files the diff no longer has don't count. +export function checkedFileProgress( + files: FileDiff[], + checkedFiles: string[], +): { filesChecked: number; filesTotal: number } { + const paths = new Set(files.map(fileDiffPath)); + return { + filesChecked: checkedFiles.filter(path => paths.has(path)).length, + filesTotal: files.length, + }; +} + +export function checkedPaths( + files: FileDiff[], + isChecked: (path: string) => boolean, +): Set { + return new Set(files.map(fileDiffPath).filter(isChecked)); +} diff --git a/desktop/src/lib/disableDropNavigation.ts b/desktop/src/lib/disableDropNavigation.ts new file mode 100644 index 000000000..7086bb7b3 --- /dev/null +++ b/desktop/src/lib/disableDropNavigation.ts @@ -0,0 +1,25 @@ +export function disableDropNavigation(): void { + const isNativeTextDrop = (event: DragEvent) => + !(event.dataTransfer?.types.includes("Files") ?? false) && + event.target instanceof Element && + event.target.closest( + 'input, textarea, [contenteditable]:not([contenteditable="false"])', + ) !== null; + + window.addEventListener("dragover", event => { + if ( + event.defaultPrevented || + isNativeTextDrop(event) || + !event.dataTransfer + ) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = "none"; + }); + window.addEventListener("drop", event => { + if (!isNativeTextDrop(event)) { + event.preventDefault(); + } + }); +} diff --git a/desktop/src/lib/disableNativeContextMenu.ts b/desktop/src/lib/disableNativeContextMenu.ts new file mode 100644 index 000000000..e684121b1 --- /dev/null +++ b/desktop/src/lib/disableNativeContextMenu.ts @@ -0,0 +1,17 @@ +export function disableNativeContextMenu(): void { + window.addEventListener("contextmenu", event => { + const selection = window.getSelection(); + if ( + selection && + !selection.isCollapsed && + selection.toString().length > 0 + ) { + return; + } + const target = event.target as HTMLElement | null; + if (target?.closest("input, textarea, [contenteditable]")) { + return; + } + event.preventDefault(); + }); +} diff --git a/desktop/src/lib/documentLinks.ts b/desktop/src/lib/documentLinks.ts new file mode 100644 index 000000000..cda5e5a43 --- /dev/null +++ b/desktop/src/lib/documentLinks.ts @@ -0,0 +1,115 @@ +import { show } from "@app/lib/modal"; +import { decodeComponent, resolveRepoTarget } from "@app/lib/repoImages"; + +import UnopenableLink from "@app/modals/UnopenableLink.svelte"; + +export type DocumentLink = + | { type: "fragment"; id: string } + // A path in the repository, undefined for its root. + | { type: "file"; path: string | undefined; fragment?: string } + | { type: "external" } + | { type: "unsupported" }; + +export type OpenFile = ( + path: string | undefined, + fragment: string | undefined, +) => void; + +// The schemes the shell plugin hands to the operating system when an anchor +// with `target="_blank"` is clicked. +const externalScheme = /^(?:https?|mailto|tel):/i; +const scheme = /^[a-z][a-z\d+.-]*:/i; + +/** + * Classify a link in the document at `from`. Links without a scheme are + * paths in the repository, as they are on code forges, so they never point + * at a page of the app. + */ +export function classifyDocumentLink(href: string, from: string): DocumentLink { + if (href === "" || href.startsWith("#")) { + return { type: "fragment", id: decodeComponent(href.slice(1)) }; + } + + if (scheme.test(href)) { + return externalScheme.test(href) + ? { type: "external" } + : { type: "unsupported" }; + } + + const target = resolveRepoTarget(href, from); + if (!target) { + return { type: "unsupported" }; + } + + const path = target.path.replace(/\/+$/, ""); + if (path === from && target.fragment !== undefined) { + return { type: "fragment", id: target.fragment }; + } + + return { type: "file", path: path || undefined, fragment: target.fragment }; +} + +// Markdown headings get lowercase ids, so a link written with the heading's +// own casing falls back to the lowercase id. +export function scrollToFragment(id: string) { + const target = + document.getElementById(id) ?? + document.getElementsByName(id)[0] ?? + document.getElementById(id.toLowerCase()); + target?.scrollIntoView(); +} + +// Scrolls once per location, so that re-rendering a document doesn't scroll +// it back. +export function scrollToLocationFragment( + scrolledAt: string | undefined, +): string { + const { href, hash } = window.location; + if (hash && href !== scrolledAt) { + scrollToFragment(decodeComponent(hash.slice(1))); + } + return href; +} + +export interface DocumentLinkOptions { + path: string; + openFile?: OpenFile; +} + +/** + * Make a link in a rendered document work inside the app. Following any link + * that isn't external would otherwise load another page into the window and + * restart the app. The document's options are read when the link is + * followed, as the same markup can be shown for another file. + */ +export function enhanceDocumentLink( + anchor: HTMLAnchorElement, + options: () => DocumentLinkOptions, +) { + const href = anchor.getAttribute("href"); + if (href === null) { + return; + } + + if (classifyDocumentLink(href, "").type === "external") { + anchor.target = "_blank"; + anchor.rel = "noopener noreferrer"; + return; + } + + anchor.onclick = event => { + event.preventDefault(); + const { path, openFile } = options(); + const link = classifyDocumentLink(href, path); + if (link.type === "fragment") { + scrollToFragment(link.id); + } else if (link.type === "file" && openFile) { + openFile(link.path, link.fragment); + } else { + show({ + component: UnopenableLink, + props: { link: href, reason: "unsupported" }, + }); + } + }; +} diff --git a/desktop/src/lib/draftReviewStorage.ts b/desktop/src/lib/draftReviewStorage.ts new file mode 100644 index 000000000..ff572426b --- /dev/null +++ b/desktop/src/lib/draftReviewStorage.ts @@ -0,0 +1,329 @@ +import type { Author } from "@bindings/cob/Author"; +import type { CreateReviewArgs } from "@bindings/cob/patch/CreateReviewArgs"; +import type { Patch } from "@bindings/cob/patch/Patch"; +import type { Verdict } from "@bindings/cob/patch/Verdict"; +import type { CodeLocation } from "@bindings/cob/thread/CodeLocation"; +import type { CodeRange } from "@bindings/cob/thread/CodeRange"; +import type { Comment } from "@bindings/cob/thread/Comment"; + +import { z } from "zod"; + +import useLocalStorage from "@app/lib/useLocalStorage.svelte"; +import { publicKeyFromDid } from "@app/lib/utils"; + +import { invoke } from "./invoke"; + +// This is different from the stored draft review to align it with a +// published `Review`. +export interface DraftReview { + id: string; + draft: true; + rid: string; + author: Author; + revisionId: string; + verdict?: Verdict; + summary?: string; + labels: string[]; + comments: Array>; + checkedFiles: string[]; +} + +const codeRangeSchema: z.Schema = z.union([ + z.object({ + type: z.literal("lines"), + range: z.object({ start: z.number(), end: z.number() }), + }), + z.object({ + type: z.literal("chars"), + line: z.number(), + range: z.object({ start: z.number(), end: z.number() }), + }), +]); + +const draftReviewStoredSchema = z.object({ + id: z.string(), + rid: z.string(), + // Node ID of the identity that wrote the draft. Optional so drafts stored + // before this field existed keep working; those are treated as belonging to + // whoever is signed in, which is how they behaved previously. + nid: z.string().optional(), + // Patch the revision belongs to. Only used to prune drafts whose revision has + // gone away, which can't be done from the revision id alone. Optional for the + // same backwards-compatibility reason. + patchId: z.string().optional(), + revision: z.string(), + verdict: z.union([z.literal("accept"), z.literal("reject")]).optional(), + summary: z.string().default(""), + labels: z.array(z.string()), + comments: z.array( + z.object({ + id: z.string(), + body: z.string(), + location: z + .object({ + commit: z.string(), + path: z.string(), + old: codeRangeSchema.nullable(), + new: codeRangeSchema.nullable(), + }) + .optional(), + }), + ), + checkedFiles: z.array(z.string()).default([]), +}); + +type DraftReviewStored = z.infer; + +const storage = useLocalStorage( + "repo.patches.draftReviews", + z.record(z.string(), draftReviewStoredSchema), + {}, +); + +function belongsTo(draft: DraftReviewStored, nid: string): boolean { + return draft.nid === undefined || draft.nid === nid; +} + +export const draftReviewStorage = { + get(id: string, author: Author): DraftReview | undefined { + const draftReviewStored = storage.value[id]; + if ( + !draftReviewStored || + !belongsTo(draftReviewStored, publicKeyFromDid(author.did)) + ) { + return undefined; + } + + return draftReviewFromStored(draftReviewStored, author); + }, + + getForRevision(revisionId: string, author: Author): DraftReview | undefined { + const nid = publicKeyFromDid(author.did); + const draftReviewStored = Object.values(storage.value).find( + draftReview => + draftReview.revision === revisionId && belongsTo(draftReview, nid), + ); + + if (draftReviewStored) { + return draftReviewFromStored(draftReviewStored, author); + } + }, + + create( + rid: string, + patchId: string, + revisionId: string, + nid: string, + ): string { + const id = crypto.randomUUID(); + storage.update(reviews => { + reviews[id] = { + id, + rid, + nid, + patchId, + revision: revisionId, + // Written down rather than defaulted when the bar reads the draft, so + // an absent verdict unambiguously means "comment only". + verdict: "accept", + summary: "", + labels: [], + comments: [], + checkedFiles: [], + }; + return reviews; + }); + return id; + }, + + isFileChecked(id: string, filePath: string): boolean { + return storage.value[id]?.checkedFiles?.includes(filePath) ?? false; + }, + + toggleCheckedFile(id: string, filePath: string) { + updateStoredDraftReview(id, review => { + const set = new Set(review.checkedFiles); + if (set.has(filePath)) { + set.delete(filePath); + } else { + set.add(filePath); + } + return { ...review, checkedFiles: [...set] }; + }); + }, + + hasForRevision(revisionId: string, nid: string): boolean { + return Object.values(storage.value).some( + review => review.revision === revisionId && belongsTo(review, nid), + ); + }, + + // Drop drafts whose revision is gone — redacted, or a patch deleted from this + // node. Drafts predating `patchId` are skipped: there is no way to tell which + // patch they belong to. + pruneStale(patchId: string, revisionIds: string[], nid: string) { + const live = new Set(revisionIds); + storage.update(reviews => { + for (const [id, review] of Object.entries(reviews)) { + if ( + review.patchId === patchId && + belongsTo(review, nid) && + !live.has(review.revision) + ) { + delete reviews[id]; + } + } + return reviews; + }); + }, + + update( + id: string, + props: { summary: string; verdict: Verdict | undefined; labels: string[] }, + ) { + updateStoredDraftReview(id, review => { + return Object.assign(review, props); + }); + }, + + delete(id: string): DraftReviewStored | undefined { + const review = storage.value[id]; + storage.update(reviews => { + delete reviews[id]; + return reviews; + }); + return review; + }, + + deleteComment(id: string, commentId: string) { + updateStoredDraftReview(id, review => { + // Guard the miss: `splice(-1, 1)` would drop the last comment instead of + // doing nothing. + const index = review.comments.findIndex( + comment => comment.id === commentId, + ); + if (index === -1) { + return review; + } + review.comments.splice(index, 1); + return review; + }); + }, + + addComment( + id: string, + comment: { + body: string; + location: CodeLocation; + }, + ): string { + const commentId = crypto.randomUUID(); + updateStoredDraftReview(id, review => { + review.comments.push({ + id: commentId, + body: comment.body, + location: comment.location, + }); + return review; + }); + return commentId; + }, + + updateComment( + id: string, + commentId: string, + comment: { + body: string; + }, + ) { + updateStoredDraftReview(id, review => { + const storedComment = review.comments.find( + comment => comment.id === commentId, + ); + + if (!storedComment) { + throw new Error( + `Comment ${commentId} does not exist for draft review ${id}`, + ); + } + + storedComment.body = comment.body; + return review; + }); + }, + + async publish(id: string) { + const draftReviewStored = storage.value[id]; + if (!draftReviewStored) { + throw new Error( + `Failed to publish draft review: Review ${id} does not exist`, + ); + } + + // Discard the draft only once the review is on the network. Deleting first + // loses every comment if the call fails, and leaves the bar unable to + // retry because the draft it is rendering no longer exists. + await invoke("create_patch_review", { + args: { + rid: draftReviewStored.rid, + revision: draftReviewStored.revision, + verdict: draftReviewStored.verdict ?? null, + summary: draftReviewStored.summary, + labels: draftReviewStored.labels, + comments: draftReviewStored.comments.map(storedComment => ({ + body: storedComment.body, + location: storedComment.location ?? null, + })), + } satisfies CreateReviewArgs, + }); + + draftReviewStorage.delete(id); + }, +}; + +function updateStoredDraftReview( + id: string, + update: (draftReviewStored: DraftReviewStored) => DraftReviewStored, +): void { + storage.update(reviews => { + const review = reviews[id]; + if (!review) { + throw new Error(`Draft review ${id} does not exist`); + } + + reviews[id] = update(review); + return reviews; + }); +} + +function draftReviewFromStored( + draftReviewStored: z.infer, + author: Author, +): DraftReview { + return { + id: draftReviewStored.id, + draft: true, + rid: draftReviewStored.rid, + summary: draftReviewStored.summary, + author, + revisionId: draftReviewStored.revision, + verdict: draftReviewStored.verdict, + labels: draftReviewStored.labels, + checkedFiles: draftReviewStored.checkedFiles, + comments: draftReviewStored.comments.map(rawComment => ({ + id: rawComment.id, + author, + edits: [ + { + author, + timestamp: 0, + body: rawComment.body, + }, + ], + reactions: [], + replyTo: null, + location: rawComment.location ?? null, + resolved: false, + })), + }; +} diff --git a/desktop/src/lib/embeds.ts b/desktop/src/lib/embeds.ts new file mode 100644 index 000000000..aade10274 --- /dev/null +++ b/desktop/src/lib/embeds.ts @@ -0,0 +1,70 @@ +export function basename(path: string): string { + const parts = path.split(/[\\/]/).filter(p => p.length > 0); + return parts[parts.length - 1] ?? path; +} + +export function embedPreviewKind( + mimeType: string | undefined, +): "image" | "document" | "video" | "audio" | undefined { + if (mimeType?.startsWith("image")) return "image"; + if (mimeType?.startsWith("application")) return "document"; + if (mimeType?.startsWith("video")) return "video"; + if (mimeType?.startsWith("audio")) return "audio"; + return undefined; +} + +const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +function chunkType(bytes: Uint8Array, offset: number): string { + return String.fromCharCode(...bytes.subarray(offset, offset + 4)); +} + +export function retinaWidth(bytes: Uint8Array): number | undefined { + if ( + bytes.length < 24 || + !pngSignature.every((byte, i) => bytes[i] === byte) || + chunkType(bytes, 12) !== "IHDR" + ) { + return undefined; + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const width = view.getUint32(16); + + let offset = 8; + while (offset + 8 <= bytes.length) { + const length = view.getUint32(offset); + const type = chunkType(bytes, offset + 4); + if (type === "pHYs" && length === 9 && offset + 17 <= bytes.length) { + const x = view.getUint32(offset + 8); + const y = view.getUint32(offset + 12); + const metre = bytes[offset + 16] === 1; + if (!metre || x !== y) return undefined; + const scale = Math.round((x * 0.0254) / 72); + return scale === 2 || scale === 3 ? Math.round(width / scale) : undefined; + } + if (type === "IDAT" || type === "IEND") return undefined; + offset += 12 + length; + } + return undefined; +} + +export function encodeEmbedUpload(name: string, bytes: Uint8Array): Uint8Array { + const encodedName = new TextEncoder().encode(name); + const body = new Uint8Array(encodedName.length + 1 + bytes.length); + body.set(encodedName); + body.set(bytes, encodedName.length + 1); + return body; +} + +export function decodeEmbed(buffer: ArrayBuffer): { + mimeType: string | undefined; + content: Uint8Array; +} { + const bytes = new Uint8Array(buffer); + const end = bytes.indexOf(0); + const mimeType = new TextDecoder().decode(bytes.subarray(0, end)); + return { + mimeType: mimeType === "" ? undefined : mimeType, + content: bytes.subarray(end + 1), + }; +} diff --git a/desktop/src/lib/emojis.ts b/desktop/src/lib/emojis.ts new file mode 100644 index 000000000..bbe51f598 --- /dev/null +++ b/desktop/src/lib/emojis.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +const emojis: { [key: string]: string } = { + 100: "💯", + question: "❓", + exclamation: "❗", + sunrise: "🌅", + rainbow: "🌈", + ocean: "🌊", + volcano: "🌋", + seedling: "🌱", + maple_leaf: "🍁", + wood: "🪵", + evergreen_tree: "🌲", + gift: "🎁", + santa: "🎅", + tada: "🎉", + art: "🎨", + dart: "🎯", + bug: "🐛", + wave: "👋", + ok_hand: "👌", + building_construction: "🏗️", + "+1": "👍", + thumbsup: "👍", + "-1": "👎", + thumbsdown: "👎", + clap: "👏", + open_hands: "👐", + ghost: "👻", + alien: "👽", + skull: "💀", + boom: "💥", + poop: "💩", + muscle: "💪", + mage: "🧙‍♀️", + bow: "🙇‍♂️", + see_no_evil: "🙈", + hear_no_evil: "🙉", + speak_no_evil: "🙊", + pray: "🙏", + rocket: "🚀", + construction: "🚧", + rotating_light: "🚨", + no_entry_sign: "🚫", + clown_face: "🤡", +}; + +export default emojis; diff --git a/desktop/src/lib/events.ts b/desktop/src/lib/events.ts new file mode 100644 index 000000000..5cc207eb4 --- /dev/null +++ b/desktop/src/lib/events.ts @@ -0,0 +1,6 @@ +import { writable } from "svelte/store"; + +export const nodeRunning = writable(false); +// Undefined until the first probe, so nothing reports the node as down before +// it has been asked. +export const artifactNodeRunning = writable(undefined); diff --git a/desktop/src/lib/fileTreeExpansion.ts b/desktop/src/lib/fileTreeExpansion.ts new file mode 100644 index 000000000..ea5c6a5e9 --- /dev/null +++ b/desktop/src/lib/fileTreeExpansion.ts @@ -0,0 +1,52 @@ +// Expansion state of the file tree's folders, keyed by folder prefix +// (`docs/`, `docs/guides/`). A folder stays open once it was opened by hand +// or held the open file, until it is collapsed by hand. Folders containing +// the open file are always expanded, unless collapsed since navigating to +// that file. `visit` counts navigations, so that coming back to the same file +// expands its folders again. +export interface FolderExpansion { + opened: Set; + collapsedOnVisit: Map; +} + +export interface Location { + path: string; + visit: number; +} + +function ancestorPrefixes(path: string): string[] { + const prefixes: string[] = []; + for (let i = path.indexOf("/"); i !== -1; i = path.indexOf("/", i + 1)) { + prefixes.push(path.slice(0, i + 1)); + } + return prefixes; +} + +export function openAncestors(state: FolderExpansion, path: string) { + for (const prefix of ancestorPrefixes(path)) { + state.opened.add(prefix); + } +} + +export function isFolderExpanded( + state: FolderExpansion, + prefix: string, + location: Location, +): boolean { + if (state.collapsedOnVisit.get(prefix) === location.visit) return false; + return state.opened.has(prefix) || location.path.startsWith(prefix); +} + +export function toggleFolder( + state: FolderExpansion, + prefix: string, + location: Location, +) { + if (isFolderExpanded(state, prefix, location)) { + state.opened.delete(prefix); + state.collapsedOnVisit.set(prefix, location.visit); + } else { + state.opened.add(prefix); + state.collapsedOnVisit.delete(prefix); + } +} diff --git a/desktop/src/lib/flip.ts b/desktop/src/lib/flip.ts new file mode 100644 index 000000000..a893f07bd --- /dev/null +++ b/desktop/src/lib/flip.ts @@ -0,0 +1,86 @@ +// FLIP: animate elements from where they were to where a layout change just put +// them. Capture from `$effect.pre` and play from `$effect`, both reading the +// state that drives the change so they land on the same flush. +// +// Positions are relative to the container, so only the reflow inside it is +// animated; the container's own shift is left to whatever already carries it. + +export interface FlipOptions { + // Which descendants to animate. Defaults to the container's direct children. + selector?: string; + // Animate the container's height too. Needed when it lays out to `auto` in + // one state, which a CSS transition can't carry. + animateHeight?: boolean; + durationMs?: number; +} + +export interface FlipSnapshot { + offsets: WeakMap; + height: number; +} + +const DEFAULT_DURATION_MS = 200; + +// Sub-pixel differences are rounding, not movement. +const EPSILON_PX = 0.5; + +function targets(container: Element, selector: string | undefined): Element[] { + return Array.from( + selector ? container.querySelectorAll(selector) : container.children, + ); +} + +export function captureFlip( + container: Element | undefined, + options: FlipOptions = {}, +): FlipSnapshot | undefined { + // First run, before the bind has landed. Returning nothing stops `playFlip` + // animating in from a zero-sized phantom. + if (!container) return undefined; + + const base = container.getBoundingClientRect(); + const offsets = new WeakMap(); + for (const child of targets(container, options.selector)) { + const rect = child.getBoundingClientRect(); + offsets.set(child, { x: rect.left - base.left, y: rect.top - base.top }); + } + + return { offsets, height: base.height }; +} + +export function playFlip( + container: Element | undefined, + first: FlipSnapshot | undefined, + options: FlipOptions = {}, +): void { + if (!container || !first) return; + + const duration = options.durationMs ?? DEFAULT_DURATION_MS; + const base = container.getBoundingClientRect(); + + if ( + options.animateHeight && + Math.abs(base.height - first.height) > EPSILON_PX + ) { + container.animate( + [{ height: `${first.height}px` }, { height: `${base.height}px` }], + { duration, easing: "ease" }, + ); + } + + for (const child of targets(container, options.selector)) { + const from = first.offsets.get(child); + if (!from) continue; + const rect = child.getBoundingClientRect(); + const dx = from.x - (rect.left - base.left); + const dy = from.y - (rect.top - base.top); + if (Math.abs(dx) < EPSILON_PX && Math.abs(dy) < EPSILON_PX) continue; + child.animate( + [ + { transform: `translate(${dx}px, ${dy}px)` }, + { transform: "translate(0px, 0px)" }, + ], + { duration, easing: "ease" }, + ); + } +} diff --git a/desktop/src/lib/frontmatter.ts b/desktop/src/lib/frontmatter.ts new file mode 100644 index 000000000..ad40b285a --- /dev/null +++ b/desktop/src/lib/frontmatter.ts @@ -0,0 +1,26 @@ +import { load } from "js-yaml"; + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; + +export interface ParsedFrontmatter { + data: Record; + content: string; +} + +export function parseFrontmatter(input: string): ParsedFrontmatter { + const match = input.match(FRONTMATTER_RE); + if (!match) { + return { data: {}, content: input }; + } + let parsed: unknown; + try { + parsed = load(match[1]); + } catch { + parsed = undefined; + } + const data = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + return { data, content: input.slice(match[0].length) }; +} diff --git a/desktop/src/lib/hints.ts b/desktop/src/lib/hints.ts new file mode 100644 index 000000000..290c21a1e --- /dev/null +++ b/desktop/src/lib/hints.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +import useLocalStorage from "@app/lib/useLocalStorage.svelte"; + +// One-off tips that can be dismissed permanently. Kept in a single list so +// settings can offer to bring all of them back. +export type HintId = "markdown" | "guide"; + +const storage = useLocalStorage("dismissedHints", z.array(z.string()), []); + +export const hints = { + get dismissedCount(): number { + return storage.value.length; + }, + + isDismissed(id: HintId): boolean { + return storage.value.includes(id); + }, + + dismiss(id: HintId) { + if (!storage.value.includes(id)) { + storage.value = [...storage.value, id]; + } + }, + + resetAll() { + storage.value = []; + }, +}; diff --git a/desktop/src/lib/identityState.ts b/desktop/src/lib/identityState.ts new file mode 100644 index 000000000..8d99dcca8 --- /dev/null +++ b/desktop/src/lib/identityState.ts @@ -0,0 +1,33 @@ +import type { State } from "@bindings/identity/State"; + +import { formatOid } from "@app/lib/utils"; + +const icons = { + active: "hourglass", + accepted: "checkmark", + rejected: "close", +} as const; + +const labels = { + active: "Active", + accepted: "Accepted", + rejected: "Rejected", +}; + +export function stateIcon(state: State): (typeof icons)[State["status"]] { + return icons[state.status]; +} + +export function stateLabel(state: State): string { + return labels[state.status]; +} + +export function stateCaption(state: State): string | undefined { + if (state.status === "rejected") { + if (state.reason.type === "vote") return "Rejected by delegate votes"; + if (state.reason.type === "parent") + return "Rejected because its parent revision was rejected"; + return `Rejected because sibling ${formatOid(state.reason.revision)} was accepted`; + } + return undefined; +} diff --git a/desktop/src/lib/ignoredFiles.ts b/desktop/src/lib/ignoredFiles.ts new file mode 100644 index 000000000..fb68882f3 --- /dev/null +++ b/desktop/src/lib/ignoredFiles.ts @@ -0,0 +1,28 @@ +import type { FileDiff } from "@bindings/diff/FileDiff"; + +import { fileDiffPath } from "@app/lib/diffText"; + +// Files that add noise to patch diffs (lockfiles, generated manifests). +// These are hidden from the revision file preview and auto-collapsed in the +// Changes tab. Add new entries here to apply both behaviours. +export const IGNORED_FILENAMES: ReadonlySet = new Set([ + "package-lock.json", + "npm-shrinkwrap.json", + "yarn.lock", + "pnpm-lock.yaml", + "bun.lockb", + "Cargo.lock", + "Gemfile.lock", + "poetry.lock", + "composer.lock", + "go.sum", + "flake.lock", +]); + +export function isIgnoredPath(path: string): boolean { + return IGNORED_FILENAMES.has(path.slice(path.lastIndexOf("/") + 1)); +} + +export function isIgnoredFile(file: FileDiff): boolean { + return isIgnoredPath(fileDiffPath(file)); +} diff --git a/desktop/src/lib/inboxFilter.ts b/desktop/src/lib/inboxFilter.ts new file mode 100644 index 000000000..7b7dfd15b --- /dev/null +++ b/desktop/src/lib/inboxFilter.ts @@ -0,0 +1,83 @@ +import type { NotificationItem } from "@bindings/cob/inbox/NotificationItem"; +import type { NotificationsByRepo } from "@bindings/cob/inbox/NotificationsByRepo"; + +import fuzzysort from "fuzzysort"; + +export type FilteredRepo = { + repo: NotificationsByRepo; + groups: NotificationItem[][]; + count: number; +}; + +type SearchableGroup = { + group: NotificationItem[]; + title: string; + state: string; + type: string; + aliases: string; + dids: string; + id: string; +}; + +function toSearchable(group: NotificationItem[]): SearchableGroup { + const last = group.at(-1); + const aliases: string[] = []; + const dids: string[] = []; + for (const item of group) { + for (const action of item.actions) { + if (action.author.alias && !aliases.includes(action.author.alias)) { + aliases.push(action.author.alias); + } + if (!dids.includes(action.author.did)) { + dids.push(action.author.did); + } + } + } + return { + group, + title: last && "title" in last ? last.title : "", + state: last?.status.status ?? "", + type: group[0]?.type ?? "", + aliases: aliases.join(" "), + dids: dids.join(" "), + id: group[0]?.id ?? "", + }; +} + +function itemCountOf(groups: NotificationItem[][]): number { + return groups.reduce((acc, g) => acc + g.length, 0); +} + +export function rowIdsOf(groups: NotificationItem[][]): string[] { + return groups.flatMap(g => g.map(item => item.rowId)); +} + +export function filterNotifications( + repos: NotificationsByRepo[], + query: string, + excludedGroupIds: string[] = [], +): FilteredRepo[] { + if (query.trim() === "") { + return repos.map(repo => ({ + repo, + groups: repo.notifications, + count: repo.count, + })); + } + const result: FilteredRepo[] = []; + for (const repo of repos) { + const searchable = repo.notifications.map(toSearchable); + const matched = fuzzysort + .go(query, searchable, { + keys: ["title", "state", "type", "aliases", "dids", "id"], + threshold: 0.5, + all: false, + }) + .map(r => r.obj.group) + .filter(g => !excludedGroupIds.includes(g[0]?.id ?? "")); + if (matched.length > 0) { + result.push({ repo, groups: matched, count: itemCountOf(matched) }); + } + } + return result; +} diff --git a/desktop/src/lib/inputPopover.ts b/desktop/src/lib/inputPopover.ts new file mode 100644 index 000000000..2e458a048 --- /dev/null +++ b/desktop/src/lib/inputPopover.ts @@ -0,0 +1,44 @@ +import { + autoUpdate, + computePosition, + hide, + offset, + shift, + size, +} from "@floating-ui/dom"; + +const gap = 8; +const padding = 8; +const maxHeight = 384; + +export function positionInputPopover( + reference: HTMLElement, + floating: HTMLElement, +): () => void { + return autoUpdate(reference, floating, () => { + const spaceAbove = reference.getBoundingClientRect().top - gap - padding; + const placement = spaceAbove >= maxHeight ? "top-start" : "bottom-start"; + + void computePosition(reference, floating, { + placement, + middleware: [ + offset(gap), + shift({ padding }), + hide({ padding }), + size({ + padding, + apply({ availableHeight, rects, elements }) { + elements.floating.style.minWidth = `${rects.reference.width}px`; + elements.floating.style.maxHeight = `${Math.min(availableHeight, maxHeight)}px`; + }, + }), + ], + }).then(({ x, y, middlewareData }) => { + floating.style.left = `${x}px`; + floating.style.top = `${y}px`; + floating.style.visibility = middlewareData.hide?.referenceHidden + ? "hidden" + : "visible"; + }); + }); +} diff --git a/desktop/src/lib/inputValidation.ts b/desktop/src/lib/inputValidation.ts new file mode 100644 index 000000000..85ff3e67c --- /dev/null +++ b/desktop/src/lib/inputValidation.ts @@ -0,0 +1,40 @@ +import { parseNodeId } from "@app/lib/utils"; + +export type AliasError = + | "AliasError.EmptyAlias" + | "AliasError.TooLongAlias" + | "AliasError.InvalidAlias"; + +export function aliasErrors(alias: string): AliasError[] { + const errors: AliasError[] = []; + if (alias.length === 0) errors.push("AliasError.EmptyAlias"); + if (alias.length > 32) errors.push("AliasError.TooLongAlias"); + if (alias.includes(" ")) errors.push("AliasError.InvalidAlias"); + return errors; +} + +export function labelError( + label: string, + labels: string[], +): string | undefined { + if (labels.includes(label.trim())) { + return "This label is already assigned"; + } +} + +export function parseAssignee( + input: string, + assignees: { did: string }[], +): { did?: string; error?: string } { + input = input.trim(); + if (input === "") return {}; + const nodeId = parseNodeId(input); + if (!nodeId) { + return input.startsWith("did:") ? { error: "This is not a valid DID" } : {}; + } + const did = `${nodeId.prefix}${nodeId.pubkey}`; + if (assignees.some(assignee => assignee.did === did)) { + return { error: "This assignee is already added" }; + } + return { did }; +} diff --git a/desktop/src/lib/interval.ts b/desktop/src/lib/interval.ts new file mode 100644 index 000000000..dbc1e4406 --- /dev/null +++ b/desktop/src/lib/interval.ts @@ -0,0 +1,48 @@ +const dynamicIntervals = new Map>(); + +export function dynamicInterval( + key: string, + callback: () => void, + period: number, +) { + // Clear an existing interval for this key, if any. + if (dynamicIntervals.has(key)) { + clearTimeout(dynamicIntervals.get(key)); + } + + // Set up a new dynamic interval. + const id = setTimeout(() => { + callback(); + dynamicInterval(key, callback, period); + }, period); + + dynamicIntervals.set(key, id); +} + +export function resetDynamicInterval(key: string) { + clearTimeout(dynamicIntervals.get(key)); + dynamicIntervals.delete(key); +} + +// Unlike setInterval, a slow run never overlaps the next one. +export function poll( + task: (active: () => boolean) => Promise, + period: number, +): () => void { + let stopped = false; + let timer: ReturnType | undefined; + const active = () => !stopped; + + const run = async () => { + await task(active); + if (!stopped) { + timer = setTimeout(() => void run(), period); + } + }; + void run(); + + return () => { + stopped = true; + clearTimeout(timer); + }; +} diff --git a/desktop/src/lib/invoke.ts b/desktop/src/lib/invoke.ts new file mode 100644 index 000000000..4ad8fbef1 --- /dev/null +++ b/desktop/src/lib/invoke.ts @@ -0,0 +1,461 @@ +import type { AliasSuggestion } from "@bindings/cob/AliasSuggestion"; +import type { DiffOptions } from "@bindings/cob/DiffOptions"; +import type { Issue } from "@bindings/cob/issue/Issue"; +import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery"; +import type { Patch } from "@bindings/cob/patch/Patch"; +import type { Release } from "@bindings/cob/release/Release"; +import type { ReleaseFilter } from "@bindings/cob/release/ReleaseFilter"; +import type { Config } from "@bindings/config/Config"; +import type { Diff } from "@bindings/diff/Diff"; +import type { Stats } from "@bindings/diff/Stats"; +import type { Commit } from "@bindings/repo/Commit"; +import type { Job } from "@bindings/repo/Job"; +import type { RepoInfo } from "@bindings/repo/RepoInfo"; +import type { RepoSummary } from "@bindings/repo/RepoSummary"; +import type { TreePath } from "@bindings/repo/TreePath"; + +import * as tauri from "@tauri-apps/api/core"; + +import { cached } from "@app/lib/cached"; +import type { RepoListScope } from "@app/lib/repoListScope"; + +export async function invoke( + cmd: string, + args?: tauri.InvokeArgs, + options?: tauri.InvokeOptions, +): Promise { + if (import.meta.env.VITE_LOG_INVOKE_TIMINGS === "true") { + const start = performance.now(); + try { + return await withTestBackend(tauri.invoke, cmd, args, options); + } finally { + const ms = Math.round(performance.now() - start); + + console.debug(`[invoke] ${cmd} ${ms}ms`); + } + } + return withTestBackend(tauri.invoke, cmd, args, options); +} + +/** + * Invoking a Tauri command returned an error with the given message + */ +class InvokeError extends Error { + name = "InvokeError"; + code: string; + + constructor(message: string, code = "Unknown Error") { + super(message); + this.code = code; + Error.captureStackTrace?.(this, InvokeError); + } +} + +async function withTestBackend( + fn: ( + cmd: string, + args?: tauri.InvokeArgs, + options?: tauri.InvokeOptions, + ) => Promise, + cmd: string, + args?: tauri.InvokeArgs, + options?: tauri.InvokeOptions, +) { + if (window.__TAURI_INTERNALS__) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return fn(cmd, args, options).catch((error: any) => { + if (typeof error === "object" && error !== null && "message" in error) { + throw new InvokeError(String(error.message), String(error.code)); + } else { + throw new InvokeError(`Invalid error object: ${error}`); + } + }); + } else { + const raw = args instanceof Uint8Array; + const headers = new Headers(options?.headers); + headers.set( + "Content-Type", + raw ? "application/octet-stream" : "application/json", + ); + return fetch(`http://127.0.0.1:${testHttpApiPort()}/${cmd}`, { + method: "POST", + headers, + body: raw ? args.slice() : JSON.stringify(args ?? {}), + }).then(async response => { + if (response.status === 404) { + throw new InvokeError( + `test-http-api has no route for \`${cmd}\``, + "TestHttpApi.MissingRoute", + ); + } + if ( + response.ok && + response.headers.get("Content-Type") === "application/octet-stream" + ) { + return response.arrayBuffer(); + } + const json = await response.json(); + if (!response.ok) { + if (typeof json === "object" && json !== null && "message" in json) { + throw new InvokeError(String(json.message), String(json.code)); + } else { + throw new InvokeError(`Invalid error object: ${json}`); + } + } + + return json; + }); + } +} + +function testHttpApiPort(): number | string { + return ( + window.__TEST_HTTP_API_PORT__ ?? + import.meta.env.VITE_TEST_HTTP_API_PORT ?? + 8081 + ); +} + +// The test backend can't stream, so outside Tauri events are dropped. +export function channel( + onMessage: (message: T) => void, +): tauri.Channel | undefined { + return window.__TAURI_INTERNALS__ + ? new tauri.Channel(onMessage) + : undefined; +} + +async function getDiff(rid: string, options: DiffOptions): Promise { + return withTestBackend(tauri.invoke, "get_diff", { + rid, + options, + }); +} + +export const cachedGetDiff = cached( + getDiff, + (...[rid, options]) => + `get_diff:${rid}:${JSON.stringify(options, Object.keys(options).sort())}`, + { max: 10_000 }, +); + +async function listCommits( + rid: string, + base: string, + head: string, +): Promise { + return withTestBackend(tauri.invoke, "list_commits", { rid, base, head }); +} + +export const cachedListCommits = cached( + listCommits, + (...[rid, base, head]) => `list_commits:${rid}:${base}:${head}`, + { max: 5_000 }, +); + +async function repoCommitCount(rid: string, head: string): Promise { + return withTestBackend(tauri.invoke, "repo_commit_count", { rid, head }); +} + +export const cachedRepoCommitCount = cached( + repoCommitCount, + (...[rid, head]) => `repo_commit_count:${rid}:${head}`, + { max: 5_000 }, +); + +async function listJobs(rid: string, sha: string): Promise { + return withTestBackend(tauri.invoke, "list_jobs", { rid, sha }); +} + +// Listing jobs scans every job COB in the repo, so it is slow (hundreds of ms +// to seconds). It is re-requested on every JobCob mount; caching by (rid, sha) +// collapses concurrent identical calls into one in-flight request and reuses +// the result across navigations. A short TTL keeps CI status reasonably fresh. +export const cachedListJobs = cached( + listJobs, + (...[rid, sha]) => `list_jobs:${rid}:${sha}`, + { max: 200, ttl: 30_000 }, +); + +async function alias(nid: string): Promise { + return withTestBackend(tauri.invoke, "alias", { nid }); +} + +export const cachedAlias = cached(alias, (...[nid]) => `alias:${nid}`, { + max: 5_000, +}); + +async function searchAliases(query: string): Promise { + return withTestBackend(tauri.invoke, "search_aliases", { + query: query === "" ? undefined : query, + }); +} + +export const cachedSearchAliases = cached( + searchAliases, + (...[query]) => `search_aliases:${query}`, + { max: 200, ttl: 60_000 }, +); + +const MENTION_CANDIDATE_TAKE = 200; + +async function listIssueCandidates(rid: string): Promise { + const issues = await invoke>("list_issues", { + rid, + status: undefined, + skip: 0, + take: MENTION_CANDIDATE_TAKE, + }); + + return issues.content; +} + +export const cachedListIssueCandidates = cached( + listIssueCandidates, + (...[rid]) => `issue_candidates:${rid}`, + { max: 10, ttl: 30_000 }, +); + +async function listPatchCandidates(rid: string): Promise { + const patches = await invoke>("list_patches", { + rid, + status: undefined, + skip: 0, + take: MENTION_CANDIDATE_TAKE, + }); + + return patches.content; +} + +export const cachedListPatchCandidates = cached( + listPatchCandidates, + (...[rid]) => `patch_candidates:${rid}`, + { max: 10, ttl: 30_000 }, +); + +async function listReleaseCandidates(rid: string): Promise { + const releases = await invoke>("list_releases", { + rid, + filter: { scope: "trusted", showRedacted: false } satisfies ReleaseFilter, + skip: 0, + take: MENTION_CANDIDATE_TAKE, + }); + + return releases.content; +} + +export const cachedListReleaseCandidates = cached( + listReleaseCandidates, + (...[rid]) => `release_candidates:${rid}`, + { max: 10, ttl: 30_000 }, +); + +const missTtl = 5_000; + +async function repoById(rid: string): Promise { + return withTestBackend(tauri.invoke, "repo_by_id", { rid }); +} + +export const cachedRepoById = cached( + repoById, + (...[rid]) => `repo_by_id:${rid}`, + { max: 200, ttl: 60_000 }, + missTtl, +); + +async function issueById(rid: string, id: string): Promise { + return withTestBackend(tauri.invoke, "issue_by_id", { rid, id }); +} + +export const cachedIssueById = cached( + issueById, + (...[rid, id]) => `issue_by_id:${rid}:${id}`, + { max: 200, ttl: 60_000 }, + missTtl, +); + +async function patchById(rid: string, id: string): Promise { + return withTestBackend(tauri.invoke, "patch_by_id", { rid, id }); +} + +export const cachedPatchById = cached( + patchById, + (...[rid, id]) => `patch_by_id:${rid}:${id}`, + { max: 200, ttl: 60_000 }, + missTtl, +); + +async function releaseById(rid: string, id: string): Promise { + return withTestBackend(tauri.invoke, "release_by_id", { rid, id }); +} + +export const cachedReleaseById = cached( + releaseById, + (...[rid, id]) => `release_by_id:${rid}:${id}`, + { max: 200, ttl: 60_000 }, + missTtl, +); + +async function repoCommit(rid: string, sha: string): Promise { + return withTestBackend(tauri.invoke, "repo_commit", { rid, sha }); +} + +export const cachedRepoCommit = cached( + repoCommit, + (...[rid, sha]) => `repo_commit:${rid}:${sha}`, + { max: 200, ttl: 60_000 }, + missTtl, +); + +async function repoCommitsByPrefix( + rid: string, + prefix: string, +): Promise { + return withTestBackend(tauri.invoke, "repo_commits_by_prefix", { + rid, + prefix, + }); +} + +export const cachedRepoCommitsByPrefix = cached( + repoCommitsByPrefix, + (...[rid, prefix]) => `repo_commits_by_prefix:${rid}:${prefix}`, + { max: 200, ttl: 60_000 }, +); + +async function repoSplitTreePath( + rid: string, + peer: string | undefined, + path: string, +): Promise { + return withTestBackend(tauri.invoke, "repo_split_tree_path", { + rid, + peer, + path, + }); +} + +export const cachedRepoSplitTreePath = cached( + repoSplitTreePath, + (...[rid, peer, path]) => `repo_split_tree_path:${rid}:${peer}:${path}`, + { max: 50, ttl: 60_000 }, +); + +async function config(): Promise { + return withTestBackend(tauri.invoke, "config", {}); +} + +// The node config is static for the session, so a single-entry cache collapses +// the many callers (e.g. every NodeId hover card) into one in-flight request. +export const cachedConfig = cached(config, () => "config", { max: 1 }); + +async function diffStats( + rid: string, + base: string, + head: string, +): Promise { + return withTestBackend(tauri.invoke, "diff_stats", { + rid, + base, + head, + }); +} + +export const cachedDiffStats = cached( + diffStats, + (...[rid, base, head]) => `diff_stats:${rid}:${base}:${head}`, + { max: 10_000 }, +); + +// Patch text is generated by libgit2 on the backend (byte-accurate `git diff` +// output) instead of being reconstructed from the structured diff model. +// Callers pass the same `unified` value the rendered diff used so the text +// matches what's on screen; `base` unset diffs `head` against its first +// parent; `path` limits output to a single file. +export async function getDiffText( + rid: string, + base: string | undefined, + head: string, + unified: number, + path?: string, +): Promise { + return withTestBackend(tauri.invoke, "get_diff_text", { + rid, + base, + head, + unified, + path, + }); +} + +// Commits and patch revisions are immutable, so the same (base, head, unified, +// path) always yields the same patch text — safe to cache. Capped since a +// commit's full patch can be sizeable. +export const cachedGetDiffText = cached( + getDiffText, + (...[rid, base, head, unified, path]) => + `get_diff_text:${rid}:${base}:${head}:${unified}:${path}`, + { max: 100 }, +); + +async function getCommitDiff(rid: string, sha: string): Promise { + return withTestBackend(tauri.invoke, "get_commit_diff", { rid, sha }); +} + +// Commits are immutable, so SHA is a perfect cache key. Cap entries since a +// Diff can be sizeable. +export const cachedGetCommitDiff = cached( + getCommitDiff, + (...[rid, sha]) => `get_commit_diff:${rid}:${sha}`, + { max: 100 }, +); + +/** + * Enumerating repos loads and verifies the identity doc of every repo in + * storage, which takes ~100ms with many repos. The list rarely changes, so it + * is cached with a short TTL; mutations that change it (adding, seeding, + * unseeding) go through invalidateReposSummary(). + */ +export const cachedListReposSummary = cached( + (show: RepoListScope) => + invoke("list_repos_summary", { show }), + show => `list_repos_summary:${show}`, + { max: 2, ttl: 30_000 }, +); + +export function invalidateReposSummary(): void { + cachedListReposSummary.clear(); +} + +export async function writeToClipboard( + text: string, + opts?: { + label?: string; + }, +) { + if (window.__TAURI_INTERNALS__) { + await tauri.invoke("plugin:clipboard-manager|write_text", { + label: opts?.label, + text, + }); + } else { + await navigator.clipboard.writeText(text); + } +} + +export async function saveToDisk(fileName: string, text: string) { + if (window.__TAURI_INTERNALS__) { + // The backend opens the native save dialog and writes the file. + await tauri.invoke("save_diff_to_disk", { name: fileName, content: text }); + } else { + // No native dialog outside Tauri (test/http mode): trigger a download. + const url = URL.createObjectURL(new Blob([text], { type: "text/plain" })); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); + } +} + +export { InvokeError }; diff --git a/desktop/src/lib/issueCounts.svelte.ts b/desktop/src/lib/issueCounts.svelte.ts new file mode 100644 index 000000000..a2a8cf469 --- /dev/null +++ b/desktop/src/lib/issueCounts.svelte.ts @@ -0,0 +1,30 @@ +import type { IssueStatus } from "@app/views/repo/router"; +import type { ProjectPayload } from "@bindings/repo/ProjectPayload"; + +export const issueCounts = $state< + Record +>({ + all: { sidebar: null, total: null }, + open: { sidebar: null, total: null }, + closed: { sidebar: null, total: null }, +}); + +export function issueCountMismatch(status: IssueStatus) { + return issueCounts[status].total !== issueCounts[status].sidebar; +} + +export function resetIssueCounts() { + Object.values(issueCounts).forEach(count => { + count.sidebar = null; + count.total = null; + }); +} + +export function updateIssueCounts( + itemCount: number, + sidebarCount: ProjectPayload["meta"]["issues"] & { all: number }, + status: IssueStatus, +) { + issueCounts[status].total = itemCount; + issueCounts[status].sidebar = sidebarCount[status]; +} diff --git a/desktop/src/lib/issueTimeline.ts b/desktop/src/lib/issueTimeline.ts new file mode 100644 index 000000000..f59e08972 --- /dev/null +++ b/desktop/src/lib/issueTimeline.ts @@ -0,0 +1,41 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Action } from "@bindings/cob/issue/Action"; +import type { Issue } from "@bindings/cob/issue/Issue"; +import type { Operation } from "@bindings/cob/Operation"; + +import type { ActivityItem, FlattenedAction } from "@app/lib/cobActivity"; +import { flattenActivity } from "@app/lib/cobActivity"; + +export type FlattenedIssueOperation = + | FlattenedAction + | { type: "opened"; id: string; author: Author; timestamp: number }; + +// Comments render as threads beside the timeline, not as entries in it. +const skip = new Set([ + "comment", + "comment.edit", + "comment.react", + "comment.redact", +]); + +/// The issue's timeline: an "opened" entry, then one entry per action worth +/// showing. +export function issueTimeline( + issue: Issue, + activity: Operation[], +): ActivityItem[] { + const opened = issue.body?.edits[0]?.timestamp ?? issue.timestamp; + return [ + { + key: `${issue.id}:opened`, + timestamp: opened, + data: { + type: "opened", + id: issue.id, + author: issue.author, + timestamp: opened, + }, + }, + ...flattenActivity(activity, { skip }), + ]; +} diff --git a/desktop/src/lib/listState.ts b/desktop/src/lib/listState.ts new file mode 100644 index 000000000..1d37874ef --- /dev/null +++ b/desktop/src/lib/listState.ts @@ -0,0 +1,37 @@ +import type { VirtualizerHandle } from "virtua/svelte"; + +// virtua's cache snapshot is opaque; derive its type from the handle so we +// don't depend on a non-public export. +export type ListCacheSnapshot = ReturnType; + +export interface ListState { + // The full set of loaded (paginated) items, so a restored list isn't limited + // to the first page the route loader re-fetches. + items: T[]; + more: boolean; + scrollOffset: number; + // virtua measurement snapshot, so item sizes (and thus the offset) are exact + // immediately on restore instead of drifting as rows are re-measured. + cache: ListCacheSnapshot | undefined; +} + +// Keep only a handful of recently-visited lists; this is a scroll-restoration +// convenience, not a persistent store. +const MAX_ENTRIES = 20; +const states = new Map(); + +export function saveListState(key: string, state: ListState): void { + // Re-insert to mark as most-recently-used. + states.delete(key); + states.set(key, state); + if (states.size > MAX_ENTRIES) { + const oldest = states.keys().next().value; + if (oldest !== undefined) { + states.delete(oldest); + } + } +} + +export function readListState(key: string): ListState | undefined { + return states.get(key) as ListState | undefined; +} diff --git a/desktop/src/lib/markdown.ts b/desktop/src/lib/markdown.ts new file mode 100644 index 000000000..b9bd5ec4a --- /dev/null +++ b/desktop/src/lib/markdown.ts @@ -0,0 +1,343 @@ +import type { Config } from "dompurify"; +import type { + MarkedExtension, + RendererExtension, + TokenizerExtension, + Tokens, +} from "marked"; + +import dompurify from "dompurify"; +import escape from "lodash/escape.js"; +import { Marked, Renderer as BaseRenderer } from "marked"; +import { markedEmoji } from "marked-emoji"; +import markedFootnote from "marked-footnote"; +import katexMarkedExtension from "marked-katex-extension"; +import markedLinkifyIt from "marked-linkify-it"; + +import emojis from "@app/lib/emojis"; +import { parseFrontmatter } from "@app/lib/frontmatter"; +import { bareReferenceStart, matchBareReference } from "@app/lib/mentions"; + +// DOMPurify only keeps hrefs whose scheme it recognises, and drops `rad:` and +// `did:key:` links along with the rest. Neither can execute the way +// `javascript:` can, and the app replaces every such anchor with a component +// after sanitization, so none is ever followed as a raw href. +// This is DOMPurify's own default as of 3.4, with those two schemes added, so +// it needs revisiting when DOMPurify changes its default. +const allowedUriSchemes = + /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix|rad|did):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i; + +/** + * DOMPurify configuration for sanitizing markdown-derived HTML. Pass this as + * the second argument to `dompurify.sanitize` at each call site instead of + * setting it globally. A global config leaks into every other consumer of the + * DOMPurify singleton, including mermaid's internal strict-mode sanitization, + * which would then strip the SVG output of valid diagrams. + */ +export const sanitizeConfig: Config = { + /* eslint-disable @typescript-eslint/naming-convention */ + ALLOWED_URI_REGEXP: allowedUriSchemes, + ALLOWED_ATTR: [ + "align", + "alt", + "checked", + "class", + "href", + "id", + "name", + "rel", + "target", + "text", + "title", + "src", + "type", + ], + ALLOWED_TAGS: [ + "a", + "blockquote", + "br", + "code", + "dd", + "del", + "div", + "dl", + "dt", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "img", + "input", + "li", + "ol", + "p", + "pre", + "strong", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "ul", + ], + /* eslint-enable @typescript-eslint/naming-convention */ +}; + +// Converts self closing anchor tags into empty anchor tags, to avoid erratic wrapping behaviour +// e.g. -> +const anchorMarkedExtension = { + name: "sanitizedAnchor", + level: "block", + start: (src: string) => src.match(//)?.index, + tokenizer(src: string) { + const match = src.match(/^/); + if (match) { + return { + type: "sanitizedAnchor", + raw: match[0], + text: match[1].trim(), + }; + } + }, + renderer: (token: Tokens.Generic): string => ``, +}; + +// GitHub flavoured alerts: a blockquote whose first line is a `[!NOTE]` marker +// renders as a titled callout rather than a quote. +export const alertVariants = [ + "note", + "tip", + "important", + "warning", + "caution", +] as const; + +export type AlertVariant = (typeof alertVariants)[number]; + +function isAlertVariant(name: string): name is AlertVariant { + return (alertVariants as readonly string[]).includes(name); +} + +const alertMarkedExtension: TokenizerExtension & RendererExtension = { + name: "alert", + level: "block", + start: (src: string) => src.match(/^ {0,3}> *\[!/m)?.index, + tokenizer(src: string) { + const quote = /^(?: {0,3}>[^\n]*(?:\n|$))+/.exec(src); + if (!quote) { + return; + } + + const body = quote[0].replace(/^ {0,3}> ?/gm, ""); + const marker = /^\[!([a-zA-Z]+)\][^\S\n]*(?:\n|$)/.exec(body); + if (!marker) { + return; + } + + const variant = marker[1].toLowerCase(); + if (!isAlertVariant(variant)) { + return; + } + + return { + type: "alert", + raw: quote[0], + variant, + tokens: this.lexer.blockTokens(body.slice(marker[0].length)), + }; + }, + renderer(token: Tokens.Generic): string { + const variant = token.variant as AlertVariant; + const title = variant.charAt(0).toUpperCase() + variant.slice(1); + + return `

${title}

${this.parser.parse(token.tokens ?? [])}
`; + }, +}; + +// Past this many references in one document the rest stay text, matching +// the number of chips `Markdown` mounts. +export const maximumReferences = 200; +const referenceCounts = new WeakMap(); + +const radicleReferenceMarkedExtension: TokenizerExtension & RendererExtension = + { + name: "radicleReference", + level: "inline", + start(src: string) { + if ((referenceCounts.get(this.lexer) ?? 0) >= maximumReferences) return; + return bareReferenceStart(src); + }, + tokenizer(src: string) { + if (this.lexer.state.inLink) return; + const match = matchBareReference(src); + if (!match) return; + referenceCounts.set( + this.lexer, + (referenceCounts.get(this.lexer) ?? 0) + 1, + ); + + return { + type: "radicleReference", + raw: match.raw, + text: match.raw, + }; + }, + renderer: (token: Tokens.Generic): string => + `${escape(token.text)}`, + }; + +const TASK_CHECKBOX_CLASS = "task-checkbox"; + +export class Renderer extends BaseRenderer { + /** + * If `baseUrl` is provided, all hrefs attributes in anchor tags, except those + * starting with `#`, are resolved with respect to `baseUrl` + */ + constructor() { + super(); + } + // Overwrites the rendering of heading tokens. + // Since there are possible non ASCII characters in headings, + // we escape them by replacing them with dashes and, + // trim eventual dashes on each side of the string. + heading({ tokens, depth }: Tokens.Heading) { + const text = this.parser.parseInline(tokens); + const id = text + // By lowercasing we avoid casing mismatches, between headings and links. + .toLowerCase() + .replace(/[^\w]+/g, "-") + .replace(/^-|-$/g, ""); + + return `${text}`; + } + + checkbox({ checked }: Tokens.Checkbox): string { + return ` `; + } + + link({ href, title, tokens }: Tokens.Link): string { + const text = this.parser.parseInline(tokens); + const attrs = [`href="${escape(href)}"`]; + if (title) { + attrs.push(`title="${escape(title)}"`); + } + + return `${text}`; + } +} + +export default new Marked(); + +export const markdownWithExtensions = new Marked( + katexMarkedExtension({ throwOnError: false }), + markedLinkifyIt({ fuzzyLink: false }), + markedFootnote({ refMarkers: true }), + markedEmoji({ emojis }), + ((): MarkedExtension => ({ + extensions: [ + anchorMarkedExtension, + alertMarkedExtension, + radicleReferenceMarkedExtension, + ], + }))(), +); + +export function renderMarkdown(content: string, breaks = false): string { + return dompurify.sanitize( + markdownWithExtensions.parse(content, { + renderer: new Renderer(), + breaks, + }) as string, + sanitizeConfig, + ); +} + +const TASK_CANDIDATE_RE = /(?:[-*+]|\d{1,9}[.)])[ \t]+\[([ xX])\]/g; +const CODE_FENCE_RE = /^[ \t]*(`{3,}|~{3,})/; + +export function isTaskCheckbox(element: Element): boolean { + return element.getAttribute("class") === TASK_CHECKBOX_CLASS; +} + +function taskStates(content: string): boolean[] { + // A template's content is inert, so nothing in it loads or runs. + const template = document.createElement("template"); + template.innerHTML = renderMarkdown(content); + return Array.from(template.content.querySelectorAll('input[type="checkbox"]')) + .filter(isTaskCheckbox) + .map(input => input.hasAttribute("checked")); +} + +function fencedRanges(content: string): [number, number][] { + const ranges: [number, number][] = []; + let open: { fence: string; start: number } | undefined; + let offset = 0; + for (const line of content.split("\n")) { + const fence = CODE_FENCE_RE.exec(line)?.[1]; + if (fence && open === undefined) { + open = { fence, start: offset }; + } else if ( + fence && + open && + fence[0] === open.fence[0] && + fence.length >= open.fence.length + ) { + ranges.push([open.start, offset + line.length]); + open = undefined; + } + offset += line.length + 1; + } + if (open) { + ranges.push([open.start, content.length]); + } + return ranges; +} + +/// Flips the task-list box at `index`, counted in rendered order. +export function toggleTask(input: string, index: number): string | undefined { + // Frontmatter is not rendered, so it is left out of the search. + const { content } = parseFrontmatter(input); + const prefix = input.slice(0, input.length - content.length); + const states = taskStates(content); + if (index >= states.length) { + return undefined; + } + + const positions = Array.from( + content.matchAll(TASK_CANDIDATE_RE), + match => match.index + match[0].length - 2, + ); + const fenced = fencedRanges(content); + const inCode = (p: number) => + fenced.some(([start, end]) => p >= start && p <= end); + const outside = positions.filter(p => !inCode(p)); + const likely = outside[index]; + const ordered = [ + ...(likely === undefined ? [] : [likely]), + ...outside.filter(p => p !== likely), + ...positions.filter(inCode), + ]; + + for (const position of ordered) { + const next = + content.slice(0, position) + + (content[position] === " " ? "x" : " ") + + content.slice(position + 1); + const nextStates = taskStates(next); + if ( + nextStates.length === states.length && + nextStates.every((checked, i) => + i === index ? checked !== states[i] : checked === states[i], + ) + ) { + return prefix + next; + } + } + + return undefined; +} diff --git a/desktop/src/lib/markdownFormat.ts b/desktop/src/lib/markdownFormat.ts new file mode 100644 index 000000000..d614b9df9 --- /dev/null +++ b/desktop/src/lib/markdownFormat.ts @@ -0,0 +1,187 @@ +import { safeHttpUrl } from "@app/lib/utils"; + +export type MarkdownFormat = "bold" | "italic" | "code" | "link"; + +export interface TextEdit { + from: number; + to: number; + text: string; + selectionStart: number; + selectionEnd: number; +} + +const markers: Record<"bold" | "italic" | "code", string> = { + bold: "**", + italic: "_", + code: "`", +}; + +const fence = "```"; + +function linkUrl(text: string): string | undefined { + const candidate = text.trim(); + if (candidate === "" || /\s/.test(candidate)) { + return undefined; + } + return safeHttpUrl(candidate) ? candidate : undefined; +} + +export function applyTextEdit(value: string, edit: TextEdit): string { + return value + .substring(0, edit.from) + .concat(edit.text, value.substring(edit.to)); +} + +function toggleWrap( + value: string, + start: number, + end: number, + marker: string, +): TextEdit { + const width = marker.length; + const selected = value.substring(start, end); + + if ( + selected.length >= width * 2 && + selected.startsWith(marker) && + selected.endsWith(marker) + ) { + const inner = selected.substring(width, selected.length - width); + return { + from: start, + to: end, + text: inner, + selectionStart: start, + selectionEnd: start + inner.length, + }; + } + + if ( + start >= width && + value.substring(start - width, start) === marker && + value.substring(end, end + width) === marker + ) { + return { + from: start - width, + to: end + width, + text: selected, + selectionStart: start - width, + selectionEnd: end - width, + }; + } + + return { + from: start, + to: end, + text: marker.concat(selected, marker), + selectionStart: start + width, + selectionEnd: end + width, + }; +} + +function toggleCode(value: string, start: number, end: number): TextEdit { + const selected = value.substring(start, end); + if (!selected.includes("\n")) { + return toggleWrap(value, start, end, markers.code); + } + + const opening = `${fence}\n`; + const closing = `\n${fence}`; + + if (selected.startsWith(opening) && selected.endsWith(closing)) { + const inner = selected.substring( + opening.length, + selected.length - closing.length, + ); + return { + from: start, + to: end, + text: inner, + selectionStart: start, + selectionEnd: start + inner.length, + }; + } + + if ( + start >= opening.length && + value.substring(start - opening.length, start) === opening && + value.substring(end, end + closing.length) === closing + ) { + return { + from: start - opening.length, + to: end + closing.length, + text: selected, + selectionStart: start - opening.length, + selectionEnd: end - opening.length, + }; + } + + // Fences must start on their own line. + const before = start > 0 && value[start - 1] !== "\n" ? "\n" : ""; + const after = end < value.length && value[end] !== "\n" ? "\n" : ""; + const selectionStart = start + before.length + opening.length; + + return { + from: start, + to: end, + text: before.concat(opening, selected, closing, after), + selectionStart, + selectionEnd: selectionStart + selected.length, + }; +} + +function insertLink(value: string, start: number, end: number): TextEdit { + const selected = value.substring(start, end); + const url = linkUrl(selected); + const text = url ? `[](${url})` : `[${selected}]()`; + const caret = url ? start + 1 : start + text.length - 1; + + return { + from: start, + to: end, + text, + selectionStart: caret, + selectionEnd: caret, + }; +} + +export function applyMarkdownFormat( + format: MarkdownFormat, + value: string, + start: number, + end: number, +): TextEdit { + switch (format) { + case "bold": + return toggleWrap(value, start, end, markers.bold); + case "italic": + return toggleWrap(value, start, end, markers.italic); + case "code": + return toggleCode(value, start, end); + case "link": + return insertLink(value, start, end); + } +} + +export function pasteLinkEdit( + value: string, + start: number, + end: number, + pasted: string, +): TextEdit | undefined { + const url = linkUrl(pasted); + if (start === end || !url) { + return undefined; + } + + const text = `[${value.substring(start, end)}](${url})`; + const caret = start + text.length; + + return { + from: start, + to: end, + text, + selectionStart: caret, + selectionEnd: caret, + }; +} diff --git a/desktop/src/lib/markdownPlain.ts b/desktop/src/lib/markdownPlain.ts new file mode 100644 index 000000000..7571c8ad2 --- /dev/null +++ b/desktop/src/lib/markdownPlain.ts @@ -0,0 +1,24 @@ +/// Inline markdown, dropped down to the words it decorates. A row is a couple +/// of truncated lines, and on a narrow column the punctuation crowds out the +/// text that says what the comment is about. Deliberately shallow — this is a +/// preview, not a parser; the comment itself is one click away in the diff. +export function plainText(body: string): string { + return ( + body + .replace(/!?\[([^\]]*)\]\([^)]*\)/gu, "$1") + .replace(/`/gu, "") + .replace(/~~(.+?)~~/gu, "$1") + // Emphasis only where a marker sits on a word boundary, so `snake_case`, + // `MAX_SIZE` and `_private` survive being read as italics, and with no + // space just inside it, so `2 * 3 * 4` does too. + .replace( + /(^|[^\w])[*_]{1,2}(?=\S)([^*_]+?)(?<=\S)[*_]{1,2}(?=[^\w]|$)/gu, + "$1$2", + ) + .replace(/^\s{0,3}#{1,6}\s+/gmu, "") + .replace(/^\s{0,3}>\s?/gmu, "") + .replace(/^\s{0,3}(?:[-+*]|\d+\.)\s+/gmu, "") + .replace(/\s+/gu, " ") + .trim() + ); +} diff --git a/desktop/src/lib/mentionTrigger.ts b/desktop/src/lib/mentionTrigger.ts new file mode 100644 index 000000000..3be961b7b --- /dev/null +++ b/desktop/src/lib/mentionTrigger.ts @@ -0,0 +1,175 @@ +import type { Entity } from "@app/lib/mentions"; +import { + describeLink, + parseBareIdentifier, + parseEntityHref, +} from "@app/lib/mentions"; +import type { RadReference } from "@app/lib/radUri"; +import { + isOid, + parseExplorerTreeUrl, + parseExplorerUrl, + parseReference, +} from "@app/lib/radUri"; + +export type MentionScope = "all" | "cobs"; + +export type MentionTrigger = + | { + kind: "query"; + scope: MentionScope; + start: number; + end: number; + query: string; + } + | { + kind: "identifier"; + start: number; + end: number; + target: Entity; + } + | { + kind: "oid"; + start: number; + end: number; + oid: string; + } + | { + kind: "link"; + start: number; + end: number; + reference: RadReference; + } + | { + kind: "tree"; + start: number; + end: number; + rid: string; + namespace?: string; + path: string; + fragment?: string; + }; + +const openingCharacters = new Set([" ", "\n", "\t", "(", "[", ">", '"', "'"]); + +const queryPattern = /^[\w.-]*$/; + +const maximumQueryLength = 64; + +const identifierPrefixes = ["did:key:", "rad:", "https://", "http://"]; + +const maximumIdentifierLength = 512; + +export function findMentionTrigger( + text: string, + caret: number, +): MentionTrigger | undefined { + return ( + findQueryTrigger(text, caret) ?? + findIdentifierTrigger(text, caret) ?? + findBareTrigger(text, caret) + ); +} + +function findBareTrigger( + text: string, + caret: number, +): MentionTrigger | undefined { + let start = caret; + while (start > 0 && /[0-9A-Za-z]/.test(text[start - 1])) { + start--; + } + const token = text.slice(start, caret); + if (token === "") return undefined; + if (start > 0 && !openingCharacters.has(text[start - 1])) return undefined; + + if (isOid(token)) { + return { kind: "oid", start, end: caret, oid: token.toLowerCase() }; + } + + const target = parseBareIdentifier(token); + + return target ? { kind: "identifier", start, end: caret, target } : undefined; +} + +function findQueryTrigger( + text: string, + caret: number, +): MentionTrigger | undefined { + for (let index = caret - 1; index >= 0; index--) { + const character = text[index]; + + if (character === "@" || character === "#") { + const preceding = index === 0 ? " " : text[index - 1]; + if (!openingCharacters.has(preceding)) return undefined; + + const query = text.slice(index + 1, caret); + if (!queryPattern.test(query)) return undefined; + + const scope: MentionScope = character === "@" ? "all" : "cobs"; + + return { kind: "query", scope, start: index, end: caret, query }; + } + + if (!queryPattern.test(character)) return undefined; + if (caret - index > maximumQueryLength) return undefined; + } + + return undefined; +} + +function findIdentifierTrigger( + text: string, + caret: number, +): MentionTrigger | undefined { + let wordStart = caret; + while ( + wordStart > 0 && + !/\s/.test(text[wordStart - 1]) && + caret - wordStart < maximumIdentifierLength + ) { + wordStart--; + } + const word = text.slice(wordStart, caret); + if (word === "") return undefined; + + const candidates = identifierPrefixes + .map(prefix => ({ prefix, offset: word.indexOf(prefix) })) + .filter(({ offset }) => offset !== -1) + .sort((a, b) => a.offset - b.offset); + + for (const { prefix, offset } of candidates) { + const trigger = triggerFor(prefix, word.slice(offset)); + if (trigger) return { ...trigger, start: wordStart + offset, end: caret }; + } + + return undefined; +} + +type Found = DistributiveOmit; +type DistributiveOmit = T extends unknown + ? Omit + : never; + +function triggerFor(prefix: string, text: string): Found | undefined { + const target = parseEntityHref(text); + if (target) return { kind: "identifier", target }; + + const reference = + prefix === "rad:" ? parseReference(text) : parseExplorerUrl(text); + if (reference && describeLink(reference, "") !== undefined) { + return { kind: "link", reference }; + } + if (prefix === "rad:") return undefined; + + const tree = parseExplorerTreeUrl(text); + return ( + tree && { + kind: "tree", + rid: `rad:${tree.repo}`, + namespace: tree.namespace, + path: tree.path, + fragment: tree.fragment, + } + ); +} diff --git a/desktop/src/lib/mentions.ts b/desktop/src/lib/mentions.ts new file mode 100644 index 000000000..d44cf1533 --- /dev/null +++ b/desktop/src/lib/mentions.ts @@ -0,0 +1,345 @@ +import type Icon from "@app/components/Icon.svelte"; +import type { RepoRoute } from "@app/views/repo/router"; +import type { Config } from "@bindings/config/Config"; +import type { ComponentProps } from "svelte"; + +import type { RadReference } from "@app/lib/radUri"; +import { + cobListType, + filePath, + formatReference, + isNodeId, + isOid, + isRepoId, + isSafeRef, + issueType, + parseExplorerUrl, + parseReference, + patchType, + releaseType, +} from "@app/lib/radUri"; +import { explorerLink, formatOid, truncateId } from "@app/lib/utils"; + +export type Entity = + | { type: "node"; nid: string } + | { type: "repo"; rid: string } + | { + type: "cob"; + kind: "issue" | "patch" | "release"; + rid: string; + oid: string; + } + | { type: "commit"; rid: string; oid: string }; + +const cobTypes = { + issue: issueType, + patch: patchType, + release: releaseType, +}; + +export function toEntity(reference: RadReference): Entity | undefined { + if (reference.type === "did") return { type: "node", nid: reference.node }; + + const { repo, resource, namespace, query, fragment } = reference.uri; + if (namespace || query || fragment !== undefined) return undefined; + const rid = `rad:${repo}`; + if (!resource) return { type: "repo", rid }; + + if (resource.type === "commit" && isOid(resource.ref)) { + return { type: "commit", rid, oid: resource.ref }; + } + if (resource.type === "cob" && resource.oid !== undefined) { + if (resource.typeName === issueType) { + return { type: "cob", kind: "issue", rid, oid: resource.oid }; + } + if (resource.typeName === patchType) { + return { type: "cob", kind: "patch", rid, oid: resource.oid }; + } + if (resource.typeName === releaseType) { + return { type: "cob", kind: "release", rid, oid: resource.oid }; + } + } + + return undefined; +} + +export function toRadReference(target: Entity): RadReference { + if (target.type === "node") return { type: "did", node: target.nid }; + + const repo = target.rid.replace(/^rad:/, ""); + switch (target.type) { + case "repo": + return { type: "uri", uri: { repo } }; + case "commit": + return { + type: "uri", + uri: { repo, resource: { type: "commit", ref: target.oid } }, + }; + case "cob": + return { + type: "uri", + uri: { + repo, + resource: { + type: "cob", + typeName: cobTypes[target.kind], + oid: target.oid, + }, + }, + }; + } +} + +export function parseEntityHref(href: string): Entity | undefined { + const trimmed = href.trim(); + const reference = parseReference(trimmed) ?? parseExplorerUrl(trimmed); + + return reference ? toEntity(reference) : undefined; +} + +export function parseBareIdentifier(token: string): Entity | undefined { + if (isNodeId(token)) return { type: "node", nid: token }; + if (isRepoId(token)) return { type: "repo", rid: `rad:${token}` }; + + return undefined; +} + +const maximumReferenceLength = 512; + +const bareReferencePattern = + /^(?:did:key:|rad:)[A-Za-z0-9._~!$&'()*+,;=:@/?#%-]{1,513}/; + +const prosePunctuation = new Set(["'", '"', ",", ";", "!", "?", "(", ")", "*"]); + +function isAlphanumeric(character: string): boolean { + return /[0-9A-Za-z]/.test(character); +} + +// Only the start of a valid reference counts: the markdown lexer copies the +// rest of the input at every start it is given, so offering each `rad:` of a +// long hostile run would make rendering quadratic. +export function bareReferenceStart(src: string): number | undefined { + const candidate = /(^|[^0-9A-Za-z])(?:did:key:z|rad:)/g; + for (let match = candidate.exec(src); match; match = candidate.exec(src)) { + const index = match.index + match[1].length; + if (matchBareReference(src.slice(index, index + maximumReferenceLength))) { + return index; + } + candidate.lastIndex = index + 1; + } + + return undefined; +} + +export function matchBareReference( + src: string, +): { raw: string; reference: RadReference } | undefined { + const raw = bareReferencePattern.exec(src)?.[0]; + // Each retry below parses again, so an unbounded run of punctuation in a + // comment would take quadratic time to render. + if (!raw || raw.length > maximumReferenceLength) return undefined; + + let end = raw.length; + for (;;) { + while (end > 0 && !isAlphanumeric(raw[end - 1])) end--; + const candidate = raw.slice(0, end); + const reference = parseReference(candidate); + if (reference) return { raw: candidate, reference }; + + do end--; + while (end > 0 && !prosePunctuation.has(raw[end])); + if (end <= 0) return undefined; + } +} + +export function referenceRoute(reference: RadReference): RepoRoute | undefined { + if (reference.type !== "uri") return undefined; + const { repo, namespace, resource } = reference.uri; + if (resource && "ref" in resource && !isSafeRef(resource.ref)) { + return undefined; + } + const rid = `rad:${repo}`; + + const file = filePath(reference); + if (file) { + return { + resource: "repo.home", + rid, + peer: namespace, + revision: shortRevision(file.revision), + path: file.path || undefined, + }; + } + + if (!resource) return { resource: "repo.home", rid, peer: namespace }; + if (resource.type === "commit" && isOid(resource.ref)) { + return { resource: "repo.commit", rid, commit: resource.ref }; + } + if (resource.type === "cob" && resource.oid !== undefined) { + if (resource.typeName === issueType) { + return { + resource: "repo.issue", + rid, + issue: resource.oid, + status: "all", + }; + } + if (resource.typeName === patchType) { + return { + resource: "repo.patch", + rid, + patch: resource.oid, + status: undefined, + reviewId: undefined, + }; + } + if (resource.typeName === releaseType) { + return { resource: "repo.release", rid, release: resource.oid }; + } + } + if (resource.type === "commit" || resource.type === "tag") { + return { + resource: "repo.home", + rid, + peer: namespace, + revision: shortRevision(resource.ref), + }; + } + + const typeName = cobListType(reference); + if (typeName === issueType) { + return { resource: "repo.issues", rid, status: "all" }; + } + if (typeName === patchType) { + return { resource: "repo.patches", rid, status: undefined }; + } + if (typeName === releaseType) { + return { resource: "repo.releases", rid }; + } + + return undefined; +} + +export interface LinkDescription { + label: string; + primary: string; + secondary: string; + icon: ComponentProps["name"]; +} + +const cobLists: Record< + string, + { label: string; icon: LinkDescription["icon"] } +> = { + [issueType]: { label: "Issues", icon: "issue" }, + [patchType]: { label: "Patches", icon: "patch" }, + [releaseType]: { label: "Releases", icon: "parcel" }, +}; + +export function describeLink( + reference: RadReference, + repoName: string, + peerName?: string, +): LinkDescription | undefined { + if (reference.type !== "uri") return undefined; + const { resource, namespace, fragment } = reference.uri; + if (resource && "ref" in resource && !isSafeRef(resource.ref)) { + return undefined; + } + + const file = filePath(reference); + if (file) { + const revision = shortRevision(file.revision); + const shown = isOid(revision) ? formatOid(revision) : revision; + if (!file.path) { + return { + label: `${repoName}: ${shown}`, + primary: shown, + secondary: repoName, + icon: "folder", + }; + } + const line = fragment ? `#${fragment}` : ""; + return { + label: `${repoName}: ${file.path}${line}`, + primary: `${file.path}${line}`, + secondary: shown, + icon: "document", + }; + } + + const typeName = cobListType(reference); + const list = typeName ? cobLists[typeName] : undefined; + if (list) { + return { + label: `${repoName}: ${list.label.toLowerCase()}`, + primary: list.label, + secondary: repoName, + icon: list.icon, + }; + } + + if ( + resource?.type === "cob" && + resource.typeName === releaseType && + resource.oid !== undefined + ) { + const release = `release ${formatOid(resource.oid)}`; + return { + label: `${repoName}: ${release}`, + primary: `Release ${formatOid(resource.oid)}`, + secondary: repoName, + icon: "parcel", + }; + } + + if ( + (resource?.type === "commit" && !isOid(resource.ref)) || + resource?.type === "tag" + ) { + const revision = shortRevision(resource.ref); + return { + label: `${repoName}: ${revision}`, + primary: revision, + secondary: repoName, + icon: resource.type === "tag" ? "label" : "branch", + }; + } + + if (!resource && namespace) { + const peer = peerName ?? truncateId(namespace); + return { + label: `${repoName}: ${peer}`, + primary: peer, + secondary: repoName, + icon: "repository", + }; + } + + return undefined; +} + +function shortRevision(ref: string): string { + return ref.replace(/^refs\/(?:heads|tags)\//, ""); +} + +export function entityUri(target: Entity): string { + return formatReference(toRadReference(target)); +} + +export function entityUrl(target: Entity, config: Config): string | undefined { + return explorerLink(toRadReference(target), config); +} + +export function entityMarkdown(target: Entity, label: string): string { + return referenceMarkdown(toRadReference(target), label); +} + +export function referenceMarkdown( + reference: RadReference, + label: string, +): string { + const escaped = label.replace(/[[\]\\]/g, character => `\\${character}`); + + return `[${escaped}](${formatReference(reference)})`; +} diff --git a/desktop/src/lib/menuNavigation.ts b/desktop/src/lib/menuNavigation.ts new file mode 100644 index 000000000..c1f277389 --- /dev/null +++ b/desktop/src/lib/menuNavigation.ts @@ -0,0 +1,19 @@ +export function menuFocusIndex( + key: string, + current: number, + count: number, +): number | undefined { + const wrap = (index: number) => ((index % count) + count) % count; + switch (key) { + case "ArrowDown": + return wrap(current + 1); + case "ArrowUp": + return current === -1 ? count - 1 : wrap(current - 1); + case "Home": + return 0; + case "End": + return count - 1; + default: + return undefined; + } +} diff --git a/desktop/src/lib/mermaid.ts b/desktop/src/lib/mermaid.ts new file mode 100644 index 000000000..ff7747b8c --- /dev/null +++ b/desktop/src/lib/mermaid.ts @@ -0,0 +1,122 @@ +export type DiagramTheme = "dark" | "light"; + +export type RenderDiagram = ( + source: string, + theme: DiagramTheme, +) => Promise; + +// Mermaid is heavy and `initialize` mutates a process wide singleton, so load +// it lazily and share the one instance across every document. +let mermaidPromise: Promise | undefined; +let mermaidTheme: DiagramTheme | undefined; + +async function loadMermaid(theme: DiagramTheme) { + mermaidPromise ??= import("mermaid").then( + ({ default: mermaid }) => mermaid, + error => { + // Forget the failed import, so that the next diagram tries again. + mermaidPromise = undefined; + throw error; + }, + ); + const mermaid = await mermaidPromise; + + // `initialize` replaces the entire site config, so it is passed in full and + // only re-run when the app theme changed since the last diagram. + if (mermaidTheme !== theme) { + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + // Render our own warning instead of letting mermaid append its + // "Syntax error" graphic to the document. + suppressErrorRendering: true, + theme: theme === "dark" ? "dark" : "default", + }); + mermaidTheme = theme; + } + + return mermaid; +} + +export const renderDiagram: RenderDiagram = async (source, theme) => { + const mermaid = await loadMermaid(theme); + const { svg } = await mermaid.render( + `mermaid-${crypto.randomUUID()}`, + source, + ); + return svg; +}; + +/** + * Replaces the mermaid code blocks in `container` with diagrams, and redraws + * the diagrams drawn for another theme. A block that can't be drawn stays as + * code, under a warning that `decorateWarning` can add to. + */ +export async function renderMermaidBlocks( + container: HTMLElement, + theme: DiagramTheme, + { + render = renderDiagram, + decorateWarning, + }: { + render?: RenderDiagram; + decorateWarning?: (warning: HTMLElement) => void; + } = {}, +) { + for (const code of container.querySelectorAll("pre code.language-mermaid")) { + if (code.closest(".mermaid-block")) { + continue; + } + const pre = code.parentElement as HTMLElement; + const target = pre.closest(".pre-wrapper") ?? pre; + const block = document.createElement("div"); + block.classList.add("mermaid-block"); + block.dataset.source = code.textContent ?? ""; + target.replaceWith(block); + block.appendChild(target); + } + + const pending: Promise[] = []; + for (const block of container.querySelectorAll( + ".mermaid-block", + )) { + if (block.dataset.theme === theme) { + continue; + } + block.dataset.theme = theme; + pending.push(renderBlock(block, theme, render, decorateWarning)); + } + await Promise.all(pending); +} + +async function renderBlock( + block: HTMLElement, + theme: DiagramTheme, + render: RenderDiagram, + decorateWarning: ((warning: HTMLElement) => void) | undefined, +) { + try { + const svg = await render(block.dataset.source ?? "", theme); + // Another theme may have been asked for while this one was drawn. + if (block.dataset.theme !== theme) { + return; + } + const diagram = document.createElement("div"); + diagram.classList.add("mermaid-diagram"); + diagram.innerHTML = svg; + block.replaceChildren(diagram); + } catch (error) { + console.warn("Not able to render mermaid diagram", error); + // A diagram drawn for the previous theme is still better than code. + if (block.querySelector(".mermaid-diagram, .mermaid-error")) { + return; + } + const warning = document.createElement("div"); + warning.classList.add("mermaid-error"); + decorateWarning?.(warning); + const message = document.createElement("span"); + message.textContent = "Couldn't render diagram"; + warning.appendChild(message); + block.prepend(warning); + } +} diff --git a/desktop/src/lib/modal.ts b/desktop/src/lib/modal.ts new file mode 100644 index 000000000..e5a9b3e93 --- /dev/null +++ b/desktop/src/lib/modal.ts @@ -0,0 +1,89 @@ +import type { Component, ComponentProps } from "svelte"; + +import { derived, get, writable } from "svelte/store"; + +import { closeFocused } from "@app/components/Popover.svelte"; + +type HideCallback = () => void; + +type Modal = { + component: Component; + props: Record; + hideCallback?: HideCallback; + disableHide?: boolean; + disableScrimClose?: boolean; +}; + +const store = writable(undefined); +export const modalStore = derived(store, s => s); + +export function enableHide() { + store.update(s => { + if (s) { + return { ...s, disableHide: false }; + } + }); +} + +export function disableHide() { + store.update(s => { + if (s) { + return { ...s, disableHide: true }; + } + }); +} + +export function forceHide(): void { + const stored = get(modalStore); + if (!stored) { + return; + } + if (stored.hideCallback) { + stored.hideCallback(); + } + store.set(undefined); +} + +export function hide(): void { + const stored = get(modalStore); + if (!stored || stored.disableHide) { + return; + } + + if (stored.hideCallback) { + stored.hideCallback(); + } + store.set(undefined); +} + +interface ShowArgs { + component: T; + props: ComponentProps; + hideCallback?: HideCallback; + disableScrimClose?: boolean; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function show>(args: ShowArgs): void { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + closeFocused(); + store.set({ ...args, disableScrimClose: args.disableScrimClose ?? false }); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function toggle>(args: ShowArgs): void { + const stored = get(modalStore); + + if (stored?.disableHide) { + return; + } + + if (stored && stored.component === args.component) { + hide(); + return; + } + + show(args); +} diff --git a/desktop/src/lib/mutexExecutor.ts b/desktop/src/lib/mutexExecutor.ts new file mode 100644 index 000000000..d6d71de3e --- /dev/null +++ b/desktop/src/lib/mutexExecutor.ts @@ -0,0 +1,106 @@ +import * as Bacon from "baconjs"; + +// A task executor that runs only one task concurrently. If a new task +// is run, any previously running task is aborted and the promise +// returned from `run()` will return undefined. +// +// import * as mutexExecutor from "ui/src/mutexExecutor" +// const executor = mutexExecutor.create() +// const first = await executor.run(async () => { +// await sleep(1000) +// return "first" +// }) +// const second = await executor.run(async () => "second") +// +// In the example above the promise `first` will resolve to `undefined` +// while the promise `second` will resolve to "second". +// +// If the first tasks throws after the second task has run the +// behavior is the same. +// +// const first = await executor.run(async () => { +// await sleep(1000) +// throw new Error() +// }) +// +// The task call back receives an AbortSignal as a parameter. The abort +// event is emitted when another task is run. +export function create(): MutexExecutor { + return new MutexExecutor(); +} + +// A worker that asynchronously process one item at a time and provides +// the result as an event stream. +// +// import * as mutexExecutor from "ui/src/mutexExecutor" +// const worker = mutexExecutor.createWorker(async (value) => { +// await sleep(1000) +// return value +// }) +// +// const firstPromise = worker.output.firstToPromise() +// worker.push("first) +// assert.equal(await firstPromise, "first") +// +// When an item is submitted to the worker while the previous items is +// still being processed, the result of the first item will not be +// emitted to `worker.output`. Instead, only the last item will be +// emitted. +export function createWorker( + fn: (x: In, abortSignal: AbortSignal) => Promise, +): MutexWorker { + return new MutexWorker(fn); +} + +class MutexExecutor { + private runningTaskId = 0; + private abortController: AbortController | null = null; + + public async run( + f: (abortSignal: AbortSignal) => Promise, + ): Promise { + this.runningTaskId += 1; + const taskId = this.runningTaskId; + + if (this.abortController) { + this.abortController.abort(); + } + this.abortController = new AbortController(); + return f(this.abortController.signal).then( + data => { + if (this.runningTaskId === taskId) { + return data; + } else { + return undefined; + } + }, + err => { + if (this.runningTaskId === taskId) { + throw err; + } else { + return undefined; + } + }, + ); + } +} + +class MutexWorker { + private outputBus = new Bacon.Bus(); + private executor = new MutexExecutor(); + + public output: Bacon.EventStream; + + public constructor( + private fn: (x: In, abortSignal: AbortSignal) => Promise, + ) { + this.output = this.outputBus.toEventStream(); + } + + public async submit(x: In): Promise { + const output = await this.executor.run(abort => this.fn(x, abort)); + if (output !== undefined) { + this.outputBus.push(output); + } + } +} diff --git a/desktop/src/lib/notification.ts b/desktop/src/lib/notification.ts new file mode 100644 index 000000000..eaa46ee8a --- /dev/null +++ b/desktop/src/lib/notification.ts @@ -0,0 +1,271 @@ +import type { RepoRoute } from "@app/views/repo/router"; +import type { ActionWithAuthor } from "@bindings/cob/inbox/ActionWithAuthor"; +import type { NotificationItem } from "@bindings/cob/inbox/NotificationItem"; +import type { Action as IssueAction } from "@bindings/cob/issue/Action"; +import type { Action as PatchAction } from "@bindings/cob/patch/Action"; + +import isEqual from "lodash/isEqual"; +import uniqWith from "lodash/uniqWith"; + +import { + emojiToTwemoji, + formatOid, + issueStatusBackgroundColor, + issueStatusColor, + patchStatusBackgroundColor, + patchStatusColor, + pluralize, +} from "@app/lib/utils"; + +export type Action = + ActionWithAuthor | ActionWithAuthor; + +// N.b. I have taken the `%` char as indicator for a `txt-id` class +export function createSummary( + a: Action[], + kind: "issue" | "patch", + oid: string, + count: number, +) { + const lastAction = a[a.length - 1]; + let summary = `${lastAction.type} not implemented!`; + + function times(count: number) { + return count > 1 ? `${count} times` : ""; + } + + if (lastAction.oid === oid) { + summary = `opened ${kind} %${formatOid(lastAction.oid)}%`; + } else if (lastAction.type === "comment") { + summary = `left ${count > 1 ? count : "a"} ${pluralize("comment", count)}`; + } else if (lastAction.type === "revision") { + const revisions = a.map(i => `%${formatOid(i.oid)}%`).slice(0, 10); + summary = `created ${pluralize("revision", count)} ${[ + ...revisions, + ...(a.length >= 11 ? ["%…%"] : []), + ].join(", ")}`; + } else if (lastAction.type === "merge") { + summary = `merged ${pluralize("revision", count)} %${formatOid(lastAction.revision)}%`; + } else if (lastAction.type === "edit" && kind === "issue") { + summary = `edited issue${count ? times(count) : ""}`; + } else if (lastAction.type === "edit" && kind === "patch") { + summary = `edited ${pluralize("revision", count)} %${formatOid(lastAction.oid)}%`; + } else if (lastAction.type === "revision.edit") { + summary = `edited ${pluralize("revision", count)} %${formatOid(lastAction.revision)}%`; + } else if (lastAction.type === "lifecycle" && count > 1) { + summary = `changed to ${lastAction.state.status} and ${count} more changes`; + } else if ( + lastAction.type === "lifecycle" && + lastAction.state.status === "draft" + ) { + summary = `changed to ${lastAction.state.status}`; + } else if ( + lastAction.type === "lifecycle" && + lastAction.state.status === "open" + ) { + summary = `reopened ${kind}`; + } else if ( + lastAction.type === "lifecycle" && + lastAction.state.status === "archived" + ) { + summary = `archived ${kind}`; + } else if ( + lastAction.type === "lifecycle" && + lastAction.state.status === "closed" && + lastAction.state.reason === "solved" + ) { + summary = `closed ${kind} as solved`; + } else if ( + lastAction.type === "lifecycle" && + lastAction.state.status === "closed" + ) { + summary = `closed ${kind}`; + } else if (lastAction.type === "revision.comment") { + summary = `left ${count > 1 ? count : "a"} ${pluralize("comment", count)}`; + } else if (lastAction.type === "review.comment") { + summary = `left ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`; + } else if (a.every(e => e.type === "comment.react")) { + const reactions = a.map(i => emojiToTwemoji(i.reaction)).slice(0, 10); + summary = `reacted with ${[ + ...reactions, + ...(a.length >= 11 ? ["%…%"] : []), + ].join(", ")} to ${count > 1 ? count : "a"} ${pluralize("comment", count)}`; + } else if (lastAction.type === "comment.edit") { + summary = `edited ${count > 1 ? count : "a"} ${pluralize("comment", count)}`; + } else if (lastAction.type === "review" && lastAction.verdict) { + summary = `${lastAction.verdict}ed revision %${formatOid(lastAction.revision)}% with a review`; + } else if (lastAction.type === "review") { + summary = `left a review with a comment on revision %${formatOid(lastAction.revision)}%`; + } else if (lastAction.type === "assign") { + summary = "changed assigns"; + } else if (lastAction.type === "revision.comment.edit") { + summary = `edited ${count > 1 ? count : "a"} ${pluralize("comment", count)}`; + } else if (lastAction.type === "comment.redact") { + summary = `redacted ${count > 1 ? count : "a"} ${pluralize("comment", count)}`; + } else if (lastAction.type === "label") { + summary = `added ${pluralize("label", count)}`; + } else if (lastAction.type === "review.comment.edit") { + summary = `edited ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`; + } else if (lastAction.type === "review.comment.react") { + summary = `reacted to ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`; + } else if (lastAction.type === "review.comment.redact") { + summary = `redacted ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`; + } else if (lastAction.type === "review.comment.resolve") { + summary = `resolved ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`; + } else if (lastAction.type === "review.comment.unresolve") { + summary = `unresolved ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`; + } else if (lastAction.type === "review.edit") { + summary = `edited review ${times(count)}`; + } else if (lastAction.type === "review.redact") { + summary = `redacted ${count > 1 ? count : "a"} ${pluralize("review", count)}`; + } else if (lastAction.type === "revision.comment.react") { + summary = `reacted to ${count > 1 ? count : "a"} revision ${pluralize("comment", count)}`; + } else if (lastAction.type === "revision.comment.redact") { + summary = `redacted ${count > 1 ? count : "a"} revision ${pluralize("comment", count)}`; + } else if (lastAction.type === "revision.react") { + summary = `reacted to ${count > 1 ? count : "a"} ${pluralize("revision", count)}`; + } else if (lastAction.type === "revision.redact") { + summary = `redacted ${count > 1 ? count : "a"} ${pluralize("revision", count)}`; + } + + return summary.replaceAll(/[%](\S+)[%]/g, '$1'); +} + +export function compressActions( + actions: Action[], + kind: "issue" | "patch", + oid: string, +) { + const result: { + summary: string; + oid: string; + items: Action[]; + }[] = []; + + let currentGroup: Action[] = []; + + for (const action of actions) { + if (currentGroup.length === 0) { + currentGroup.push(action); + continue; + } + + const last = currentGroup[currentGroup.length - 1]; + const sameAuthorDid = last.author.did === action.author.did; + const sameType = + last.type === action.type || + (last.type === "revision.edit" && action.type === "edit") || + (action.type === "revision.edit" && last.type === "edit") || + action.oid === oid; + + if (sameAuthorDid && sameType) { + currentGroup.push(action); + } else { + const summaryStr = createSummary( + currentGroup, + kind, + oid, + currentGroup.length, + ); + + result.push({ + summary: summaryStr, + oid, + items: currentGroup, + }); + + currentGroup = [action]; + } + } + + // Summarize any open actions. + if (currentGroup.length > 0) { + const summaryStr = createSummary( + currentGroup, + kind, + oid, + currentGroup.length, + ); + result.push({ + summary: summaryStr, + oid, + items: currentGroup, + }); + } + + return result; +} + +// Actions of a notified COB, newest first. Notifications for the same COB can +// cover overlapping ranges, so an action may appear in several of them. Only +// identical actions are duplicates: one operation can carry several actions of +// the same type, such as the code comments of a published review. +export function notificationActions(items: { actions: Action[] }[]): Action[] { + return uniqWith( + items.flatMap(item => item.actions), + isEqual, + ).sort((a, b) => b.timestamp - a.timestamp); +} + +export function notificationIcon( + item: NotificationItem | undefined, +): + | "issue" + | "issue-closed" + | "patch" + | "patch-draft" + | "patch-archived" + | "patch-merged" { + if (item?.type === "issue") { + return item.status.status === "open" + ? "issue" + : `issue-${item.status.status}`; + } + if (item?.type === "patch" && item.status.status !== "open") { + return `patch-${item.status.status}`; + } + return "patch"; +} + +export function notificationStatusColor(item: NotificationItem | undefined): { + color: string; + background: string; +} { + switch (item?.type) { + case "patch": + return { + color: patchStatusColor[item.status.status], + background: patchStatusBackgroundColor[item.status.status], + }; + case "issue": + return { + color: issueStatusColor[item.status.status], + background: issueStatusBackgroundColor[item.status.status], + }; + default: + return { + color: "var(--color-text-secondary)", + background: "var(--color-surface-subtle)", + }; + } +} + +export function notificationRoute( + rid: string, + item: NotificationItem | undefined, +): RepoRoute | undefined { + switch (item?.type) { + case "patch": + return { + resource: "repo.patch", + rid, + patch: item.id, + status: undefined, + reviewId: undefined, + }; + case "issue": + return { resource: "repo.issue", rid, issue: item.id, status: "all" }; + default: + return undefined; + } +} diff --git a/desktop/src/lib/notificationCount.svelte.ts b/desktop/src/lib/notificationCount.svelte.ts new file mode 100644 index 000000000..2ff714d33 --- /dev/null +++ b/desktop/src/lib/notificationCount.svelte.ts @@ -0,0 +1,14 @@ +import { invoke } from "@app/lib/invoke"; + +export const notificationCount = $state({ value: 0 }); + +export async function refreshNotificationCount() { + notificationCount.value = await invoke("notification_count"); +} + +export function badgeCount( + count: number, + enabled: boolean, +): number | undefined { + return enabled && count > 0 ? count : undefined; +} diff --git a/desktop/src/lib/paginatedList.svelte.ts b/desktop/src/lib/paginatedList.svelte.ts new file mode 100644 index 000000000..7f791ef5c --- /dev/null +++ b/desktop/src/lib/paginatedList.svelte.ts @@ -0,0 +1,216 @@ +import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery"; + +import type { ListCacheSnapshot } from "@app/lib/listState"; +import { readListState, saveListState } from "@app/lib/listState"; +import * as mutexExecutor from "@app/lib/mutexExecutor"; +import * as router from "@app/lib/router"; + +export interface PaginatedListOptions { + // Scroll-restoration cache key; changes when the list's filter changes. + key: () => string; + // The route loader's freshly fetched first page (reactive prop access). + page: () => PaginatedQuery; + // Fetch a page from the backend; `take === undefined` means all rows. + fetchPage: ( + skip: number, + take: number | undefined, + ) => Promise>; + pageSize: number; + // Stable identity used to dedupe overlapping pages. + id: (item: T) => string; + // Skip persisting scroll state (e.g. while a fuzzy filter is active, since + // the rendered rows don't match the cached item list). + skipPersist?: () => boolean; +} + +// Paginated list state shared by the Issues/Patches/Commits views: an +// incrementally loaded item list with mutex-guarded page fetches, plus scroll +// restoration for back/forward navigation. Must be called during component +// init (it registers an $effect). +export function createPaginatedList(opts: PaginatedListOptions) { + // Restore the prior list and scroll position only when arriving via + // back/forward; a fresh navigation (sidebar, links) starts from the top. + const restored = router.isHistoryNavigation() + ? readListState(opts.key()) + : undefined; + + // The snapshot is trusted as a scroll anchor, never as data — its contents + // are as old as the moment the user left. The route loader re-fetches the + // first page on every navigation (including history), so a snapshot no + // deeper than one page is replaced by that fresh page outright, at no + // extra cost. A deeper snapshot seeds the list for instant paint and the + // whole restored window is refetched in the background (see the + // revalidate call below). + let seedItems: T[]; + let seedMore: boolean; + let staleWindow: number | undefined; + if (restored !== undefined && restored.items.length > opts.pageSize) { + seedItems = restored.items; + seedMore = restored.more; + staleWindow = restored.items.length; + } else { + seedItems = opts.page().content; + seedMore = opts.page().more; + } + + let items = $state(seedItems); + // Rows fetched so far — used as the pagination offset. Tracked separately + // from items.length because appends are deduped (overlapping pages, e.g. + // when the list grows underneath us, must still advance the offset). + let cursor = seedItems.length; + let more = $state(seedMore); + let loadingMore = $state(false); + let activeKey = opts.key(); + + const loader = mutexExecutor.create(); + const abort = async (): Promise => undefined; + + // Parent reuses the view across filter changes; a key change resets the + // pagination state from the loader's fresh page. + $effect(() => { + const key = opts.key(); + const fresh = opts.page(); + // Skip the initial mount (state is seeded above, possibly restored); only + // reset when the key actually changes. + if (key === activeKey) return; + activeKey = key; + items = fresh.content; + cursor = fresh.content.length; + more = fresh.more; + // Abort any in-flight loadMore so it cannot append a page from the + // previous filter onto the just-reset items. The aborted call leaves + // `loadingMore` to its superseder — and here that superseder is this + // abort task, which never clears it, so reset the flag explicitly or + // load-more stays disabled for the new list forever. + void loader.run(abort); + loadingMore = false; + }); + + async function loadMore(all: boolean = false): Promise { + if (!more) return; + loadingMore = true; + let superseded = false; + try { + const page = await loader.run(async () => + opts.fetchPage(all ? 0 : cursor, all ? undefined : opts.pageSize), + ); + + // Superseded by a newer load (e.g. fuzzy-focus triggered a load-all). + // Leave items/more alone for the new call. The flag stays set too: the + // newer call owns it now, and clearing it here would let the + // virtualizer's auto load-more re-fire and abort that call in turn. + if (page === undefined) { + superseded = true; + return; + } + + more = page.more; + if (all) { + items = page.content; + cursor = page.content.length; + } else { + // Drop ids already shown so duplicate keys never reach the virtual + // list (which would leave blank, persistent gaps); still advance the + // offset by the rows fetched so paging keeps moving forward. + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local set, never rendered + const seen = new Set(items.map(opts.id)); + items = [...items, ...page.content.filter(i => !seen.has(opts.id(i)))]; + cursor += page.content.length; + } + if (page.content.length === 0) more = false; + } catch (error) { + // Callers fire-and-forget (the virtualizer's load-more trigger), so + // failures are logged here instead of surfacing as unhandled + // rejections; the finally below re-enables the next attempt. + console.error("Loading more list items failed: ", error); + } finally { + if (!superseded) { + loadingMore = false; + } + } + } + + // Refetch the first `window` rows in one request and replace the list + // wholesale: stale-while-revalidate after a history restore, first page + // after a cache rebuild. `loadingMore` stays set for the duration, which + // both surfaces the refresh and keeps the virtualizer's auto load-more + // from racing it and appending a page onto items about to be replaced. + // A newer load (e.g. fuzzy-focus load-all) superseding the refetch is + // fine: every competitor fetches a fresher, same-or-larger window. + async function revalidate(window: number = opts.pageSize): Promise { + loadingMore = true; + let superseded = false; + try { + const page = await loader.run(async () => opts.fetchPage(0, window)); + if (page === undefined) { + superseded = true; + return; + } + items = page.content; + cursor = page.content.length; + more = page.more; + } catch (error) { + // The history-restore call site is fire-and-forget; a failed refresh + // logs and leaves the snapshot on screen rather than rejecting + // unhandled. + console.error("Revalidating the list failed: ", error); + } finally { + if (!superseded) { + loadingMore = false; + } + } + } + + if (staleWindow !== undefined) { + void revalidate(staleWindow); + } + + // Persist the loaded list + scroll position so back/forward can restore it. + // Only the unfiltered list is cached, so its length matches virtua's size + // snapshot. + function persistScroll(state: { + scrollOffset: number; + cache: ListCacheSnapshot; + }): void { + if (opts.skipPersist?.()) return; + saveListState(opts.key(), { + items: [...items], + more, + scrollOffset: state.scrollOffset, + cache: state.cache, + }); + } + + // The restore values are handed out once: VirtualList applies them on its + // first mount and reports back via consumeRestoredScroll. A later remount + // of the same list (e.g. the empty-fuzzy-filter branch swapping it out and + // back) must start fresh instead of re-applying a stale offset against + // items that may have changed since. + let restoredScroll = restored + ? { scrollOffset: restored.scrollOffset, cache: restored.cache } + : undefined; + + return { + get items() { + return items; + }, + get more() { + return more; + }, + get loadingMore() { + return loadingMore; + }, + get initialScrollOffset() { + return restoredScroll?.scrollOffset; + }, + get initialCache() { + return restoredScroll?.cache; + }, + consumeRestoredScroll() { + restoredScroll = undefined; + }, + loadMore, + revalidate, + persistScroll, + }; +} diff --git a/desktop/src/lib/patchActivityContext.ts b/desktop/src/lib/patchActivityContext.ts new file mode 100644 index 000000000..3c9cb193e --- /dev/null +++ b/desktop/src/lib/patchActivityContext.ts @@ -0,0 +1,44 @@ +import { getContext, setContext } from "svelte"; + +export interface PatchUserActivity { + isAuthor: boolean; + isDelegate: boolean; + revisionCount: number; + commentCount: number; + reviewCount: number; + patchesAuthored: number; + issuesAuthored: number; +} + +export interface PatchActivitySource { + /// Pure lookup: derives the counts from data already in hand. Safe to call + /// from a `$derived`. + resolve: (publicKey: string) => PatchUserActivity | undefined; + /// Loads the repo-wide figures the lookup needs for `patchesAuthored` and + /// `issuesAuthored`. Separate from `resolve` because it fetches, and a + /// `$derived` is no place to start one; call it from an effect when a card + /// is actually opened. + prefetch: () => void; +} + +const KEY = Symbol("patch-activity-context"); + +export function setPatchActivitySource(source: PatchActivitySource) { + setContext(KEY, source); +} + +export function getPatchActivitySource(): PatchActivitySource | undefined { + return getContext(KEY); +} + +/// The current source packaged for `mount()`. A comment thread superimposed on a +/// diff is mounted as its own component tree, outside the component hierarchy +/// that set this up, and its author cards would otherwise lose their figures. +export function forwardPatchActivityContext(): Map { + const context = new Map(); + const source = getPatchActivitySource(); + if (source) { + context.set(KEY, source); + } + return context; +} diff --git a/desktop/src/lib/patchContributions.ts b/desktop/src/lib/patchContributions.ts new file mode 100644 index 000000000..836d97d06 --- /dev/null +++ b/desktop/src/lib/patchContributions.ts @@ -0,0 +1,30 @@ +import type { Revision } from "@bindings/cob/patch/Revision"; + +import type { PatchUserActivity } from "@app/lib/patchActivityContext"; + +/// What one person contributed to a patch, counted from the revisions in hand. +/// +/// A revision's discussion holds only real comments — its description is a +/// field of its own — and the same is true of a review's summary versus its +/// code comments, so nothing has to be excluded from either count. +export function patchContributions( + revisions: Revision[], + did: string, +): Pick { + let revisionCount = 0; + let commentCount = 0; + let reviewCount = 0; + for (const rev of revisions) { + if (rev.author.did === did) revisionCount += 1; + for (const comment of rev.discussion ?? []) { + if (comment.author.did === did) commentCount += 1; + } + for (const review of rev.reviews ?? []) { + if (review.author.did === did) reviewCount += 1; + for (const comment of review.comments ?? []) { + if (comment.author.did === did) commentCount += 1; + } + } + } + return { revisionCount, commentCount, reviewCount }; +} diff --git a/desktop/src/lib/patchCounts.svelte.ts b/desktop/src/lib/patchCounts.svelte.ts new file mode 100644 index 000000000..aada80736 --- /dev/null +++ b/desktop/src/lib/patchCounts.svelte.ts @@ -0,0 +1,41 @@ +import type { PatchStatus } from "@app/views/repo/router"; +import type { ProjectPayload } from "@bindings/repo/ProjectPayload"; + +import sum from "lodash/sum"; + +export const patchCounts = $state< + Record +>({ + draft: { sidebar: null, total: null }, + open: { sidebar: null, total: null }, + archived: { sidebar: null, total: null }, + merged: { sidebar: null, total: null }, + all: { sidebar: null, total: null }, +}); + +export function patchCountMismatch(status?: PatchStatus) { + return ( + patchCounts[status || "all"].total !== patchCounts[status || "all"].sidebar + ); +} + +export function resetPatchCounts() { + Object.values(patchCounts).forEach(count => { + count.sidebar = null; + count.total = null; + }); +} + +export function updatePatchCounts( + itemCount: number, + sidebarCount: ProjectPayload["meta"]["patches"], + status?: PatchStatus, +) { + if (status) { + patchCounts[status].total = itemCount; + patchCounts[status].sidebar = sidebarCount[status]; + } else { + patchCounts["all"].total = itemCount; + patchCounts["all"].sidebar = sum(Object.values(sidebarCount)); + } +} diff --git a/desktop/src/lib/patchSlice.ts b/desktop/src/lib/patchSlice.ts new file mode 100644 index 000000000..9d22d1b5d --- /dev/null +++ b/desktop/src/lib/patchSlice.ts @@ -0,0 +1,158 @@ +/// Cutting a single-file unified diff down to the few lines a code comment is +/// about, as text. +/// +/// Text rather than the structured `Diff` because that is what `@pierre/diffs` +/// renders from: a snippet is just a smaller patch, and a patch with one hunk in +/// it parses exactly like any other. The alternative — slicing the structured +/// diff and asking the renderer to draw a hunk out of context — is what this +/// replaced. + +/// `@@ -oldStart,oldCount +newStart,newCount @@`, with the counts optional (git +/// omits them for a single line) and anything after the closing `@@` — the +/// enclosing function git sometimes names — ignored. +const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; + +interface Hunk { + /// Kept verbatim so an unclamped hunk goes back out exactly as git wrote it, + /// including the enclosing function name after the closing `@@`. + header: string; + oldStart: number; + newStart: number; + /// Body lines, each still carrying its ` `, `+` or `-` prefix. A + /// `\ No newline at end of file` marker counts as part of the line above it. + lines: string[]; +} + +/// The lines of a patch before its first hunk (the `diff --git`, `index`, `---` +/// and `+++` headers), and then each hunk. +function parse(patch: string): { preamble: string[]; hunks: Hunk[] } { + const preamble: string[] = []; + const hunks: Hunk[] = []; + let current: Hunk | undefined; + for (const line of patch.split("\n")) { + const header = HUNK_HEADER.exec(line); + if (header) { + current = { + header: line, + oldStart: Number(header[1]), + newStart: Number(header[3]), + lines: [], + }; + hunks.push(current); + continue; + } + if (!current) { + preamble.push(line); + continue; + } + // A trailing empty line from the split, and anything after the last hunk + // that is not a body line, is not part of it. + if (line === "") continue; + current.lines.push(line); + } + return { preamble, hunks }; +} + +/// Which line of which side each body line is, so a slice can be renumbered. +/// `undefined` for the side a line does not appear on. +interface Numbered { + line: string; + old?: number; + new?: number; +} + +function number(hunk: Hunk): Numbered[] { + const numbered: Numbered[] = []; + let oldLine = hunk.oldStart; + let newLine = hunk.newStart; + for (const line of hunk.lines) { + if (line.startsWith("\\")) { + // "\ No newline at end of file" — belongs to whichever line came before. + numbered.push({ line }); + continue; + } + if (line.startsWith("+")) { + numbered.push({ line, new: newLine }); + newLine += 1; + } else if (line.startsWith("-")) { + numbered.push({ line, old: oldLine }); + oldLine += 1; + } else { + numbered.push({ line, old: oldLine, new: newLine }); + oldLine += 1; + newLine += 1; + } + } + return numbered; +} + +function join(preamble: string[], header: string, lines: string[]): string { + return [...preamble, header, ...lines, ""].join("\n"); +} + +/// Rebuild a hunk header for a slice of a hunk's lines, since the positions and +/// counts of both sides change with it. +function render(preamble: string[], lines: Numbered[]): string { + let oldStart: number | undefined; + let newStart: number | undefined; + let oldCount = 0; + let newCount = 0; + for (const { old, new: next } of lines) { + if (old !== undefined) { + oldStart ??= old; + oldCount += 1; + } + if (next !== undefined) { + newStart ??= next; + newCount += 1; + } + } + // A hunk with nothing on a side still has to name a position on it, and git + // writes 0 there. + const header = `@@ -${oldCount === 0 ? 0 : (oldStart ?? 1)},${oldCount} +${newCount === 0 ? 0 : (newStart ?? 1)},${newCount} @@`; + return join( + preamble, + header, + lines.map(({ line }) => line), + ); +} + +/// Cut `patch` down to `context` lines either side of `[start, end)` on one +/// side, within the hunk that covers it. +/// +/// `undefined` when no hunk covers the range, which means the comment is +/// anchored outside the diff — a stale comment on a line a later revision +/// changed, say. +export function sliceForRange( + patch: string, + side: "old" | "new", + start: number, + end: number, + context = 3, +): string | undefined { + const { preamble, hunks } = parse(patch); + for (const hunk of hunks) { + const lines = number(hunk); + const covered: number[] = []; + lines.forEach(({ old, new: next }, index) => { + const lineNumber = side === "new" ? next : old; + if (lineNumber !== undefined && lineNumber >= start && lineNumber < end) { + covered.push(index); + } + }); + if (covered.length === 0) continue; + + const first = Math.max(0, covered[0] - context); + const last = Math.min( + lines.length - 1, + covered[covered.length - 1] + context, + ); + // The whole hunk, verbatim, when it is already no bigger than the window — + // which keeps git's own header, including the enclosing function name. + if (first === 0 && last === lines.length - 1) { + return join(preamble, hunk.header, hunk.lines); + } + return render(preamble, lines.slice(first, last + 1)); + } + return undefined; +} diff --git a/desktop/src/lib/patchTimeline.ts b/desktop/src/lib/patchTimeline.ts new file mode 100644 index 000000000..6716fe5ba --- /dev/null +++ b/desktop/src/lib/patchTimeline.ts @@ -0,0 +1,326 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Operation } from "@bindings/cob/Operation"; +import type { Action } from "@bindings/cob/patch/Action"; +import type { Revision } from "@bindings/cob/patch/Revision"; +import type { CodeLocation } from "@bindings/cob/thread/CodeLocation"; +import type { Thread } from "@bindings/cob/thread/Thread"; +import type { Commit } from "@bindings/repo/Commit"; + +import type { ActivityItem, FlattenedAction } from "@app/lib/cobActivity"; +import { flattenActivity } from "@app/lib/cobActivity"; + +export type FlattenedPatchOperation = FlattenedAction; + +export type PatchActivityData = + | { + kind: "op"; + op: FlattenedPatchOperation; + commits?: Commit[]; + reviewThreads?: Thread[]; + reviewComments?: Thread[]; + } + | { + kind: "opened"; + op: FlattenedPatchOperation & { type: "revision" }; + openedAsDraft: boolean; + } + | { + kind: "olderRevisions"; + groupKey: string; + revisionIds: string[]; + count: number; + author?: Author; + expanded: boolean; + }; + +// Shown elsewhere in the patch view, or folded into the entry they change. +const skip = new Set([ + "revision.comment", + "revision.comment.edit", + "revision.comment.redact", + "revision.comment.react", + "revision.react", + "revision.edit", + "revision.redact", + "review.comment", + "review.comment.edit", + "review.comment.redact", + "review.comment.react", + "review.comment.resolve", + "review.comment.unresolve", + "review.edit", + "review.redact", + "review.react", +]); + +// The patch was opened as a draft when its first lifecycle change is a draft +// happening right at creation (the first or second operation), as opposed to +// being converted to draft later in its life. +export function openingDraftOpId( + activity: Operation[], +): string | undefined { + const ops = [...activity].sort((a, b) => a.timestamp - b.timestamp); + const firstLifecycleIdx = ops.findIndex(op => + op.actions.some(a => a.type === "lifecycle"), + ); + if (firstLifecycleIdx === -1 || firstLifecycleIdx > 1) return undefined; + const op = ops[firstLifecycleIdx]; + const lifecycle = op.actions.find(a => a.type === "lifecycle"); + return lifecycle?.type === "lifecycle" && lifecycle.state.status === "draft" + ? op.id + : undefined; +} + +export interface PatchTimelineInput { + activity: Operation[]; + /// In timeline order. + revisions: Revision[]; + commitsByRevision: Record; + threadsByReview: Map[]>; + discussionThreadsByReview: Map[]>; + /// Which folded runs of older revisions the reader expanded, by group key. + expandedRevisionRuns: Record; +} + +/// The patch's activity timeline, built from its operation log. +export function patchTimeline({ + activity, + revisions, + commitsByRevision, + threadsByReview, + discussionThreadsByReview, + expandedRevisionRuns, +}: PatchTimelineInput): ActivityItem[] { + const latestRevisionId = revisions.at(-1)?.id; + const firstRevisionId = revisions[0]?.id; + const olderRevisionIds = new Set( + revisions.filter(r => r.id !== latestRevisionId).map(r => r.id), + ); + const draftOpId = openingDraftOpId(activity); + + const reviewOpsByReviewId = new Map< + string, + FlattenedPatchOperation & { type: "review" } + >(); + const revisionOpsByRevisionId = new Map< + string, + FlattenedPatchOperation & { type: "revision" } + >(); + const redactedRevisionIds = new Set(); + const redactedReviewIds = new Set(); + + const flattened = flattenActivity(activity, { + skip, + onSkipped: action => { + if (action.type === "review.edit") { + const reviewOp = reviewOpsByReviewId.get(action.review); + if (reviewOp) { + if ("verdict" in action) reviewOp.verdict = action.verdict; + if ("summary" in action) reviewOp.summary = action.summary; + if ("labels" in action) reviewOp.labels = action.labels; + } + } else if (action.type === "revision.edit") { + const revisionOp = revisionOpsByRevisionId.get(action.revision); + if (revisionOp) { + revisionOp.description = action.description; + } + } else if (action.type === "revision.redact") { + redactedRevisionIds.add(action.revision); + } else if (action.type === "review.redact") { + redactedReviewIds.add(action.review); + } + }, + onKept: op => { + if (op.type === "review") { + reviewOpsByReviewId.set(op.id, op); + } else if (op.type === "revision") { + revisionOpsByRevisionId.set(op.id, op); + } + }, + }); + + const items: ActivityItem[] = flattened.map(item => { + const op = item.data; + return { + key: item.key, + timestamp: item.timestamp, + data: { + kind: "op", + op, + commits: op.type === "revision" ? commitsByRevision[op.id] : undefined, + reviewThreads: + op.type === "review" ? threadsByReview.get(op.id) : undefined, + reviewComments: + op.type === "review" + ? discussionThreadsByReview.get(op.id) + : undefined, + }, + // A merge draws a filled band and a review draws a card; both need + // the space around them that a run of bare rows deliberately drops. + standalone: op.type === "merge" || op.type === "review", + }; + }); + + const filtered = items.filter(item => { + if (item.data.kind !== "op") return true; + if ( + item.data.op.type === "revision" && + redactedRevisionIds.has(item.data.op.id) + ) { + return false; + } + if ( + item.data.op.type === "review" && + redactedReviewIds.has(item.data.op.id) + ) { + return false; + } + // The opening-draft lifecycle is folded into the "opened a draft patch" + // label on the first revision, so drop the standalone item. + if ( + item.data.op.type === "lifecycle" && + item.data.op.state.status === "draft" && + item.data.op.id === draftOpId + ) { + return false; + } + return true; + }); + filtered.sort((a, b) => a.timestamp - b.timestamp); + + // Place each review immediately after the revision it belongs to, so it + // reads as the next timeline item under that revision rather than floating + // wherever its own timestamp lands. + const reviewsByRevision = new Map< + string, + ActivityItem[] + >(); + for (const item of filtered) { + if (item.data.kind === "op" && item.data.op.type === "review") { + const revId = item.data.op.revision; + const list = reviewsByRevision.get(revId) ?? []; + list.push(item); + reviewsByRevision.set(revId, list); + } + } + const reordered: ActivityItem[] = []; + const placedReviews = new Set(); + for (const item of filtered) { + if (item.data.kind === "op" && item.data.op.type === "review") continue; + reordered.push(item); + if (item.data.kind === "op" && item.data.op.type === "revision") { + const reviews = reviewsByRevision.get(item.data.op.id); + if (reviews) { + reordered.push(...reviews); + reviews.forEach(r => placedReviews.add(r.key)); + } + } + } + // Reviews whose revision is gone (e.g. redacted) keep their original order. + for (const item of filtered) { + if ( + item.data.kind === "op" && + item.data.op.type === "review" && + !placedReviews.has(item.key) + ) { + reordered.push(item); + } + } + items.length = 0; + items.push(...reordered); + + // The patch creation is shown as a standalone "opened patch" marker; the + // first revision itself stays in the timeline below (and folds with other + // revisions). Synthesize the marker from the first revision operation. + const firstRevisionOp = revisionOpsByRevisionId.get(firstRevisionId); + const opened: ActivityItem[] = firstRevisionOp + ? [ + { + key: `opened:${firstRevisionId}`, + timestamp: firstRevisionOp.timestamp, + data: { + kind: "opened", + op: firstRevisionOp, + openedAsDraft: draftOpId !== undefined, + }, + }, + ] + : []; + + const isOlderRevisionItem = (item: ActivityItem) => + item.data.kind === "op" && + item.data.op.type === "revision" && + olderRevisionIds.has(item.data.op.id); + // A review belonging to an older revision folds together with that revision. + const isFoldableReview = (item: ActivityItem) => + item.data.kind === "op" && + item.data.op.type === "review" && + olderRevisionIds.has(item.data.op.revision); + const isFoldable = (item: ActivityItem) => + isOlderRevisionItem(item) || isFoldableReview(item); + const itemOpAuthorDid = (item: ActivityItem) => + item.data.kind === "op" ? item.data.op.author.did : undefined; + + // Fold each maximal run of *consecutive* older revisions by the *same + // author* (and the reviews nested under them) into one " created N + // revisions" toggle. A lifecycle change, comment, or a switch to another + // author breaks the run, so a fold is always attributed to one person. + const folded: ActivityItem[] = []; + let i = 0; + while (i < items.length) { + if (!isFoldable(items[i])) { + folded.push(items[i]); + i += 1; + continue; + } + let j = i; + const runAuthorDid = itemOpAuthorDid(items[i]); + while ( + j < items.length && + isFoldable(items[j]) && + itemOpAuthorDid(items[j]) === runAuthorDid + ) { + j += 1; + } + const run = items.slice(i, j); + const revisionItems = run.filter(isOlderRevisionItem); + if (revisionItems.length < 2) { + // A lone older revision (with its reviews) isn't worth folding. + folded.push(...run); + } else { + const head = run[0]; + const groupKey = `older:${head.data.kind === "op" ? head.data.op.id : head.key}`; + const runExpanded = expandedRevisionRuns[groupKey] ?? false; + const revisionIds = revisionItems + .map(item => (item.data.kind === "op" ? item.data.op.id : undefined)) + .filter((id): id is string => id !== undefined); + // Only attribute the fold to an author when every folded revision is by + // the same person; a mixed-author run stays unattributed so it isn't + // wrongly labelled " created N revisions". + const runAuthors = revisionItems + .map(item => + item.data.kind === "op" ? item.data.op.author : undefined, + ) + .filter((a): a is Author => a !== undefined); + const uniqueDids = new Set(runAuthors.map(a => a.did)); + const commonAuthor = uniqueDids.size === 1 ? runAuthors[0] : undefined; + folded.push({ + key: groupKey, + timestamp: head.timestamp, + data: { + kind: "olderRevisions", + groupKey, + revisionIds, + count: revisionItems.length, + author: commonAuthor, + expanded: runExpanded, + }, + }); + if (runExpanded) { + folded.push(...run); + } + } + i = j; + } + return [...opened, ...folded]; +} diff --git a/desktop/src/lib/pierreComments.ts b/desktop/src/lib/pierreComments.ts new file mode 100644 index 000000000..0b2d922a6 --- /dev/null +++ b/desktop/src/lib/pierreComments.ts @@ -0,0 +1,286 @@ +import type { FileStatus } from "@app/components/diffFileHeaderState.svelte"; +import type { CodeLocation } from "@bindings/cob/thread/CodeLocation"; +import type { CodeRange } from "@bindings/cob/thread/CodeRange"; +import type { Thread } from "@bindings/cob/thread/Thread"; +import type { AnnotationSide, DiffLineAnnotation } from "@pierre/diffs"; + +/// Where a code comment sits in a rendered diff: a side of a line in a file. +/// +/// `line` is an absolute blob line number — the old file's for `deletions`, the +/// new file's for `additions` — which is also how Pierre numbers the two sides +/// of a diff. Pierre renders the backend's exact diff rather than re-diffing, so +/// the two numbering schemes agree and this mapping needs no fuzzy matching. +export interface CommentAnchor { + path: string; + side: AnnotationSide; + line: number; +} + +/// An open new-comment composer. The reader can drag the gutter marker across +/// several lines, so it covers a range; it renders on the last line, which is +/// where the published comment will read back from (see `lineOf`). +export interface ComposerTarget { + path: string; + side: AnnotationSide; + firstLine: number; + lastLine: number; +} + +/// What a rendered annotation slot has to show: everything anchored to one side +/// of one line, since Pierre derives the slot name from the side and line alone +/// and so gives each of them a single slot. +export interface LineAnnotation { + path: string; + side: AnnotationSide; + line: number; + threads: Thread[]; + composer?: ComposerTarget; +} + +/// The line a `CodeRange` points at. +/// +/// A `lines` range's `start` is a line number and its `end` is exclusive, so the +/// line it points at is `end - 1`. That is also what the diff renderer this +/// replaced matched on, which keeps every comment already on the network landing +/// exactly where it does today; a multi-line range therefore renders under its +/// last line. +/// +/// This app never writes a `chars` range. One from another client is placed on +/// its line, on the assumption that `line` counts the same way `lines.start` +/// does. +function lineOf(range: CodeRange): number { + return range.type === "lines" ? range.range.end - 1 : range.line; +} + +/// Which side of which line a comment is anchored to, or `undefined` for a +/// comment with no code location (a plain review or revision comment). +/// +/// A location carries an old range, a new range, or both. Both is not something +/// this app writes; the new side wins, matching what the reader sees on an +/// unchanged line in a unified diff. +export function anchorOf( + location: CodeLocation | null | undefined, +): CommentAnchor | undefined { + if (!location) return undefined; + if (location.new) { + return { + path: location.path, + side: "additions", + line: lineOf(location.new), + }; + } + if (location.old) { + return { + path: location.path, + side: "deletions", + line: lineOf(location.old), + }; + } + return undefined; +} + +/// The `CodeLocation` for a comment written against a composer's line range, in +/// the same numbering `anchorOf` reads back. +export function locationOf( + commit: string, + target: ComposerTarget, +): CodeLocation { + const range: CodeRange = { + type: "lines", + range: { + start: Math.min(target.firstLine, target.lastLine), + end: Math.max(target.firstLine, target.lastLine) + 1, + }, + }; + return { + commit, + path: target.path, + old: target.side === "deletions" ? range : null, + new: target.side === "additions" ? range : null, + }; +} + +/// The lines a comment covers, as text: `R12` for one line on the new side, +/// `L3-L9` for a range on the old one. +export function formatAnchorLines(location: CodeLocation): string | undefined { + const range = lineRangeOf(location); + if (!range) return undefined; + const marker = range.side === "new" ? "R" : "L"; + const end = range.end - 1; + return range.start >= end + ? `${marker}${end}` + : `${marker}${range.start}-${marker}${end}`; +} + +/// A comment's anchor with the file it is in, e.g. `foo.ts:R12` or +/// `foo.ts:L3-L9`. For contexts that do not already say which file it is. +export function formatAnchor(location: CodeLocation): string { + const lines = formatAnchorLines(location); + return lines ? `${location.path}:${lines}` : location.path; +} + +/// Comments cannot be anchored in a file whose content moved: a `CodeLocation` +/// names one path, and which side of the rename it means is ambiguous. +export function isCommentableStatus(status: FileStatus | undefined): boolean { + return status !== "moved" && status !== "copied"; +} + +function keyOf(side: AnnotationSide, line: number): string { + return `${side}:${line}`; +} + +/// Group everything that has to be rendered inside a file's diff into one +/// annotation per (side, line), in the shape Pierre takes on a `CodeViewItem`. +/// +/// `threads` is the whole set on the diff, filtered by the caller to the sources +/// the reader chose to see; only the ones anchored in this file are used. +export function fileAnnotations( + path: string, + threads: Thread[], + composer: ComposerTarget | undefined, +): DiffLineAnnotation[] { + const byLine = new Map(); + + function entryFor(side: AnnotationSide, line: number): LineAnnotation { + const key = keyOf(side, line); + const existing = byLine.get(key); + if (existing) return existing; + const created: LineAnnotation = { path, side, line, threads: [] }; + byLine.set(key, created); + return created; + } + + for (const thread of threads) { + const anchor = anchorOf(thread.root.location); + if (!anchor || anchor.path !== path) continue; + entryFor(anchor.side, anchor.line).threads.push(thread); + } + + if (composer && composer.path === path) { + entryFor(composer.side, composer.lastLine).composer = composer; + } + + const annotations: DiffLineAnnotation[] = []; + for (const entry of byLine.values()) { + // A line can carry more than one thread: two reviewers can comment on the + // same line, and a comment can arrive over the network onto a line we just + // commented on ourselves. Oldest first, so none is shadowed by another. + entry.threads.sort( + (a, b) => a.root.edits[0].timestamp - b.root.edits[0].timestamp, + ); + annotations.push({ + side: entry.side, + lineNumber: entry.line, + metadata: entry, + }); + } + return annotations; +} + +/// Resolved and unresolved thread counts per file, for the file headers. +/// +/// Only threads that can be resolved are counted: draft and standalone revision +/// comments carry a `resolved` flag that no action can ever set, so counting +/// them would leave a file permanently showing unresolved work. +export function commentCountsByPath( + threads: Thread[], + canResolve: (commentId: string) => boolean, +): Map { + const counts = new Map(); + for (const thread of threads) { + const anchor = anchorOf(thread.root.location); + if (!anchor) continue; + if (!canResolve(thread.root.id)) continue; + const entry = counts.get(anchor.path) ?? { resolved: 0, unresolved: 0 }; + if (thread.root.resolved === true) { + entry.resolved += 1; + } else if (thread.root.resolved === false) { + entry.unresolved += 1; + } + counts.set(anchor.path, entry); + } + return counts; +} + +// Which lines of which side a comment is about, end exclusive. +export function lineRangeOf( + location: CodeLocation, +): { side: "old" | "new"; start: number; end: number } | undefined { + const range = location.new ?? location.old; + if (!range) return undefined; + const side = location.new ? "new" : "old"; + if (range.type === "chars") { + return { side, start: range.line, end: range.line + 1 }; + } + return { side, start: range.range.start, end: range.range.end }; +} + +export function commentedLines(location: CodeLocation): number[] { + const range = lineRangeOf(location); + if (!range) return []; + const lines: number[] = []; + for (let line = range.start; line < range.end; line++) { + lines.push(line); + } + return lines; +} + +// Which slots a file's annotations occupy, and only that. Replacing a file's +// annotation array makes Pierre drop every measured annotation height in that +// file and lay it out as if the comments had no height at all, which yanks the +// scroll position; so it is only told when the set of slots really changes. +// What goes *in* a slot is pushed straight into the mounted component instead +// (see `syncAnnotationSlots` in PierreDiff), which is all a reply, an edit or a +// resolve touches. +export function annotationSignature( + annotations: DiffLineAnnotation[] | undefined, +): string { + return (annotations ?? []) + .map(({ side, lineNumber }) => `${side}:${lineNumber}`) + .sort() + .join("|"); +} + +// The lines a gutter drag covers. A drag in a split diff can end on the other +// side, where the end line counts a different file. There is no range that +// means both, so keep the line it started from. +export function composerLines(range: { + start: number; + end: number; + side?: AnnotationSide; + endSide?: AnnotationSide; +}): { side: AnnotationSide; firstLine: number; lastLine: number } { + const side = range.side ?? "additions"; + const crossSide = range.endSide !== undefined && range.endSide !== side; + const end = crossSide ? range.start : range.end; + return { + side, + firstLine: Math.min(range.start, end), + lastLine: Math.max(range.start, end), + }; +} + +// How a diff entry changed. `fileStatuses` is keyed by path, so the two entries +// of a type change would both read as whichever half the structured diff +// recorded last; Pierre parsed each entry on its own and knows which this is. +export function entryStatus( + status: FileStatus | undefined, + entryType: string, +): FileStatus | undefined { + if (status === "added" && entryType === "deleted") return "deleted"; + if (status === "deleted" && entryType === "new") return "added"; + return status; +} + +// An id per file, in order. A type change is one path arriving as two entries, +// and CodeView throws on a duplicate id, so the repeats are numbered. The +// first keeps the bare path, which is what scroll targets and collapsed paths +// resolve by. NUL is the separator because a path cannot contain one. +export function itemIds(names: string[]): string[] { + const occurrences = new Map(); + return names.map(name => { + const seen = occurrences.get(name) ?? 0; + occurrences.set(name, seen + 1); + return seen === 0 ? name : `${name}\u0000${seen + 1}`; + }); +} diff --git a/desktop/src/lib/pierreParse.ts b/desktop/src/lib/pierreParse.ts new file mode 100644 index 000000000..28526a916 --- /dev/null +++ b/desktop/src/lib/pierreParse.ts @@ -0,0 +1,103 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; + +import { processPatch } from "@pierre/diffs"; + +import type { ParseRequest, ParseResponse } from "@app/lib/pierreParse.worker"; +import PierreParseWorker from "@app/lib/pierreParse.worker?worker"; + +// Parse a unified patch into per-file (partial) diffs off the main thread so +// large diffs do not stutter. Falls back to synchronous main-thread parsing if +// the worker cannot be created. + +let parseWorker: Worker | undefined | null; +let parseSeq = 0; +const pending = new Map< + number, + { + resolve: (response: ParseResponse) => void; + reject: (error: unknown) => void; + } +>(); + +function getParseWorker(): Worker | undefined { + if (parseWorker !== undefined) { + return parseWorker ?? undefined; + } + try { + const worker = new PierreParseWorker(); + worker.onmessage = (event: MessageEvent) => { + const data = event.data; + const entry = pending.get(data.id); + if (!entry) { + return; + } + pending.delete(data.id); + if ("error" in data) { + entry.reject(new Error(data.error)); + } else { + entry.resolve(data); + } + }; + // If the worker dies, in-flight requests would otherwise hang forever. + // Reject them and drop the worker (`null` = permanent fallback) so this and + // future parses run on the main thread instead. + worker.onerror = event => { + const error = new Error(event.message || "parse worker crashed"); + for (const entry of pending.values()) { + entry.reject(error); + } + pending.clear(); + parseWorker = null; + }; + parseWorker = worker; + } catch (error) { + console.error( + "PierreDiff: parse worker unavailable; parsing on the main thread", + error, + ); + parseWorker = null; + } + return parseWorker ?? undefined; +} + +function send( + worker: Worker, + patch: string, + cacheKeyPrefix: string | undefined, +): Promise { + const id = ++parseSeq; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + worker.postMessage({ id, patch, cacheKeyPrefix } satisfies ParseRequest); + }); +} + +// Parse a unified patch into per-file (partial) diffs. +// +// `cacheKeyPrefix` must be unique per diff (e.g. the commit or revision id): it +// becomes each file's `cacheKey` (`-`), which the worker pool +// uses to cache highlight results. Without it Pierre keys the cache by file +// path alone, so the same path across two commits collides and the shared pool +// serves a stale, differently-sized highlight — the walk then overruns it and +// the file renders blank. +export async function parsePatch( + patch: string, + cacheKeyPrefix?: string, +): Promise { + const worker = getParseWorker(); + if (!worker) { + return processPatch(patch, cacheKeyPrefix).files; + } + try { + const response = await send(worker, patch, cacheKeyPrefix); + return "files" in response ? response.files : []; + } catch (error) { + // Worker crash or a parse error inside it — fall back to the main thread so + // the diff still renders (a genuinely unparseable patch throws again here). + console.error( + "PierreDiff: parse worker failed; parsing on the main thread", + error, + ); + return processPatch(patch, cacheKeyPrefix).files; + } +} diff --git a/desktop/src/lib/pierreParse.worker.ts b/desktop/src/lib/pierreParse.worker.ts new file mode 100644 index 000000000..bebeb5c88 --- /dev/null +++ b/desktop/src/lib/pierreParse.worker.ts @@ -0,0 +1,37 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; + +import { processPatch } from "@pierre/diffs"; + +// Parsing a unified patch into per-file `FileDiffMetadata` (partial — only the +// patch's own context) is CPU-heavy and touches no DOM, so it runs here off the +// main thread. Results are plain data and post back via structured clone. +// (Full-file hydration for context expansion is handled by Pierre itself via +// the `loadDiffFiles` option, so it does not go through this worker.) + +export type ParseRequest = { + id: number; + patch: string; + cacheKeyPrefix?: string; +}; + +export type ParseResponse = + { id: number; files: FileDiffMetadata[] } | { id: number; error: string }; + +// `self` is typed against the DOM lib here, so narrow to the worker surface we +// actually use to avoid the `Window.postMessage` signature. +const ctx = self as unknown as { + onmessage: ((event: MessageEvent) => void) | null; + postMessage: (message: ParseResponse) => void; +}; + +ctx.onmessage = (event: MessageEvent) => { + const request = event.data; + try { + ctx.postMessage({ + id: request.id, + files: processPatch(request.patch, request.cacheKeyPrefix).files, + }); + } catch (error) { + ctx.postMessage({ id: request.id, error: String(error) }); + } +}; diff --git a/desktop/src/lib/pierreView.ts b/desktop/src/lib/pierreView.ts new file mode 100644 index 000000000..6e1d65a69 --- /dev/null +++ b/desktop/src/lib/pierreView.ts @@ -0,0 +1,219 @@ +// Styling and worker wiring shared by the two ways the app renders a diff with +// `@pierre/diffs`: `PierreDiff` (the virtualized `CodeView`, for a whole +// revision or commit) and `PierreSnippet` (a bare `FileDiff`, for the few lines +// a review comment is about). Both inject the same per-file CSS into the same +// kind of shadow root, so it lives here rather than in either of them. +import type { WorkerPoolManager } from "@pierre/diffs/worker"; + +import { getOrCreateWorkerPoolSingleton } from "@pierre/diffs/worker"; +// Vite bundles Pierre's highlighting worker (and its Shiki/WASM deps) into a +// self-contained, same-origin worker module. +import PierreDiffWorker from "@pierre/diffs/worker/worker.js?worker"; + +export const themes = { dark: "github-dark", light: "github-light" } as const; + +// Shiki tokenization is the expensive part of rendering a large diff. Run it in +// a shared worker pool so it stays off the main thread; if the worker cannot be +// created (e.g. blocked by CSP), fall back to main-thread highlighting rather +// than failing to render. +let workerPoolResolved = false; +let workerPool: WorkerPoolManager | undefined; +export function getWorkerPool(): WorkerPoolManager | undefined { + if (workerPoolResolved) { + return workerPool; + } + workerPoolResolved = true; + try { + workerPool = getOrCreateWorkerPoolSingleton({ + poolOptions: { + workerFactory: () => new PierreDiffWorker(), + poolSize: 2, + // Pierre's default is 100 entries, which a large patch blows through + // immediately — and a miss is not just a re-tokenize. `hydrate` seeds a + // file's render cache from this LRU, so a hit skips the plain AST + // Pierre otherwise builds *synchronously on the main thread* to have + // something to paint. On a thousand-file diff that turns scrolling back + // over old ground into main-thread work per file. Traded against memory: + // this holds a tokenized AST per file, so it is generous rather than + // unbounded. + totalASTLRUCacheSize: 500, + }, + highlighterOptions: { theme: themes }, + }); + } catch (error) { + console.error( + "pierreView: worker pool unavailable; highlighting on the main thread", + error, + ); + workerPool = undefined; + } + return workerPool; +} + +// Diff line height, rounded to a whole pixel. `1.25rem` is fractional at some +// font sizes (e.g. 17.5px at a 14px root) and WebKit snaps each rendered row to +// a whole pixel, so a fractional metric drifts scroll-to-file down a long diff. +// The same integer feeds the CSS line height and Pierre's `itemMetrics`, so the +// rendered rows and the virtualization model agree exactly. +export function codeLineHeight(fontSize: number): number { + return Math.round(fontSize * 1.25); +} + +// Pierre only emits unprefixed `user-select: none` on its gutters, which +// WebKit ignores — so with the subtree opted into selection, gutters would +// become selectable there. Re-assert it with the `-webkit-` prefix inside the +// shadow root (injected into the highest `@layer unsafe`, so it wins). +export const gutterUnsafeCSS = ` + [data-column-number], + [data-content-buffer], + [data-gutter-buffer], + [data-separator-wrapper], + [data-separator-content] { + -webkit-user-select: none; + user-select: none; + } +`; + +// Shared surface treatment, injected into every file's shadow root. Each file +// is its own `` custom element, so `:host` — reachable from the +// per-file `unsafeCSS` — is the whole-file box (header and diff). +// +// `background: transparent` gives the "no background" look: unchanged lines show +// the app canvas, and only the change tints and whatever frame the host puts +// around the file stand out. +// +// Context separators ("N unmodified lines" / "More context" bars) are restyled +// too: recessed one surface level below the diff, rounded, no hover underline, +// and Pierre's expand glyph swapped for the app's caret (an app-file chevron +// painted via a CSS mask, like the tree's folder glyphs). +const caretMask = + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='black' d='M4 5.29297L8 9.29297L12 5.29297L12.707 6L8 10.707L3.29297 6L4 5.29297Z'/%3E%3C/svg%3E\")"; +export const surfaceUnsafeCSS = ` + :host { + background: transparent; + /* Force the diff surface to the app's view background. The Shiki theme + sets \`--diffs-light-bg\`/\`--diffs-dark-bg\` on \`:host\` directly, so + overriding those by inheritance fails; overriding the resolved + \`--diffs-bg\` from this higher \`@layer\` wins. The header, lines and + gutter read it directly, and the separator/context/change tints are + \`color-mix\`es off it — so everything sits on the view background with + only the syntax and add/del/mod tints standing out, while the separators + keep their subtle Pierre tint (now mixed off our base). */ + --diffs-bg: var(--color-surface-canvas); + /* Lift the context separators one surface level above the diff. */ + --diffs-bg-separator-override: var(--color-surface-subtle); + } + /* Context separators: rounded, no hover underline. */ + [data-separator-content], + [data-separator="line-info"] [data-separator-wrapper] { + border-radius: var(--border-radius-md); + } + [data-separator-content]:hover { + text-decoration: none; + } + /* Swap Pierre's expand glyph for the app caret (down; flipped when the + separator expands upward). */ + [data-expand-button] [data-icon] { + display: none; + } + [data-expand-button]::before { + content: ""; + width: 1rem; + height: 1rem; + background-color: currentColor; + -webkit-mask: center / contain no-repeat ${caretMask}; + mask: center / contain no-repeat ${caretMask}; + } + [data-expand-button][data-expand-up]::before { + transform: rotate(180deg); + } +`; + +// A snippet is deliberately only the lines a comment is about, cut out of the +// file. Pierre still reads that as a diff with context missing either side and +// offers to expand it — but a snippet has no `loadDiffFiles` behind that +// affordance, so there is nothing for it to expand into. The separators that +// carry it go away entirely; what is left is the lines, which is the point. +// The card each file gets in a `PierreDiff`: an inset for the `overlayLeft` +// column, a rounded outline, and a header that pins to the top of the scroll +// port. Horizontal spacing via margin; vertical spacing between files stays on +// `layout.gap` (vertical margins fight the virtualizer's height math). +// +// The outline is a `box-shadow`, NOT a real border: the virtualizer computes +// each file's height from metrics (for `overflow: scroll` it never measures the +// container), so a real border's 2px would drift the layout model from the DOM — +// corrupting scroll-to targets and the sticky math for short collapsed files. A +// box-shadow adds zero layout height. It must be an *outset* ring: the diff +// surface equals the view background, so the opaque header and line backgrounds +// would paint over an inset shadow. (The header/body divider is an inset shadow +// on `DiffFileHeader`'s own row, so it adds no height and keeps the header +// matching the metric.) +// +// Corners are rounded per-element, not with `overflow: hidden` on `:host` (that +// would make the header stick within the file box instead of the scroll +// viewport). The header's opaque sticky background would otherwise square off +// over the rounded box-shadow, so we round its top corners; the body wrapper (a +// sibling of the header, not the sticky element) gets rounded and clipped bottom +// corners — safe because the horizontal scroll lives on the inner `[data-code]`. +// A header-only card (collapsed, binary, or empty — no rendered body) rounds all +// its header corners, driven by the `data-app-no-body` attribute we set on the +// host. +export const cardUnsafeCSS = ` + :host { + /* Lines up with the horizontal padding of the chrome above the diff. The + left inset makes room for an \`overlayLeft\` column when there is one — a + custom property because it inherits across the shadow boundary, unlike + the class that would otherwise carry it. */ + margin: 0 1rem 0 var(--app-diff-left-inset, 1rem); + border-radius: var(--border-radius-md); + box-shadow: 0 0 0 1px var(--color-border-subtle); + } + [data-diffs-header] { + border-top-left-radius: var(--border-radius-md); + border-top-right-radius: var(--border-radius-md); + /* Must equal \`itemMetrics.diffHeaderHeight\`, and must not wait for the + Svelte header to mount to get there. In custom mode Pierre renders this + box around a bare slot and gives it no height of its own, so an unmounted + slot measures zero — and the mount is deliberately deferred a microtask + (see \`queueSlotSync\`). Pierre measures the file in the render that + created it, finds it exactly one header short, then has to reconcile and + re-render when the header lands: two passes and a reflow for every file + that scrolls into view. Pinning it here means the box is right from the + first paint and the content just fills it. */ + height: 2.5rem; + } + :host([data-app-no-body]) [data-diffs-header] { + border-radius: var(--border-radius-md); + } + [data-diffs-header] ~ [data-diff], + [data-diffs-header] ~ [data-file] { + border-bottom-left-radius: var(--border-radius-md); + border-bottom-right-radius: var(--border-radius-md); + overflow: hidden; + } + /* Pin below the sticky bar rather than at the very top of the port. Driven + by a custom property, which inherits across the shadow boundary, rather + than by writing the height into this string: Pierre treats any change to + the unsafe CSS as a layout change and relays out every file from the + first. (No backticks in here — this is inside a template literal.) + + The card's border is a ring around the host, so it scrolls away with the + card and leaves a pinned header with nothing along its top. The header + carries that one line itself instead, as an outset shadow, which adds no + height to the 2.5rem the virtualizer assumes. The extra pixel of offset is + what the line needs to live in: pinned flush against the bar, it would + fall on the bar's own last row, and the bar is opaque and paints above the + diff. While the top of the card is still in view the offset does nothing + (the header is not displaced at all) and the line lands in the band the + ring already occupies, so nothing doubles up. + + The two corners are left as they are. The diff runs behind a pinned + header, so a line's tint shows in the wedge between each arc and the + corner of the box — but the ring is drawn in the host's own background + layer, beneath every descendant, so anything the header paints over that + wedge takes the corner of the ring with it. */ + [data-diffs-header][data-sticky] { + top: calc(var(--app-sticky-top, 0px) + 1px); + box-shadow: 0 -1px 0 0 var(--color-border-subtle); + } +`; diff --git a/desktop/src/lib/portal.ts b/desktop/src/lib/portal.ts new file mode 100644 index 000000000..86b46288f --- /dev/null +++ b/desktop/src/lib/portal.ts @@ -0,0 +1,12 @@ +export function portal(node: HTMLElement, target: HTMLElement = document.body) { + const placeholder = document.createComment("portal"); + node.replaceWith(placeholder); + target.appendChild(node); + + return { + destroy() { + node.remove(); + placeholder.remove(); + }, + }; +} diff --git a/desktop/src/lib/radUri.ts b/desktop/src/lib/radUri.ts new file mode 100644 index 000000000..855d4a9ff --- /dev/null +++ b/desktop/src/lib/radUri.ts @@ -0,0 +1,564 @@ +import bs58 from "bs58"; + +export const issueType = "xyz.radicle.issue"; +export const patchType = "xyz.radicle.patch"; +export const releaseType = "dev.radicle.artifact"; + +const explorerCobPaths: Record = { + [issueType]: "issues", + [patchType]: "patches", + [releaseType]: "releases", +}; + +export interface RadAuthority { + node: string; + address?: string; +} + +export type RadResource = + | { type: "commit"; ref: string } + | { type: "tag"; ref: string } + | { type: "tree"; oid: string } + | { type: "blob"; oid: string } + | { type: "cob"; typeName: string; oid?: string }; + +export interface QueryParam { + param: string; + value?: string; +} + +export interface RadUri { + authority?: RadAuthority; + repo: string; + namespace?: string; + resource?: RadResource; + query?: QueryParam[]; + fragment?: string; +} + +export type RadReference = + { type: "uri"; uri: RadUri } | { type: "did"; node: string }; + +const base58 = "[1-9A-HJ-NP-Za-km-z]"; +const repoIdPattern = new RegExp(`^z${base58}{27,28}$`); +const nodeIdPattern = new RegExp(`^z6Mk${base58}{44}$`); +const oidPattern = /^[0-9a-fA-F]{40}$/; +const gitRefPattern = /^[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/; +const cobTypePattern = + /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*(?:\.[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)+$/; +const hostPattern = + /^(?:\[[0-9A-Fa-f:.vV]+\]|(?:[A-Za-z0-9._~!$&'()*+,;=-]|%[0-9A-Fa-f]{2})*)$/; +const portPattern = /^[0-9]*$/; +const queryOrFragmentPattern = + /^(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*$/; + +function decodesTo(id: string, check: (bytes: Uint8Array) => boolean) { + try { + return check(bs58.decode(id.slice(1))); + } catch { + return false; + } +} + +export function isRepoId(id: string): boolean { + return ( + repoIdPattern.test(id) && decodesTo(id, bytes => bytes.byteLength === 20) + ); +} + +export function isNodeId(id: string): boolean { + return ( + nodeIdPattern.test(id) && + decodesTo( + id, + bytes => + bytes.byteLength === 34 && bytes[0] === 0xed && bytes[1] === 0x01, + ) + ); +} + +export function isOid(oid: string): boolean { + return oidPattern.test(oid); +} + +function parseAuthority(input: string): RadAuthority | undefined { + const at = input.indexOf("@"); + const node = at === -1 ? input : input.slice(0, at); + if (!isNodeId(node)) return undefined; + if (at === -1) return { node }; + + const address = input.slice(at + 1); + const colon = address.lastIndexOf(":"); + if (colon === -1) return undefined; + if ( + !hostPattern.test(address.slice(0, colon)) || + !portPattern.test(address.slice(colon + 1)) + ) { + return undefined; + } + + return { node, address }; +} + +function parseResource(segments: string[]): RadResource | undefined | null { + if (segments.length === 0) return undefined; + + const [kind, ...rest] = segments; + switch (kind.toLowerCase()) { + case "commit": + case "tag": { + const ref = rest.join("/"); + if (!gitRefPattern.test(ref)) return null; + return { + type: kind.toLowerCase() as "commit" | "tag", + ref: isOid(ref) ? ref.toLowerCase() : ref, + }; + } + case "tree": + case "blob": + if (rest.length !== 1 || !isOid(rest[0])) return null; + return { + type: kind.toLowerCase() as "tree" | "blob", + oid: rest[0].toLowerCase(), + }; + case "cob": { + const [typeName, oid, ...extra] = rest; + if (extra.length > 0 || !typeName || !cobTypePattern.test(typeName)) { + return null; + } + if (oid === undefined) return { type: "cob", typeName }; + if (!isOid(oid)) return null; + return { type: "cob", typeName, oid: oid.toLowerCase() }; + } + default: + return null; + } +} + +function parseQuery(input: string): QueryParam[] { + return input.split("&").map(pair => { + const equals = pair.indexOf("="); + return equals === -1 + ? { param: pair } + : { param: pair.slice(0, equals), value: pair.slice(equals + 1) }; + }); +} + +export function parseRadUri(input: string): RadUri | undefined { + if (input.slice(0, 4).toLowerCase() !== "rad:") return undefined; + let rest = input.slice(4); + + let fragment: string | undefined; + const hash = rest.indexOf("#"); + if (hash !== -1) { + fragment = rest.slice(hash + 1); + rest = rest.slice(0, hash); + if (!queryOrFragmentPattern.test(fragment)) return undefined; + } + + let query: QueryParam[] | undefined; + const question = rest.indexOf("?"); + if (question !== -1) { + const raw = rest.slice(question + 1); + rest = rest.slice(0, question); + if (!queryOrFragmentPattern.test(raw)) return undefined; + query = parseQuery(raw); + } + + let authority: RadAuthority | undefined; + let legacy = false; + if (rest.startsWith("//")) { + rest = rest.slice(2); + if (rest.startsWith("/")) { + rest = rest.slice(1); + } else { + const slash = rest.indexOf("/"); + const head = slash === -1 ? rest : rest.slice(0, slash); + if (isRepoId(head)) { + legacy = true; + } else { + authority = parseAuthority(head); + if (!authority || slash === -1) return undefined; + rest = rest.slice(slash + 1); + } + } + } + + const segments = rest.split("/"); + const repo = segments.shift() ?? ""; + if (!isRepoId(repo)) return undefined; + + let namespace: string | undefined; + if (segments.length > 0 && nodeIdPattern.test(segments[0])) { + namespace = segments.shift(); + if (!namespace || !isNodeId(namespace)) return undefined; + } + + const resource = parseResource(segments); + if (resource === null) return undefined; + if (legacy && (resource || query || fragment !== undefined)) { + return undefined; + } + + return { authority, repo, namespace, resource, query, fragment }; +} + +function formatResource(resource: RadResource): string { + switch (resource.type) { + case "commit": + case "tag": + return `/${resource.type}/${resource.ref}`; + case "tree": + case "blob": + return `/${resource.type}/${resource.oid}`; + case "cob": + return resource.oid === undefined + ? `/cob/${resource.typeName}` + : `/cob/${resource.typeName}/${resource.oid}`; + } +} + +export function formatRadUri( + uri: RadUri, + options: { forceAuthority?: boolean } = {}, +): string { + let result = "rad:"; + if (uri.authority) { + const { node, address } = uri.authority; + result += `//${node}${address === undefined ? "" : `@${address}`}/`; + } else if (options.forceAuthority) { + result += "///"; + } + result += uri.repo; + if (uri.namespace) result += `/${uri.namespace}`; + if (uri.resource) result += formatResource(uri.resource); + if (uri.query && uri.query.length > 0) { + result += `?${uri.query + .map(({ param, value }) => + value === undefined ? param : `${param}=${value}`, + ) + .join("&")}`; + } + if (uri.fragment !== undefined) result += `#${uri.fragment}`; + + return result; +} + +export function parseDid(input: string): string | undefined { + if (!input.startsWith("did:key:")) return undefined; + const node = input.slice(8); + + return isNodeId(node) ? node : undefined; +} + +export function formatDid(node: string): string { + return `did:key:${node}`; +} + +export function parseReference(input: string): RadReference | undefined { + const node = parseDid(input); + if (node) return { type: "did", node }; + const uri = parseRadUri(input); + + return uri ? { type: "uri", uri } : undefined; +} + +export function formatReference(reference: RadReference): string { + return reference.type === "did" + ? formatDid(reference.node) + : formatRadUri(reference.uri); +} + +function queryPath(uri: RadUri): string | undefined { + return uri.query?.find( + ({ param, value }) => + (param === "path" || param === "tree" || param === "blob") && + value !== undefined, + )?.value; +} + +function explorerRevision(ref: string): string | undefined { + if (!isSafeRef(ref)) return undefined; + if (!ref.startsWith("refs/")) return ref; + for (const prefix of ["refs/heads/", "refs/tags/"]) { + if (ref.startsWith(prefix)) return ref.slice(prefix.length); + } + + return undefined; +} + +// A `.` or `..` segment would walk the explorer URL to a page the reference +// does not name, so such a path or ref has no explorer page. +// The rules of `git check-ref-format` that the URI grammar does not already +// enforce; a `..` segment would otherwise walk a link to another page. +export function isSafeRef(ref: string): boolean { + return ref + .split("/") + .every( + segment => + !segment.startsWith(".") && + !segment.endsWith(".") && + !segment.endsWith(".lock") && + !segment.includes(".."), + ); +} + +function hasDotSegment(path: string): boolean { + return path.split("/").some(segment => segment === "." || segment === ".."); +} + +function explorerFilePath(path: string): string | undefined { + const segments = path.split("/").map(decodeSegment); + if (hasDotSegment(segments.join("/"))) return undefined; + + return segments.map(encodeURIComponent).join("/"); +} + +function explorerResourcePath(uri: RadUri): string | undefined { + const { resource } = uri; + const remote = uri.namespace ? `/remotes/${uri.namespace}` : ""; + if (!resource) return remote; + + switch (resource.type) { + case "commit": + case "tag": { + const revision = explorerRevision(resource.ref); + if (revision === undefined) return undefined; + const raw = queryPath(uri); + if (raw !== undefined) { + const path = explorerFilePath(raw); + if (path === undefined) return undefined; + return `${remote}/tree/${revision}${path ? `/${path}` : ""}`; + } + if (resource.type === "commit" && isOid(revision)) { + return `/commits/${revision}`; + } + return `${remote}/tree/${revision}`; + } + case "tree": + case "blob": + return undefined; + case "cob": { + const kind = explorerCobPaths[resource.typeName]; + if (!kind) return undefined; + return resource.oid === undefined + ? `/${kind}` + : `/${kind}/${resource.oid}`; + } + } +} + +export function explorerUrl( + reference: RadReference, + base: string, + host: string, +): string | undefined { + const node = `${base.replace(/\/+$/, "")}/nodes/${host}`; + if (reference.type === "did") { + return `${node}/users/${formatDid(reference.node)}`; + } + + const path = explorerResourcePath(reference.uri); + if (path === undefined) return undefined; + + const fragment = + isFileReference(reference) && reference.uri.fragment + ? `#${reference.uri.fragment}` + : ""; + + return `${node}/rad:${reference.uri.repo}${path}${fragment}`; +} + +function decodeSegment(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +function explorerUrlParts( + href: string, +): { segments: string[]; fragment?: string } | undefined { + let url: URL; + try { + url = new URL(href); + } catch { + return undefined; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return undefined; + + const fragment = url.hash.slice(1); + + return { + segments: url.pathname + .split("/") + .filter(segment => segment !== "") + .map(decodeSegment), + fragment: + fragment && queryOrFragmentPattern.test(fragment) ? fragment : undefined, + }; +} + +function explorerRepoPage( + segments: string[], +): { repo: string; namespace?: string; tail: string[] } | undefined { + const nodes = segments.findIndex( + segment => segment === "nodes" || segment === "seeds", + ); + const index = + nodes !== -1 && segments.length > nodes + 2 + ? nodes + 2 + : segments.findIndex(segment => segment.startsWith("rad:")); + if (index === -1) return undefined; + + const repo = segments[index].replace(/^rad:/, ""); + if (!isRepoId(repo)) return undefined; + + const tail = segments.slice(index + 1); + if (tail[0] !== "remotes") return { repo, tail }; + + const namespace = tail[1]; + if (namespace === undefined || !isNodeId(namespace)) return undefined; + + return { repo, namespace, tail: tail.slice(2) }; +} + +export function parseExplorerUrl(href: string): RadReference | undefined { + const parts = explorerUrlParts(href); + if (!parts) return undefined; + const { segments } = parts; + + const users = segments.indexOf("users"); + if (users !== -1 && users === segments.length - 2) { + const node = parseDid(segments[users + 1]); + return node ? { type: "did", node } : undefined; + } + + const page = explorerRepoPage(segments); + if (!page) return undefined; + const { repo, namespace, tail } = page; + + const [kind, ...rest] = tail; + if (kind === undefined || (kind === "tree" && rest.length === 0)) { + return { type: "uri", uri: { repo, namespace } }; + } + + if (kind === "tree") { + const [revision, ...path] = rest; + if (!isOid(revision)) return undefined; + return fileReference( + { repo, namespace }, + revision.toLowerCase(), + path.join("/"), + parts.fragment, + ); + } + + const typeName = Object.keys(explorerCobPaths).find( + key => explorerCobPaths[key] === kind, + ); + if (typeName && rest.length === 0) { + return { type: "uri", uri: { repo, resource: { type: "cob", typeName } } }; + } + + const [raw, ...extra] = rest; + if ((extra.length > 0 && kind !== "patches") || !raw || !isOid(raw)) { + return undefined; + } + const oid = raw.toLowerCase(); + + if (kind === "commits") { + return { + type: "uri", + uri: { repo, resource: { type: "commit", ref: oid } }, + }; + } + + if (!typeName) return undefined; + + return { + type: "uri", + uri: { repo, resource: { type: "cob", typeName, oid } }, + }; +} + +export function parseExplorerTreeUrl( + href: string, +): + | { repo: string; namespace?: string; path: string; fragment?: string } + | undefined { + const parts = explorerUrlParts(href); + const page = parts && explorerRepoPage(parts.segments); + if (!page) return undefined; + + const [kind, revision, ...path] = page.tail; + if (kind !== "tree" || revision === undefined || isOid(revision)) { + return undefined; + } + + return { + repo: page.repo, + namespace: page.namespace, + path: [revision, ...path].join("/"), + fragment: parts.fragment, + }; +} + +export function cobListType(reference: RadReference): string | undefined { + if (reference.type !== "uri") return undefined; + const { resource } = reference.uri; + + return resource?.type === "cob" && resource.oid === undefined + ? resource.typeName + : undefined; +} + +export function isFileReference(reference: RadReference): boolean { + return ( + reference.type === "uri" && + (reference.uri.resource?.type === "commit" || + reference.uri.resource?.type === "tag") && + queryPath(reference.uri) !== undefined + ); +} + +export function filePath( + reference: RadReference, +): { revision: string; path: string } | undefined { + if (reference.type !== "uri" || !isFileReference(reference)) return undefined; + const { resource } = reference.uri; + const raw = queryPath(reference.uri); + if (!resource || !("ref" in resource) || raw === undefined) return undefined; + + return { revision: resource.ref, path: decodeSegment(raw) }; +} + +export function fileReference( + location: { repo: string; namespace?: string }, + revision: string, + path: string, + fragment?: string, +): RadReference | undefined { + if (!gitRefPattern.test(revision) || !isSafeRef(revision)) return undefined; + + return { + type: "uri", + uri: { + repo: location.repo, + namespace: location.namespace, + resource: { type: "commit", ref: revision }, + query: [ + { + param: "path", + value: path + .split("/") + .filter(segment => segment !== "") + .map(encodeURIComponent) + .join("/"), + }, + ], + fragment, + }, + }; +} diff --git a/desktop/src/lib/refs.ts b/desktop/src/lib/refs.ts new file mode 100644 index 000000000..83e295a9b --- /dev/null +++ b/desktop/src/lib/refs.ts @@ -0,0 +1,44 @@ +import type { Canonical } from "@bindings/repo/Canonical"; +import type { Remote } from "@bindings/repo/Remote"; +import type { Tag } from "@bindings/repo/Tag"; + +import orderBy from "lodash/orderBy"; + +export function sortedTags(tags: Record): [string, Tag][] { + return Object.entries(tags).sort(([nameA, a], [nameB, b]) => { + if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp; + return nameB.localeCompare(nameA); + }); +} + +// Delegates first; within each (delegate / non-delegate) group, peers with +// an alias come before those without, alphabetically. No-alias peers fall +// to the bottom and sort by NID. +export function sortRemotes< + T extends { id: string; alias?: string; delegate: boolean }, +>(list: T[]): T[] { + return orderBy(list, [ + r => !r.delegate, + r => r.alias === undefined, + r => (r.alias ?? "").toLowerCase(), + r => r.id, + ]); +} + +export function selectedRefType( + revision: string | undefined, + selectedPeer: Remote | undefined, + canonical: Canonical | undefined, + defaultBranch: string, +): "branch" | "tag" | undefined { + if (revision === undefined) return "branch"; + if (selectedPeer) { + if (revision in selectedPeer.branches) return "branch"; + if (revision in selectedPeer.tags) return "tag"; + } else { + if (revision in (canonical?.branches ?? {})) return "branch"; + if (revision in (canonical?.tags ?? {})) return "tag"; + if (revision === defaultBranch) return "branch"; + } + return undefined; +} diff --git a/desktop/src/lib/releases.ts b/desktop/src/lib/releases.ts new file mode 100644 index 000000000..7936cc515 --- /dev/null +++ b/desktop/src/lib/releases.ts @@ -0,0 +1,166 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Artifact } from "@bindings/cob/release/Artifact"; +import type { Location } from "@bindings/cob/release/Location"; +import type { ReleaseCounts } from "@bindings/cob/release/ReleaseCounts"; +import type { ReleaseScope } from "@bindings/cob/release/ReleaseScope"; +import type { Commit } from "@bindings/repo/Commit"; + +// With no scope asked for and no delegate releases, open on the untrusted +// scope rather than on an empty list. +export function releaseListScope( + requested: ReleaseScope | undefined, + counts: ReleaseCounts, +): ReleaseScope { + if (requested !== undefined) { + return requested; + } + return counts.delegate === 0 && counts.other > 0 ? "untrusted" : "trusted"; +} + +export interface ArtifactView { + scope: ReleaseScope; + shown: Artifact[]; + redactedCount: number; + counts: Record; + showFilters: boolean; +} + +// Falls back to the other scope when the one asked for is empty. +export function artifactView( + artifacts: Artifact[], + delegates: Set, + wanted: ReleaseScope, + showRedacted: boolean, +): ArtifactView { + const byScope: Record = { + trusted: artifacts.filter(a => delegates.has(a.author.did)), + untrusted: artifacts.filter(a => !delegates.has(a.author.did)), + }; + const visible = (list: Artifact[]) => + showRedacted ? list : list.filter(a => !a.redacted); + const other = wanted === "trusted" ? "untrusted" : "trusted"; + const scope = byScope[wanted].length > 0 ? wanted : other; + + return { + scope, + shown: visible(byScope[scope]), + redactedCount: byScope[scope].filter(a => a.redacted).length, + counts: { + trusted: visible(byScope.trusted).length, + untrusted: visible(byScope.untrusted).length, + }, + showFilters: byScope.trusted.length > 0 && byScope.untrusted.length > 0, + }; +} + +// Delegate takes precedence over author, since the author may also be a +// delegate. +export function redactedByDelegate( + artifact: Artifact, + delegates: Set, +): boolean { + return artifact.redactions.some(r => delegates.has(r.user.did)); +} + +export function locationsByNode( + locations: Location[], +): { user: Author; urls: string[] }[] { + const groups = new Map(); + for (const { user, url } of locations) { + const group = groups.get(user.did) ?? { user, urls: [] }; + group.urls.push(url); + groups.set(user.did, group); + } + return [...groups.values()]; +} + +// The backend sorts by DID, which means nothing to a reader. +export function delegatesFirst( + items: T[], + did: (item: T) => string, + delegates: Set, +): T[] { + return [...items].sort( + (a, b) => Number(delegates.has(did(b))) - Number(delegates.has(did(a))), + ); +} + +export function webLocations( + locations: Location[], + delegates: Set, +): Location[] { + return delegatesFirst( + locations.filter(l => /^https?:\/\//i.test(l.url)), + l => l.user.did, + delegates, + ); +} + +// The trust signal worth carrying on the collapsed row. Only delegates count: +// anyone can make keys that attest their own artifact. +export function attestedBy( + attestations: Author[], + delegates: Set, +): number { + return attestations.filter(a => delegates.has(a.did)).length; +} + +export function canEditMetadata( + artifact: Artifact, + ownDid: string, + delegates: Set, +): boolean { + return artifact.author.did === ownDid || delegates.has(ownDid); +} + +// The author vouches by registering, so the COB ignores their attestation, +// and a redaction bars the same user from attesting. +export function canAttest(artifact: Artifact, ownDid: string): boolean { + return ( + artifact.author.did !== ownDid && + !artifact.redacted && + !artifact.attestations.some(a => a.did === ownDid) && + !artifact.redactions.some(r => r.user.did === ownDid) + ); +} + +// Text that parses as JSON is sent as that value, so numbers round-trip. +export function parseMetadataValue(input: string): unknown { + try { + return JSON.parse(input); + } catch { + return input; + } +} + +export function displayMetadataValue(value: unknown): string { + return typeof value === "string" ? value : JSON.stringify(value); +} + +export function matchCommits( + commits: Commit[], + query: string, + resolved: Commit | undefined, +): Commit[] { + const q = query.trim().toLowerCase(); + const matches = commits.filter( + c => c.id.startsWith(q) || c.summary.toLowerCase().includes(q), + ); + if (resolved && !matches.some(c => c.id === resolved.id)) { + return [resolved]; + } + return matches; +} + +// A release by a non-delegate is untrusted as a whole. On a delegate's +// release, only the artifacts registered by others are. +export function releaseWarning( + creatorDid: string, + delegates: Set, + artifactScope: ReleaseScope, +): "release" | "artifacts" | undefined { + if (!delegates.has(creatorDid)) { + return "release"; + } + return artifactScope === "untrusted" ? "artifacts" : undefined; +} diff --git a/desktop/src/lib/repoImages.ts b/desktop/src/lib/repoImages.ts new file mode 100644 index 000000000..79740bb6b --- /dev/null +++ b/desktop/src/lib/repoImages.ts @@ -0,0 +1,255 @@ +import { retinaWidth } from "@app/lib/embeds"; +import { invoke } from "@app/lib/invoke"; + +// Links in a document are resolved against this origin, so that anything +// resolving to another origin is known to point outside the repository. It +// is never requested. +const repoOrigin = "https://repo.radicle.invalid"; + +export function decodeComponent(component: string): string { + try { + return decodeURIComponent(component); + } catch { + // A `%` that doesn't start an escape is part of the name. + return component; + } +} + +/** + * Resolve a link written relative to the document at `from` into a repository + * path, which is empty for the repository root, and the fragment it points + * to. Returns undefined when the link points to another origin. Targets that + * climb above the root stop at it. + */ +export function resolveRepoTarget( + href: string, + from: string, +): { path: string; fragment: string | undefined } | undefined { + const dir = from.split("/").slice(0, -1).join("/"); + + let url: URL; + try { + // The trailing slash makes the base a directory, so `a.adoc` resolves as + // a sibling of `from` rather than replacing its last segment. + url = new URL(href, `${repoOrigin}/${dir ? `${dir}/` : ""}`); + } catch { + return undefined; + } + + if (url.origin !== repoOrigin) { + return undefined; + } + + return { + path: decodeComponent(url.pathname).replace(/^\//, ""), + fragment: url.hash ? decodeComponent(url.hash.slice(1)) : undefined, + }; +} + +export function resolveRepoPath( + href: string, + from: string, +): string | undefined { + return resolveRepoTarget(href, from)?.path || undefined; +} + +// Rewrite GitHub "blob" image URLs so they resolve to the raw image content. A +// URL like https://github.com///blob// serves an HTML +// page rather than the image bytes, so appending `?raw=true` makes GitHub +// redirect to the raw content. URLs already pointing at the raw content are +// returned unchanged. +export function canonicalizeGithubImageUrl(input: string): string { + let url; + try { + url = new URL(input); + } catch { + return input; + } + + if ( + (url.hostname === "github.com" || url.hostname === "www.github.com") && + /^\/[^/]+\/[^/]+\/blob\//.test(url.pathname) && + url.searchParams.get("raw") !== "true" + ) { + url.searchParams.set("raw", "true"); + return url.toString(); + } + + return input; +} + +const imageTypes: Record = { + avif: "image/avif", + bmp: "image/bmp", + gif: "image/gif", + ico: "image/x-icon", + jpeg: "image/jpeg", + jpg: "image/jpeg", + png: "image/png", + svg: "image/svg+xml", + webp: "image/webp", +}; + +// The webview sniffs raster formats, but only shows an SVG when the blob it +// comes from says it is one, so the type comes from the file name. +export function imageMimeType(path: string): string | undefined { + const extension = /\.([^./]+)$/.exec(path)?.[1].toLowerCase(); + return extension ? imageTypes[extension] : undefined; +} + +export interface RepoImage { + url: string; + // The width a Retina screenshot is meant to be shown at. + width: number | undefined; +} + +export type FetchRepoBytes = ( + rid: string, + sha: string, + path: string, +) => Promise; + +function fetchRepoBytes(rid: string, sha: string, path: string) { + return invoke("repo_blob_bytes", { rid, sha, path }); +} + +/** + * Loads files out of a repository as images, once per file, and owns the + * object URLs they are shown through. + */ +export class RepoImages { + #fetch: FetchRepoBytes; + #images = new Map>(); + #urls = new Map(); + #used = new Set(); + #disposed = false; + + constructor(fetch: FetchRepoBytes = fetchRepoBytes) { + this.#fetch = fetch; + } + + /** + * Resolves to undefined when the image was released before it loaded. + */ + load(rid: string, sha: string, path: string): Promise { + const key = JSON.stringify([rid, sha, path]); + this.#used.add(key); + + const cached = this.#images.get(key); + if (cached) { + return cached; + } + + const image = this.#fetch(rid, sha, path).then(buffer => { + const bytes = new Uint8Array(buffer); + const url = URL.createObjectURL( + new Blob([bytes], { type: imageMimeType(path) ?? "" }), + ); + if (this.#images.get(key) !== image) { + URL.revokeObjectURL(url); + return undefined; + } + this.#urls.set(key, url); + return { url, width: retinaWidth(bytes) }; + }); + image.catch(() => { + // A failed load is retried on the next pass rather than cached. + if (this.#images.get(key) === image) { + this.#images.delete(key); + } + }); + + if (!this.#disposed) { + this.#images.set(key, image); + } + return image; + } + + /** + * Releases the images not loaded since the previous sweep. + */ + sweep() { + for (const key of this.#images.keys()) { + if (!this.#used.has(key)) { + this.#release(key); + } + } + this.#used.clear(); + } + + dispose() { + this.#disposed = true; + for (const key of this.#images.keys()) { + this.#release(key); + } + this.#used.clear(); + } + + #release(key: string) { + this.#images.delete(key); + const url = this.#urls.get(key); + if (url) { + URL.revokeObjectURL(url); + this.#urls.delete(key); + } + } +} + +// A pass replaces the source of a repository image with an object URL, so the +// source the document was written with is kept here for the next pass. +const sourceAttribute = "data-repo-src"; + +/** + * Points the images in `container` at their content: images in the repository + * are read out of storage, GitHub page URLs are rewritten to the raw file. + * Repository images need the commit the document is read at, and resolve + * against the document's `path`. + */ +export function enhanceRepoImages( + container: HTMLElement, + source: { rid: string; sha: string | undefined; path: string }, + images: RepoImages, +) { + for (const image of container.querySelectorAll("img")) { + if (image.classList.contains("txt-emoji")) { + continue; + } + + const src = + image.getAttribute(sourceAttribute) ?? image.getAttribute("src"); + if (!src) { + continue; + } + + const repoPath = resolveRepoPath(src, source.path); + if (!repoPath) { + const canonical = canonicalizeGithubImageUrl(src); + if (canonical !== src) { + image.setAttribute("src", canonical); + } + continue; + } + + if (!source.sha) { + continue; + } + + image.setAttribute(sourceAttribute, src); + images + .load(source.rid, source.sha, repoPath) + .then(loaded => { + if (!loaded || image.getAttribute(sourceAttribute) !== src) { + return; + } + image.setAttribute("src", loaded.url); + if (loaded.width && !image.hasAttribute("width")) { + image.style.width = `${loaded.width}px`; + } else { + image.style.removeProperty("width"); + } + }) + .catch(error => console.warn("Not able to load image", src, error)); + } + + images.sweep(); +} diff --git a/desktop/src/lib/repoListScope.ts b/desktop/src/lib/repoListScope.ts new file mode 100644 index 000000000..e6f3f6b1e --- /dev/null +++ b/desktop/src/lib/repoListScope.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import useLocalStorage from "@app/lib/useLocalStorage.svelte"; + +// Which repositories the sidebar lists, mirroring `rad ls` and `rad ls --all`: +// only the ones you seed, or everything in local storage. +const schema = z.union([z.literal("seeded"), z.literal("all")]); + +export type RepoListScope = z.infer; + +export const repoListScope = useLocalStorage( + "repoListScope", + schema, + "seeded", + !window.localStorage, +); diff --git a/desktop/src/lib/repoOrdering.ts b/desktop/src/lib/repoOrdering.ts new file mode 100644 index 000000000..23c32470b --- /dev/null +++ b/desktop/src/lib/repoOrdering.ts @@ -0,0 +1,49 @@ +/// Pins `rid` at the front of the list, or unpins it if it is already there. +export function togglePinned(list: string[], rid: string): string[] { + return list.includes(rid) ? list.filter(r => r !== rid) : [rid, ...list]; +} + +/// The pinned items first, in the order they were pinned, then the rest in +/// their original order. +export function pinnedFirst( + items: T[], + pinned: string[], + ridOf: (item: T) => string, +): T[] { + return [ + ...pinnedIn(items, pinned, ridOf), + ...items.filter(item => !pinned.includes(ridOf(item))), + ]; +} + +/// The pinned items that are present, in the order they were pinned. +export function pinnedIn( + items: T[], + pinned: string[], + ridOf: (item: T) => string, +): T[] { + const byRid = new Map(items.map(item => [ridOf(item), item])); + return pinned + .map(rid => byRid.get(rid)) + .filter((item): item is T => item !== undefined); +} + +/// The inbox's order: pinned repos in pin order, then the others by name, then +/// the hidden ones by name. +export function inboxRepoOrder( + items: T[], + pinned: string[], + hidden: string[], + ridOf: (item: T) => string, + nameOf: (item: T) => string, +): T[] { + const byName = (a: T, b: T) => nameOf(a).localeCompare(nameOf(b)); + return [ + ...pinnedIn(items, pinned, ridOf), + ...items + .filter(item => !pinned.includes(ridOf(item))) + .filter(item => !hidden.includes(ridOf(item))) + .sort(byName), + ...items.filter(item => hidden.includes(ridOf(item))).sort(byName), + ]; +} diff --git a/desktop/src/lib/reviewSummary.ts b/desktop/src/lib/reviewSummary.ts new file mode 100644 index 000000000..fcab4527d --- /dev/null +++ b/desktop/src/lib/reviewSummary.ts @@ -0,0 +1,140 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Reviewer } from "@bindings/cob/patch/Reviewer"; +import type { Revision } from "@bindings/cob/patch/Revision"; +import type { Verdict } from "@bindings/cob/patch/Verdict"; + +// How a patch's reviews are summarised. Shared by the patch header and the +// patch list so the two can't drift: both count the same things, order them the +// same way and go outdated under the same rule. + +/// One review, from either source: the patch header builds these from the +/// revisions it has loaded, the patch list from `Patch["reviewers"]`. +export interface ReviewEntry { + author: Author; + verdict?: Verdict; + /// 1-based position of the reviewed revision in the patch timeline. + revisionNumber: number; + delegate: boolean; + /// Only the header links to a specific review; the list has no popover. + reviewId?: string; +} + +export function entriesFromReviewers(reviewers: Reviewer[]): ReviewEntry[] { + return reviewers.map(r => ({ + author: r.author, + verdict: r.verdict, + revisionNumber: r.revisionNumber, + delegate: r.delegate, + })); +} + +export function entriesFromRevisions( + revisions: Revision[], + delegateDids: string[], +): ReviewEntry[] { + const delegates = new Set(delegateDids); + return revisions.flatMap((rev, index) => + (rev.reviews ?? []).map(review => ({ + author: review.author, + verdict: review.verdict, + revisionNumber: index + 1, + delegate: delegates.has(review.author.did), + reviewId: review.id, + })), + ); +} + +/// Grouped by author so a person's reviews stay together, newest revision first +/// within a group and across groups. +export function orderReviews(entries: ReviewEntry[]): ReviewEntry[] { + const byAuthor = new Map(); + for (const entry of entries) { + const group = byAuthor.get(entry.author.did) ?? []; + group.push(entry); + byAuthor.set(entry.author.did, group); + } + return [...byAuthor.values()] + .map(group => + [...group].sort((a, b) => b.revisionNumber - a.revisionNumber), + ) + .sort((a, b) => b[0].revisionNumber - a[0].revisionNumber) + .flat(); +} + +export interface ReviewSummaryState { + reviews: ReviewEntry[]; + /// People, not reviews — the avatar stack and the "N reviewers" wording. + /// Delegates first, so they survive the stack being truncated. + authors: (Author & { delegate: boolean })[]; + hasReject: boolean; + allAccept: boolean; + /// No review covers the newest revision, so nothing here describes the + /// changes as they now stand. The verdict colouring is suppressed in this + /// state: a stale accept must never read as a current approval. + outdated: boolean; + /// Newest revision anyone has reviewed, which is what the outdated pill + /// names ("· r1"). Zero when there are no reviews. + latestReviewedRevision: number; + delegateCount: number; +} + +export function reviewSummary( + entries: ReviewEntry[], + revisionCount: number, +): ReviewSummaryState { + const reviews = orderReviews(entries); + + const seen = new Map(); + for (const entry of reviews) { + const existing = seen.get(entry.author.did); + if (existing) { + // A person is a delegate or not; if any of their reviews says so, keep it. + existing.delegate ||= entry.delegate; + } else { + seen.set(entry.author.did, { ...entry.author, delegate: entry.delegate }); + } + } + const authors = [...seen.values()].sort( + (a, b) => Number(b.delegate) - Number(a.delegate), + ); + + const outdated = + reviews.length > 0 && + revisionCount > 0 && + !reviews.some(r => r.revisionNumber === revisionCount); + + return { + reviews, + authors, + hasReject: !outdated && reviews.some(r => r.verdict === "reject"), + allAccept: + !outdated && + reviews.length > 0 && + reviews.every(r => r.verdict === "accept"), + outdated, + latestReviewedRevision: reviews.reduce( + (max, r) => Math.max(max, r.revisionNumber), + 0, + ), + delegateCount: authors.filter(a => a.delegate).length, + }; +} + +/// A review that no longer covers the current head. +export function isOutdatedReview( + entry: ReviewEntry, + revisionCount: number, +): boolean { + return revisionCount > 0 && entry.revisionNumber !== revisionCount; +} + +export function summaryTitle(state: ReviewSummaryState): string { + const reviews = `${state.reviews.length} ${state.reviews.length === 1 ? "review" : "reviews"}`; + const people = `${state.authors.length} ${state.authors.length === 1 ? "reviewer" : "reviewers"}`; + const delegates = + state.delegateCount > 0 ? `, ${state.delegateCount} a delegate` : ""; + const outdated = state.outdated + ? ` — newest is of revision ${state.latestReviewedRevision}, so these may no longer apply` + : ""; + return `${reviews} by ${people}${delegates}${outdated}`; +} diff --git a/desktop/src/lib/revisionDescription.ts b/desktop/src/lib/revisionDescription.ts new file mode 100644 index 000000000..c3fd027a6 --- /dev/null +++ b/desktop/src/lib/revisionDescription.ts @@ -0,0 +1,91 @@ +import type { Revision } from "@bindings/cob/patch/Revision"; +import type { Commit } from "@bindings/repo/Commit"; + +/// A description's first line and the rest, both trimmed. +export function splitDescription(text: string): { + subject?: string; + body?: string; +} { + const trimmed = text.trim(); + if (!trimmed) return {}; + const idx = trimmed.indexOf("\n"); + if (idx === -1) return { subject: trimmed }; + const subject = trimmed.slice(0, idx).trim(); + const body = trimmed.slice(idx + 1).trim(); + return { + subject: subject || undefined, + body: body || undefined, + }; +} + +/// Swaps the body beneath the subject, keeping everything else as written. +export function replaceDescriptionBody( + description: string, + body: string, +): string { + const { subject = "", body: current = "" } = splitDescription(description); + const subjectEnd = description.indexOf(subject) + subject.length; + const start = current ? description.indexOf(current, subjectEnd) : -1; + if (start === -1) { + return subject ? `${subject}\n\n${body}` : body; + } + return ( + description.slice(0, start) + + body + + description.slice(start + current.length) + ); +} + +/// The first line of a revision's current description. +export function revisionTitle(revision: Revision): string | undefined { + return splitDescription(revision.description.at(-1)?.body ?? "").subject; +} + +/// A description that is exactly the list of commit summaries is the default +/// Radicle produces, and is noise next to the commits themselves. +export function isCommitListDescription( + description: string, + commits: Commit[] | undefined, +): boolean { + if (!commits || commits.length === 0) return false; + const lines = description + .split("\n") + .map(line => line.trim()) + .filter(line => line.length > 0); + if (lines.length !== commits.length) return false; + const summaries = new Set(commits.map(c => c.summary.trim())); + return lines.every(line => summaries.has(line)); +} + +/// Consecutive commits by the same author name. +export function groupCommitsByAuthor(commits: Commit[]): Commit[][] { + const groups: Commit[][] = []; + for (const commit of commits) { + const last = groups[groups.length - 1]; + if (last && last[0].author.name === commit.author.name) { + last.push(commit); + } else { + groups.push([commit]); + } + } + return groups; +} + +const MAX_COMMITS_VISIBLE = 3; +const COMMIT_COLLAPSE_THRESHOLD = 5; + +/// Which of a group's commits to show. A long group shows its first few until +/// the reader expands it. +export function visibleCommitsOf( + group: Commit[], + expanded: boolean, +): { collapsed: boolean; visible: Commit[]; hiddenCount: number } { + const collapsed = !expanded && group.length > COMMIT_COLLAPSE_THRESHOLD; + return collapsed + ? { + collapsed, + visible: group.slice(0, MAX_COMMITS_VISIBLE), + hiddenCount: group.length - MAX_COMMITS_VISIBLE, + } + : { collapsed, visible: group, hiddenCount: 0 }; +} diff --git a/desktop/src/lib/revisionList.ts b/desktop/src/lib/revisionList.ts new file mode 100644 index 000000000..c7b1251bb --- /dev/null +++ b/desktop/src/lib/revisionList.ts @@ -0,0 +1,66 @@ +import type { Revision } from "@bindings/cob/patch/Revision"; +import type { Commit } from "@bindings/repo/Commit"; + +import type { RevisionListSettings } from "@app/lib/revisionListSettings"; + +// How the revision picker presents a patch's revisions. Kept out of the patch +// view so the ordering rules can be read — and tested — on their own. +// +// Revisions arrive in the patch's timeline order and that order is what the +// numbering means; see `revisionPosition` in `utils`. Sorting only reorders the +// dropdown and grouping only buckets it, so neither may change what "Revision +// N of M" refers to. + +/// 1-based timeline position per revision id, so the dropdown labels a revision +/// the same way the button above it does no matter how the list is arranged. +export function revisionNumbers(revisions: Revision[]): Record { + const numbers: Record = {}; + revisions.forEach((rev, index) => { + numbers[rev.id] = index + 1; + }); + return numbers; +} + +export function orderRevisions( + revisions: Revision[], + patchAuthorDid: string, + settings: RevisionListSettings, +): Revision[] { + if (!settings.groupByAuthor) { + return settings.sortDesc ? [...revisions].reverse() : [...revisions]; + } + // The patch author's revisions come first, then every other author's in the + // order they first appear in the timeline. The date direction applies within + // each group rather than to the group order. + + const groups = new Map(); + for (const rev of revisions) { + const group = groups.get(rev.author.did); + if (group) group.push(rev); + else groups.set(rev.author.did, [rev]); + } + const orderedKeys = [...groups.keys()].sort( + (a, b) => Number(b === patchAuthorDid) - Number(a === patchAuthorDid), + ); + return orderedKeys.flatMap(key => { + const group = groups.get(key) ?? []; + return settings.sortDesc ? [...group].reverse() : group; + }); +} + +/// The commits each revision adds over the revisions before it, in the order +/// `commitsOf` lists them. A later revision usually contains every earlier +/// commit, so counting its full range would count them again. +export function newCommitsByRevision( + revisions: { id: string }[], + commitsOf: (revisionId: string) => Commit[], +): Record { + const seen = new Set(); + const result: Record = {}; + for (const revision of revisions) { + const added = commitsOf(revision.id).filter(c => !seen.has(c.id)); + added.forEach(c => seen.add(c.id)); + result[revision.id] = added; + } + return result; +} diff --git a/desktop/src/lib/revisionListSettings.ts b/desktop/src/lib/revisionListSettings.ts new file mode 100644 index 000000000..4b738eb4c --- /dev/null +++ b/desktop/src/lib/revisionListSettings.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +import useLocalStorage from "@app/lib/useLocalStorage.svelte"; + +// How the revision dropdown is arranged. Kept out of the patch view so the +// choice follows the user across patches and repositories rather than +// resetting every time the view is mounted. +const schema = z.object({ + sortDesc: z.boolean().default(false), + // Bucket revisions per author, patch author first. + groupByAuthor: z.boolean().default(false), + // Off by default: the position is rarely what you are scanning for, and + // it is already spelled out on the dropdown's own button. + showNumber: z.boolean().default(false), + showStats: z.boolean().default(false), + showReviewers: z.boolean().default(true), +}); + +export type RevisionListSettings = z.infer; + +const storage = useLocalStorage("revisionListSettings", schema, { + sortDesc: false, + groupByAuthor: false, + showNumber: false, + showStats: false, + showReviewers: true, +}); + +export const revisionListSettings = { + get value(): RevisionListSettings { + return storage.value; + }, + + toggle(key: keyof RevisionListSettings) { + storage.value = { ...storage.value, [key]: !storage.value[key] }; + }, +}; diff --git a/desktop/src/lib/roles.ts b/desktop/src/lib/roles.ts new file mode 100644 index 000000000..12d05ba22 --- /dev/null +++ b/desktop/src/lib/roles.ts @@ -0,0 +1,43 @@ +import { publicKeyFromDid } from "@app/lib/utils"; + +export function isDelegate( + publicKey: string | undefined, + delegates: string[], +): true | undefined { + if (!publicKey) { + return undefined; + } + return ( + delegates.some(delegate => publicKeyFromDid(delegate) === publicKey) || + undefined + ); +} + +export function isDelegateOrAuthor( + publicKey: string | undefined, + delegates: string[], + author: string, +): true | undefined { + return ( + isDelegate(publicKey, delegates) || + publicKey === publicKeyFromDid(author) || + undefined + ); +} + +/// The protocol lets the comment author, the review author or the revision +/// author resolve a review comment; delegates may do anything. +export function canResolveReviewComment( + publicKey: string, + delegates: string[], + authors: { + comment: string | undefined; + review: string | undefined; + revision: string | undefined; + }, +): boolean { + if (isDelegate(publicKey, delegates)) return true; + return [authors.comment, authors.review, authors.revision].some( + did => did !== undefined && publicKeyFromDid(did) === publicKey, + ); +} diff --git a/desktop/src/lib/router.ts b/desktop/src/lib/router.ts new file mode 100644 index 000000000..2ba46de13 --- /dev/null +++ b/desktop/src/lib/router.ts @@ -0,0 +1,252 @@ +import { repoRouteToPath, repoUrlToRoute } from "@app/views/repo/router"; +import { on } from "svelte/events"; +import { get, writable } from "svelte/store"; + +import * as mutexExecutor from "@app/lib/mutexExecutor"; +import type { LoadedRoute, Route } from "@app/lib/router/definitions"; +import { loadRoute } from "@app/lib/router/definitions"; +import * as utils from "@app/lib/utils"; + +export { type Route }; + +const InitialStore = { resource: "booting" as const }; + +export const isLoading = writable(true); +export const activeRouteStore = writable(InitialStore); +export const activeUnloadedRouteStore = writable(InitialStore); + +let currentUrl: URL | undefined; + +const HISTORY_INDEX = "radicleHistoryIndex"; + +let historyDepth = 0; + +export const canGoBack = writable(false); +export const canGoForward = writable(false); + +function historyIndex(): number { + const state: unknown = window.history.state; + const index = + state && typeof state === "object" + ? (state as Record)[HISTORY_INDEX] + : undefined; + return typeof index === "number" ? index : 0; +} + +function stamp(route: Route, index: number): unknown { + return { ...route, [HISTORY_INDEX]: index }; +} + +function updateNavigationState(index: number, truncated = false): void { + historyDepth = truncated ? index : Math.max(historyDepth, index); + canGoBack.set(index > 0); + canGoForward.set(index < historyDepth); +} + +// Set while a back/forward (popstate) navigation is in flight, then frozen onto +// `historyNavigation` for the route that becomes active, so views can choose to +// restore prior scroll state only for history navigations. +let pendingHistoryNavigation = false; +let historyNavigation = false; + +export function isHistoryNavigation(): boolean { + return historyNavigation; +} + +export async function loadFromLocation(): Promise { + await navigateToUrl("replace", new URL(window.location.href)); +} + +async function navigateToUrl( + action: "push" | "replace", + url: URL, +): Promise { + const { pathname, hash } = url; + + if (url.origin !== window.origin) { + throw new Error("Cannot navigate to other origin"); + } + + if ( + currentUrl && + currentUrl.pathname === pathname && + currentUrl.search === url.search + ) { + // The navigation this flag was set for ends here; without the reset a + // same-URL popstate would leak it onto the next unrelated navigation, + // which would then wrongly restore cached list state. + pendingHistoryNavigation = false; + updateNavigationState(historyIndex()); + return; + } + + const relativeUrl = pathname + url.search + (hash || ""); + url = new URL(relativeUrl, window.origin); + const route = urlToRoute(url); + + if (route) { + await navigate(action, route); + } else { + console.error(`Could not resolve route for URL: ${url.href}`); + await navigate(action, { resource: "inbox" }); + } +} + +const offPopstate = on(window, "popstate", () => { + pendingHistoryNavigation = true; + return loadFromLocation(); +}); + +const offNavigateAnchor = on(document, "click", e => { + // A link that handled its own click, like one in a rendered document, + // must not be followed as a route as well. + if (e.defaultPrevented) { + return; + } + const [anchor] = e + .composedPath() + .flatMap(target => (target instanceof HTMLAnchorElement ? [target] : [])); + if (anchor && anchor.getAttribute("href")?.startsWith("/")) { + e.preventDefault(); + void navigateToUrl( + "push", + new URL(anchor.getAttribute("href") ?? "", window.location.href), + ); + } +}); + +if (import.meta.hot) { + import.meta.hot.dispose(() => { + offPopstate(); + offNavigateAnchor(); + }); +} + +const loadExecutor = mutexExecutor.create(); + +async function navigate( + action: "push" | "replace", + newRoute: Route, + fragment?: string, +): Promise { + const historyNav = pendingHistoryNavigation; + pendingHistoryNavigation = false; + isLoading.set(true); + const path = routeToPath(newRoute); + + if ( + action === "push" && + path !== "" && + path !== window.location.pathname + window.location.search + ) { + // Pushing the route that is already active would mint a duplicate + // history entry, making Back appear to do nothing. Booting has no path. + const index = historyIndex() + 1; + window.history.pushState( + stamp(newRoute, index), + "", + fragment ? `${path}#${encodeURIComponent(fragment)}` : path, + ); + updateNavigationState(index, true); + } else if (action === "replace") { + const index = historyIndex(); + window.history.replaceState(stamp(newRoute, index), ""); + updateNavigationState(index); + } else { + updateNavigationState(historyIndex()); + } + currentUrl = new URL(window.location.href); + const currentLoadedRoute = get(activeRouteStore); + + const loadedRoute = await loadExecutor.run(async () => { + return loadRoute(newRoute, currentLoadedRoute); + }); + + // Only let the last request through. + if (loadedRoute === undefined) { + return; + } + + historyNavigation = historyNav; + activeRouteStore.set(loadedRoute); + activeUnloadedRouteStore.set(newRoute); + isLoading.set(false); + Array.from( + document.getElementsByClassName("global-reset-scroll-after-navigate"), + ).forEach(el => { + el.scrollTo(0, 0); + }); +} + +export async function push(newRoute: Route, fragment?: string): Promise { + await navigate("push", newRoute, fragment); +} + +export async function replace(newRoute: Route): Promise { + await navigate("replace", newRoute); +} + +function inboxUrlToRoute(url: URL): { resource: "inbox" } | undefined { + if (url.pathname === "/inbox") { + return { resource: "inbox" }; + } +} + +function guideUrlToRoute(url: URL): { resource: "guide" } | undefined { + if (url.pathname === "/guide") { + return { resource: "guide" }; + } +} + +function urlToRoute(url: URL): Route | null { + const segments = url.pathname.substring(1).split("/"); + const resource = segments.shift(); + + if (url.pathname === "/") { + return { resource: "inbox" }; + } + + const inboxRoute = inboxUrlToRoute(url); + if (inboxRoute) { + return inboxRoute; + } + + const guideRoute = guideUrlToRoute(url); + if (guideRoute) { + return guideRoute; + } + + switch (resource) { + case "repos": { + return repoUrlToRoute(segments, url.searchParams); + } + default: { + return null; + } + } +} + +export function routeToPath(route: Route): string { + if (route.resource === "inbox") { + return "/inbox"; + } else if (route.resource === "guide") { + return "/guide"; + } else if ( + route.resource === "repo.home" || + route.resource === "repo.commits" || + route.resource === "repo.commit" || + route.resource === "repo.identity" || + route.resource === "repo.issue" || + route.resource === "repo.issues" || + route.resource === "repo.patch" || + route.resource === "repo.patches" || + route.resource === "repo.release" || + route.resource === "repo.releases" + ) { + return repoRouteToPath(route); + } else if (route.resource === "booting") { + return ""; + } else { + return utils.unreachable(route); + } +} diff --git a/desktop/src/lib/router/definitions.ts b/desktop/src/lib/router/definitions.ts new file mode 100644 index 000000000..ad6fde19e --- /dev/null +++ b/desktop/src/lib/router/definitions.ts @@ -0,0 +1,134 @@ +import type { LoadedRepoRoute, RepoRoute } from "@app/views/repo/router"; +import type { NotificationsByRepo } from "@bindings/cob/inbox/NotificationsByRepo"; +import type { Config } from "@bindings/config/Config"; +import type { RepoSummary } from "@bindings/repo/RepoSummary"; + +import { + loadIdentity, + loadIssue, + loadIssues, + loadPatch, + loadPatches, + loadRelease, + loadReleases, + loadRepoCommit, + loadRepoCommits, + loadRepoHome, +} from "@app/views/repo/router"; + +import { cachedListReposSummary, invoke } from "@app/lib/invoke"; +import { repoListScope } from "@app/lib/repoListScope"; + +interface BootingRoute { + resource: "booting"; +} + +interface InboxRoute { + resource: "inbox"; +} + +interface GuideRoute { + resource: "guide"; +} + +export interface SidebarData { + config: Config; + repos: RepoSummary[]; + notificationCount: number; + seededNotReplicated: string[]; +} + +export interface LoadedInboxRoute { + resource: "inbox"; + params: { + sidebarData: SidebarData; + notificationsByRepo: NotificationsByRepo[]; + }; +} + +export interface LoadedGuideRoute { + resource: "guide"; + params: { sidebarData: SidebarData }; +} + +export type Route = BootingRoute | RepoRoute | InboxRoute | GuideRoute; +export type LoadedRoute = + BootingRoute | LoadedRepoRoute | LoadedInboxRoute | LoadedGuideRoute; + +export function isLoadedRepoRoute( + route: LoadedRoute, +): route is LoadedRepoRoute { + return ( + route.resource === "repo.home" || + route.resource === "repo.commits" || + route.resource === "repo.commit" || + route.resource === "repo.identity" || + route.resource === "repo.issue" || + route.resource === "repo.issues" || + route.resource === "repo.patch" || + route.resource === "repo.patches" || + route.resource === "repo.release" || + route.resource === "repo.releases" + ); +} + +export async function loadSidebarData(): Promise { + const [config, repos, notificationCount, seededNotReplicated] = + await Promise.all([ + invoke("config"), + cachedListReposSummary(repoListScope.value), + invoke("notification_count"), + invoke("seeded_not_replicated"), + ]); + return { config, repos, notificationCount, seededNotReplicated }; +} + +export async function loadGuide(): Promise { + const sidebarData = await loadSidebarData(); + return { resource: "guide", params: { sidebarData } }; +} + +export async function loadInbox(): Promise { + const [sidebarData, notificationsByRepo] = await Promise.all([ + loadSidebarData(), + invoke("list_notifications", { + params: { take: 100 }, + }), + ]); + return { + resource: "inbox", + params: { sidebarData, notificationsByRepo }, + }; +} + +export async function loadRoute( + route: Route, + previousLoaded: LoadedRoute, +): Promise { + if (route.resource === "inbox") { + return loadInbox(); + } else if (route.resource === "guide") { + return loadGuide(); + } else if (route.resource === "repo.home") { + return loadRepoHome(route); + } else if (route.resource === "repo.commits") { + return loadRepoCommits(route); + } else if (route.resource === "repo.commit") { + return loadRepoCommit(route); + } else if (route.resource === "repo.identity") { + return loadIdentity(route); + } else if (route.resource === "repo.issue") { + return loadIssue(route); + } else if (route.resource === "repo.issues") { + return loadIssues(route); + } else if (route.resource === "repo.patch") { + return loadPatch(route, previousLoaded); + } else if (route.resource === "repo.patches") { + return loadPatches(route); + } else if (route.resource === "repo.release") { + return loadRelease(route); + } else if (route.resource === "repo.releases") { + return loadReleases(route); + } + return route; +} diff --git a/desktop/src/lib/seedRepo.ts b/desktop/src/lib/seedRepo.ts new file mode 100644 index 000000000..cf4e8ca5a --- /dev/null +++ b/desktop/src/lib/seedRepo.ts @@ -0,0 +1,43 @@ +import { writable } from "svelte/store"; + +import { invalidateReposSummary, invoke } from "@app/lib/invoke"; +import { parseRepositoryId } from "@app/lib/utils"; + +// Validates a RID typed into the "Seed a repo" form. The form accepts a bare +// `z…` id as well as `rad:z…`, so the result is the RID in the `rad:` form the +// seeded and queued lists use. +export function seedTarget( + input: string, + queued: string[], + seeded: string[], +): { rid: string } | { error: string } { + const parsed = parseRepositoryId(input.trim()); + if (parsed === undefined) { + return { error: "RID is not valid" }; + } + const rid = `${parsed.prefix}${parsed.pubkey}`; + if (queued.includes(rid)) { + return { error: "This repo is already queued for fetching" }; + } + if (seeded.includes(rid)) { + return { error: "This repo is already seeded" }; + } + return { rid }; +} + +const seeded = writable(0); +const fetching = writable([]); + +// Bumped whenever a repo is seeded from outside the sidebar, which only polls +// while something is already being fetched. +export const seededRepos = { subscribe: seeded.subscribe }; + +// Seeded repos that haven't been fetched yet, as the sidebar last polled them. +export const fetchingRepos = { subscribe: fetching.subscribe }; +export const setFetchingRepos = fetching.set; + +export async function seedRepo(rid: string): Promise { + await invoke("seed", { rid }); + invalidateReposSummary(); + seeded.update(count => count + 1); +} diff --git a/desktop/src/lib/shareAction.svelte.ts b/desktop/src/lib/shareAction.svelte.ts new file mode 100644 index 000000000..a60d86228 --- /dev/null +++ b/desktop/src/lib/shareAction.svelte.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +export const shareActionSchema = z.enum(["open", "copyLink", "copyId"]); + +export type ShareAction = z.infer; + +const KEY = "shareAction"; + +export const defaultShareAction: ShareAction = "copyLink"; + +function load(): ShareAction { + const raw = globalThis.localStorage?.getItem(KEY); + if (!raw) { + return defaultShareAction; + } + const parsed = shareActionSchema.safeParse(raw); + return parsed.success ? parsed.data : defaultShareAction; +} + +export const shareAction = $state<{ value: ShareAction }>({ value: load() }); + +// A detached root effect persists changes for the whole session; the module +// singleton is never torn down. +$effect.root(() => { + $effect(() => { + globalThis.localStorage?.setItem(KEY, shareAction.value); + }); +}); diff --git a/desktop/src/lib/shortcuts.svelte.ts b/desktop/src/lib/shortcuts.svelte.ts new file mode 100644 index 000000000..f90cceeb8 --- /dev/null +++ b/desktop/src/lib/shortcuts.svelte.ts @@ -0,0 +1,314 @@ +import { untrack } from "svelte"; +import { get } from "svelte/store"; + +import { modalStore } from "@app/lib/modal"; +import { isMac, modifierKey } from "@app/lib/utils"; + +// A combo is the key, optionally preceded by modifiers: "Mod+F", "Alt+ArrowLeft", +// "?". "Mod" is Cmd on macOS and Ctrl elsewhere. Keys are `KeyboardEvent.key` +// values, except that letters and digits are case-insensitive. +export type Combo = string; + +interface ShortcutDoc { + combos: Combo[]; + description: string; + // Where the shortcut applies, when that isn't everywhere. + note?: string; + // Shown in the shortcuts modal instead of the first combo. + label?: Combo; +} + +// Every shortcut in the app, in the order the shortcuts modal lists them. +// Components bind behaviour to these by id, so the keys live only here. +export const shortcuts = { + help: { combos: ["?"], description: "Keyboard shortcuts" }, + filter: { + combos: ["Mod+F"], + description: "Focus a filter on screen, press again to cycle through them", + }, + newIssue: { + combos: ["Mod+N"], + description: "New issue", + note: "In a repository", + }, + goToRepo: { + combos: Array.from({ length: 9 }, (_, i) => `Mod+${i + 1}`), + label: "Mod+1…9", + description: "Go to the nth repo in the sidebar", + }, + back: { + combos: [isMac() ? "Mod+[" : "Alt+ArrowLeft"], + description: "Go back", + }, + forward: { + combos: [isMac() ? "Mod+]" : "Alt+ArrowRight"], + description: "Go forward", + }, + previousCommit: { + combos: ["ArrowUp"], + description: "Previous commit", + note: "In a patch's changes", + }, + nextCommit: { + combos: ["ArrowDown"], + description: "Next commit", + note: "In a patch's changes", + }, + allCommits: { + combos: ["Escape"], + description: "Show all commits", + note: "In a patch's changes", + }, + toggleSidebar: { combos: ["Mod+B"], description: "Toggle sidebar" }, + settings: { combos: ["Mod+,"], description: "Settings" }, + reload: { combos: ["Mod+R"], description: "Reload" }, + // Covers where the plus key sits on different keyboard layouts. + increaseFontSize: { + combos: ["Mod++", "Mod+="], + label: "Mod++", + description: "Increase font size", + }, + decreaseFontSize: { combos: ["Mod+-"], description: "Decrease font size" }, + resetFontSize: { combos: ["Mod+0"], description: "Reset font size" }, + closeModal: { + combos: ["Escape"], + description: "Close modal", + note: "In a modal", + }, + submit: { + combos: ["Mod+Enter"], + description: "Submit", + note: "In a comment or description", + }, + bold: { + combos: ["Mod+B"], + description: "Bold", + note: "In a comment or description", + }, + italic: { + combos: ["Mod+I"], + description: "Italic", + note: "In a comment or description", + }, + code: { + combos: ["Mod+E"], + description: "Code", + note: "In a comment or description", + }, + link: { + combos: ["Mod+K"], + description: "Link", + note: "In a comment or description", + }, + pasteLink: { + combos: ["Mod+V"], + description: "Paste a URL over selected text to link it", + note: "In a comment or description", + }, +} satisfies Record; + +export type ShortcutId = keyof typeof shortcuts; + +export interface Binding { + shortcut: ShortcutId; + // Gets the index of the combo that matched. Returning false passes the key + // on to the next binding for it. + run: (event: KeyboardEvent, combo: number) => unknown; + enabled?: () => boolean; + // Whether what the binding acts on already has focus. The next press then + // moves on to the following binding, wrapping around, so repeated presses + // cycle through them. + active?: () => boolean; + allowInModal?: boolean; + // Tried before other bindings for the same keys. Among equals, the most + // recently registered one goes first. + priority?: number; +} + +let registered = $state.raw([]); +let modifierDown = $state(false); + +// Whether Mod is held down in a state where Mod shortcuts would run. +export const modifierHeld = { + get value() { + return modifierDown; + }, +}; + +function register(binding: Binding): () => void { + registered = [...registered, binding]; + return () => { + registered = registered.filter(b => b !== binding); + }; +} + +// Binds shortcuts for the lifetime of the calling component. +export function useShortcuts(...bindings: Binding[]) { + $effect(() => { + // Registering reads the registry it writes to, which would otherwise make + // every registration re-run all of them. + const unregister = untrack(() => bindings.map(register)); + return () => untrack(() => unregister.forEach(fn => fn())); + }); +} + +function parse(combo: Combo): { modifiers: string[]; key: string } { + const parts = combo.split("+"); + // "Mod++" splits into a trailing pair of empty strings. + const key = parts.pop() || "+"; + return { modifiers: parts.filter(Boolean), key }; +} + +function isAlphanumeric(key: string): boolean { + return /^[a-z0-9]$/i.test(key); +} + +export function matchesCombo(event: KeyboardEvent, combo: Combo): boolean { + const { modifiers, key } = parse(combo); + const [mod, otherMod] = isMac() + ? [event.metaKey, event.ctrlKey] + : [event.ctrlKey, event.metaKey]; + if (mod !== modifiers.includes("Mod") || otherMod) return false; + if (event.altKey !== modifiers.includes("Alt")) return false; + // Shift is part of how symbols like "?" and "+" are typed on most layouts. + const symbol = key.length === 1 && !isAlphanumeric(key); + if (!symbol && event.shiftKey !== modifiers.includes("Shift")) return false; + + if (!isAlphanumeric(key)) return event.key === key; + if (event.key.toLowerCase() === key.toLowerCase()) return true; + // On a non-Latin layout, fall back to the key's position on a QWERTY one. + return ( + event.key.length === 1 && + event.key.charCodeAt(0) > 127 && + event.code === (/\d/.test(key) ? `Digit${key}` : `Key${key.toUpperCase()}`) + ); +} + +export function matchesShortcut(event: KeyboardEvent, id: ShortcutId): boolean { + return (shortcuts[id].combos as Combo[]).some(c => matchesCombo(event, c)); +} + +const keyLabels: Record = { + ArrowLeft: "←", + ArrowRight: "→", + ArrowUp: "↑", + ArrowDown: "↓", + Escape: "esc", +}; + +export function comboKeys(combo: Combo): string[] { + const { modifiers, key } = parse(combo); + const modifierLabels = modifiers.map(m => + m === "Mod" + ? modifierKey() + : m === "Alt" + ? isMac() + ? "⌥" + : "alt" + : m.toLowerCase(), + ); + return [...modifierLabels, keyLabels[key] ?? key.toLowerCase()]; +} + +export function ariaKeyShortcuts(id: ShortcutId): string { + const { modifiers, key } = parse(shortcuts[id].combos[0]); + const modifierNames = modifiers.map(m => + m === "Mod" ? (isMac() ? "Meta" : "Control") : m, + ); + return [...modifierNames, key.length === 1 ? key.toUpperCase() : key].join( + "+", + ); +} + +function isTyping(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + const tag = target.tagName.toLowerCase(); + return tag === "input" || tag === "textarea" || target.isContentEditable; +} + +function isModifierKey(event: KeyboardEvent): boolean { + return event.key === (isMac() ? "Meta" : "Control"); +} + +// Fields marked with `data-mod-shortcuts`, like filters, hold nothing worth +// protecting, so shortcuts with Mod still work from them. +function blocksShortcuts(event: KeyboardEvent, modDown: boolean): boolean { + if (!isTyping(event.target)) { + return false; + } + const allowsMod = + event.target instanceof HTMLElement && + event.target.hasAttribute("data-mod-shortcuts"); + return !(allowsMod && modDown); +} + +function onKeydown(event: KeyboardEvent) { + const modalOpen = get(modalStore) !== undefined; + if (isModifierKey(event)) { + modifierDown = !modalOpen && !blocksShortcuts(event, true); + } + const modDown = isMac() ? event.metaKey : event.ctrlKey; + // Keys a component already handled, and anything typed into a field, are + // left alone. + if ( + event.defaultPrevented || + event.isComposing || + blocksShortcuts(event, modDown) + ) { + return; + } + + const candidates = registered + .map((binding, index) => ({ + binding, + index, + combo: (shortcuts[binding.shortcut].combos as Combo[]).findIndex(c => + matchesCombo(event, c), + ), + })) + .filter( + ({ binding, combo }) => + combo !== -1 && + (!modalOpen || binding.allowInModal) && + binding.enabled?.() !== false, + ) + .sort( + (a, b) => + (b.binding.priority ?? 0) - (a.binding.priority ?? 0) || + b.index - a.index, + ); + const active = candidates.findIndex(({ binding }) => binding.active?.()); + const ordered = + active === -1 + ? candidates + : [...candidates.slice(active + 1), ...candidates.slice(0, active + 1)]; + for (const { binding, combo } of ordered) { + if (binding.run(event, combo) !== false) { + event.preventDefault(); + return; + } + } +} + +function onKeyup(event: KeyboardEvent) { + if (isModifierKey(event)) { + modifierDown = false; + } +} + +function onBlur() { + modifierDown = false; +} + +export function listenForShortcuts(): () => void { + document.addEventListener("keydown", onKeydown); + document.addEventListener("keyup", onKeyup); + window.addEventListener("blur", onBlur); + return () => { + document.removeEventListener("keydown", onKeydown); + document.removeEventListener("keyup", onKeyup); + window.removeEventListener("blur", onBlur); + }; +} diff --git a/desktop/src/lib/sidebar.svelte.ts b/desktop/src/lib/sidebar.svelte.ts new file mode 100644 index 000000000..fc239c259 --- /dev/null +++ b/desktop/src/lib/sidebar.svelte.ts @@ -0,0 +1,96 @@ +import { boolean, number } from "zod"; + +import useLocalStorage from "@app/lib/useLocalStorage.svelte"; + +export const MIN_SIDEBAR_WIDTH = 12; +export const MAX_SIDEBAR_WIDTH = 30; +export const DEFAULT_SIDEBAR_WIDTH = 16.5; + +// Wide enough to clear macOS's traffic lights (~70px in), which would otherwise +// spill onto the content pane. Not platform-gated, to keep one rail geometry. +export const RAIL_WIDTH_REM = 5; + +// The widths above are rem and the user can scale the root font size, so cap +// the share of the window the sidebar may take. Mirrored by `max-width` on the +// slot, which also covers the window being resized afterwards. +const MAX_VIEWPORT_FRACTION = 0.4; + +export function rootFontSize(): number { + return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; +} + +export function clampSidebarWidth(rem: number): number { + if (!Number.isFinite(rem)) return DEFAULT_SIDEBAR_WIDTH; + const viewportMax = + (window.innerWidth * MAX_VIEWPORT_FRACTION) / rootFontSize(); + // Never below the minimum, even in a window too narrow to honour the share. + const max = Math.max( + MIN_SIDEBAR_WIDTH, + Math.min(MAX_SIDEBAR_WIDTH, viewportMax), + ); + return Math.min(max, Math.max(MIN_SIDEBAR_WIDTH, rem)); +} + +export const sidebarCollapsed = useLocalStorage( + "sidebarCollapsed", + boolean(), + false, + !window.localStorage, +); + +export const sidebarWidth = useLocalStorage( + "sidebarWidth", + number(), + DEFAULT_SIDEBAR_WIDTH, + !window.localStorage, +); + +// Writes are clamped; a value stored by a build with different bounds is not. +// Only written back when out of range, to keep startup off localStorage. +const restoredWidth = clampSidebarWidth(sidebarWidth.value); +if (sidebarWidth.value !== restoredWidth) { + sidebarWidth.value = restoredWidth; +} + +// True while dragging the resize edge; suppresses the width transition so the +// sidebar tracks the pointer 1:1. +export const sidebarResizing = $state({ value: false }); + +// Held out of `sidebarWidth`, which writes to localStorage on every set. +const draft = $state<{ width: number | undefined }>({ width: undefined }); + +// The live drag value while resizing, the stored one otherwise. +export const renderedSidebarWidth = { + get value() { + return draft.width ?? sidebarWidth.value; + }, +}; + +export function previewSidebarWidth(rem: number) { + draft.width = clampSidebarWidth(rem); +} + +// Drops the drag's width without storing it, for a drag that ends up collapsing +// instead: the widths it passed through on the way down are not a width the +// user chose, so the stored one is left alone to reopen at. +export function discardSidebarWidthPreview() { + draft.width = undefined; +} + +// A one-shot width change, for callers that aren't tracking a pointer. +export function setSidebarWidth(rem: number) { + draft.width = undefined; + sidebarWidth.value = clampSidebarWidth(rem); +} + +// The single storage write for a whole drag. +export function commitSidebarWidth() { + if (draft.width !== undefined) { + sidebarWidth.value = draft.width; + draft.width = undefined; + } +} + +export function toggleSidebar() { + sidebarCollapsed.value = !sidebarCollapsed.value; +} diff --git a/desktop/src/lib/sidebarResize.ts b/desktop/src/lib/sidebarResize.ts new file mode 100644 index 000000000..ea83ada45 --- /dev/null +++ b/desktop/src/lib/sidebarResize.ts @@ -0,0 +1,49 @@ +import { MIN_SIDEBAR_WIDTH, RAIL_WIDTH_REM } from "@app/lib/sidebar.svelte"; + +// How far below the minimum width the drag has to go before it collapses, +// rather than the edge just sticking at the minimum. +const COLLAPSE_DRAG_SLACK_REM = 2; + +// While collapsed, the rail follows the drag at a fraction of its distance so +// it feels attached to the pointer, instead of sitting still until the +// threshold and then jumping open. +const RAIL_STRETCH_FACTOR = 0.35; + +// Where a drag of the sidebar edge leaves it. One boundary serves both +// directions: below it the sidebar is collapsed, above it expanded, so a +// single drag can cross either way. +export function dragResult( + startWidth: number, + deltaPx: number, + pxPerRem: number, +): { collapse: boolean; width: number; railStretch: number } { + const width = startWidth + deltaPx / pxPerRem; + const collapse = width < MIN_SIDEBAR_WIDTH - COLLAPSE_DRAG_SLACK_REM; + const railStretch = collapse + ? Math.max(0, (width - RAIL_WIDTH_REM) * RAIL_STRETCH_FACTOR) + : 0; + return { collapse, width, railStretch }; +} + +const DOUBLE_PRESS_MS = 500; +const DOUBLE_PRESS_SLOP_PX = 6; + +// A second press in quick succession, close to the first. A press that ended +// in a resize isn't half of a pair, and a pair is cleared so a third press +// starts a new one. +export function doublePressDetector() { + let lastPressAt = -Infinity; + let lastPressX = 0; + return ( + press: { timeStamp: number; clientX: number }, + lastPressResized: boolean, + ): boolean => { + const paired = + !lastPressResized && + press.timeStamp - lastPressAt < DOUBLE_PRESS_MS && + Math.abs(press.clientX - lastPressX) <= DOUBLE_PRESS_SLOP_PX; + lastPressAt = paired ? -Infinity : press.timeStamp; + lastPressX = press.clientX; + return paired; + }; +} diff --git a/desktop/src/lib/sleep.ts b/desktop/src/lib/sleep.ts new file mode 100644 index 000000000..7d994df1e --- /dev/null +++ b/desktop/src/lib/sleep.ts @@ -0,0 +1,5 @@ +export function sleep(timeMs: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, timeMs); + }); +} diff --git a/desktop/src/lib/stageArtifacts.ts b/desktop/src/lib/stageArtifacts.ts new file mode 100644 index 000000000..f9e229b01 --- /dev/null +++ b/desktop/src/lib/stageArtifacts.ts @@ -0,0 +1,188 @@ +import type { Artifact } from "@bindings/cob/release/Artifact"; +import type { ArtifactDigest } from "@bindings/cob/release/ArtifactDigest"; + +import { formatBytes } from "@app/lib/utils"; + +export interface Picked { + path: string; + name: string; + digest: ArtifactDigest; +} + +// Identity is the content hash, so picking bytes the release already holds +// lands on the existing entry. +export interface StagedArtifact extends Picked { + register: boolean; + seed: boolean; + duplicateOf?: string; + existing?: { + name: string; + keepsName: boolean; + redaction?: string; + }; +} + +export function stageArtifacts( + picked: Picked[], + artifacts: Artifact[], + options: { + ownDid: string; + delegates: Set; + nodeRunning: boolean; + seeded: Set; + }, +): StagedArtifact[] { + const byCid = new Map(artifacts.map(a => [a.cid, a])); + const firstPick = new Map(); + return picked.map(item => { + const duplicateOf = firstPick.get(item.digest.cid); + if (duplicateOf !== undefined) { + return { ...item, register: false, seed: false, duplicateOf }; + } + firstPick.set(item.digest.cid, item.name); + const match = byCid.get(item.digest.cid); + const seed = options.nodeRunning && !options.seeded.has(item.digest.cid); + if (!match) { + return { ...item, register: true, seed }; + } + const own = match.author.did === options.ownDid; + const renamed = match.name !== item.name; + const redaction = match.redacted + ? (match.redactions.find( + r => + r.user.did === match.author.did || + options.delegates.has(r.user.did), + )?.reason ?? "") + : undefined; + return { + ...item, + register: own && renamed, + seed, + existing: { + name: match.name, + keepsName: renamed && !own, + redaction, + }, + }; + }); +} + +// A redacted match is left alone unless you confirm registering it again. +export function effective( + item: StagedArtifact, + includeRedacted: boolean, +): { register: boolean; seed: boolean } { + if (item.existing?.redaction !== undefined && !includeRedacted) { + return { register: false, seed: false }; + } + return { register: item.register, seed: item.seed }; +} + +export interface PlannedArtifact { + item: StagedArtifact; + register: boolean; + seed: boolean; +} + +export interface RegisterPlan { + items: PlannedArtifact[]; + acting: PlannedArtifact[]; + registering: PlannedArtifact[]; + newCount: number; + renamedCount: number; + seededCount: number; + skippedCount: number; +} + +export function registerPlan( + staged: StagedArtifact[], + includeRedacted: boolean, +): RegisterPlan { + const items = staged.map(item => ({ + item, + ...effective(item, includeRedacted), + })); + const acting = items.filter(p => p.register || p.seed); + const registering = items.filter(p => p.register); + const newCount = registering.filter(p => !p.item.existing).length; + return { + items, + acting, + registering, + newCount, + renamedCount: registering.length - newCount, + seededCount: acting.length - registering.length, + skippedCount: items.length - acting.length, + }; +} + +export function planTitle(plan: RegisterPlan): string { + const count = plan.acting.length; + if (count === 0) { + return "Already registered"; + } + const verb = plan.newCount > 0 ? "Register" : "Update"; + return `${verb} ${count} ${count === 1 ? "artifact" : "artifacts"}`; +} + +export function planSummary(plan: RegisterPlan): string { + return ( + [ + [plan.newCount, "registered"], + [plan.renamedCount, "renamed"], + [plan.seededCount, "seeded"], + [plan.skippedCount, "skipped"], + ] as const + ) + .filter(([count]) => count !== 0) + .map(([count, label]) => `${count} ${label}`) + .join(", "); +} + +export function planDetail( + { item, register, seed }: PlannedArtifact, + includeRedacted: boolean, +): string { + const existing = item.existing; + if (item.duplicateOf !== undefined) { + return `same as “${item.duplicateOf}” · skipped`; + } + if (!existing) { + const size = formatBytes(item.digest.sizeBytes); + const files = item.digest.fileCount; + return item.digest.directory + ? `${files} ${files === 1 ? "file" : "files"} · ${size}` + : size; + } + if (existing.redaction !== undefined && !includeRedacted) { + return "redacted · skipped"; + } + const name = register + ? `renames “${existing.name}”` + : existing.keepsName + ? `stays “${existing.name}”` + : "already registered"; + if (seed) { + return `${name} · will seed`; + } + return register ? name : `${name} · skipped`; +} + +export function duplicatePicks( + picks: { path: string; name: string; digest?: { cid: string } }[], +): Map { + const first = new Map(); + const duplicates = new Map(); + for (const pick of picks) { + if (!pick.digest) { + continue; + } + const earlier = first.get(pick.digest.cid); + if (earlier === undefined) { + first.set(pick.digest.cid, pick.name); + } else { + duplicates.set(pick.path, earlier); + } + } + return duplicates; +} diff --git a/desktop/src/lib/startup.svelte.ts b/desktop/src/lib/startup.svelte.ts new file mode 100644 index 000000000..651b187e9 --- /dev/null +++ b/desktop/src/lib/startup.svelte.ts @@ -0,0 +1,42 @@ +import type { UnlistenFn } from "@tauri-apps/api/event"; + +import { listen } from "@tauri-apps/api/event"; +import once from "lodash/once"; + +import { invoke } from "@app/lib/invoke"; + +import { artifactNodeRunning, nodeRunning } from "./events"; + +export let unlistenNodeEvents: UnlistenFn | undefined = undefined; + +export function setUnlistenNodeEvents(unlisten: UnlistenFn) { + unlistenNodeEvents = unlisten; +} + +// Will be called once in the startup of the app +export const createEventEmittersOnce = once(async (): Promise => { + if (!window.__TAURI_INTERNALS__) { + // The test backend can't emit events, so poll at the backend's pace. + const pollNodes = () => { + invoke("node_running") + .then(running => nodeRunning.set(running)) + .catch(console.error); + invoke("artifact_node_running") + .then(running => artifactNodeRunning.set(running)) + .catch(console.error); + }; + pollNodes(); + const interval = setInterval(pollNodes, 2_000); + return () => clearInterval(interval); + } + + const unlisteners = await Promise.all([ + listen("node_running", event => { + nodeRunning.set(event.payload); + }), + listen("artifact_node_running", event => { + artifactNodeRunning.set(event.payload); + }), + ]); + return () => unlisteners.forEach(unlisten => unlisten()); +}); diff --git a/desktop/src/lib/syntax.ts b/desktop/src/lib/syntax.ts new file mode 100644 index 000000000..f3b15c75a --- /dev/null +++ b/desktop/src/lib/syntax.ts @@ -0,0 +1,123 @@ +import type { Root } from "hast"; + +import { common, createStarryNight, type Grammar } from "@wooorm/starry-night"; +import etc from "@wooorm/starry-night/etc"; +import goMod from "@wooorm/starry-night/go.mod"; +import goSum from "@wooorm/starry-night/go.sum"; +import sourceBatchfile from "@wooorm/starry-night/source.batchfile"; +import sourceDockerfile from "@wooorm/starry-night/source.dockerfile"; +import sourceDotenv from "@wooorm/starry-night/source.dotenv"; +import sourceEditorconfig from "@wooorm/starry-night/source.editorconfig"; +import sourceErlang from "@wooorm/starry-night/source.erlang"; +import sourceGitrevlist from "@wooorm/starry-night/source.git-revlist"; +import sourceGitattributes from "@wooorm/starry-night/source.gitattributes"; +import sourceGitconfig from "@wooorm/starry-night/source.gitconfig"; +import sourceGitignore from "@wooorm/starry-night/source.gitignore"; +import sourceGradle from "@wooorm/starry-night/source.groovy.gradle"; +import sourceHaproxyConfig from "@wooorm/starry-night/source.haproxy-config"; +import sourceNpmrc from "@wooorm/starry-night/source.ini.npmrc"; +import sourceJson from "@wooorm/starry-night/source.json"; +import sourceNix from "@wooorm/starry-night/source.nix"; +import sourceSass from "@wooorm/starry-night/source.sass"; +import sourceSolidity from "@wooorm/starry-night/source.solidity"; +import sourceSvelte from "@wooorm/starry-night/source.svelte"; +import sourceToml from "@wooorm/starry-night/source.toml"; +import sourceTsx from "@wooorm/starry-night/source.tsx"; +import sourceZig from "@wooorm/starry-night/source.zig"; +import sourceAsciiDoc from "@wooorm/starry-night/text.html.asciidoc"; +import textHtmlDjango from "@wooorm/starry-night/text.html.django"; +import textHtmlVue from "@wooorm/starry-night/text.html.vue"; +import textRobotsTxt from "@wooorm/starry-night/text.robots-txt"; +import textZoneFile from "@wooorm/starry-night/text.zone_file"; +import onigurumaWASMUrl from "vscode-oniguruma/release/onig.wasm?url"; + +export { type Root }; + +export const grammars = [ + ...common, + sourceAsciiDoc, + sourceToml, + sourceErlang, + sourceSolidity, + sourceSvelte, + sourceSass, + sourceTsx, + sourceDockerfile, + sourceNix, + sourceGitconfig, + sourceGitignore, + sourceGitrevlist, + sourceGitattributes, + sourceNpmrc, + sourceGradle, + sourceBatchfile, + sourceEditorconfig, + sourceHaproxyConfig, + sourceDotenv, + sourceZig, + textHtmlVue, + textHtmlDjango, + textRobotsTxt, + textZoneFile, + etc, + goMod, + goSum, + { + extensions: [".hintrc"], + names: ["json"], + patterns: [sourceJson], + scopeName: "source.json", + }, + { + extensions: [ + ".npmignore", + ".eslintignore", + ".dockerignore", + ".nuxtignore", + ".vscodeignore", + ], + names: ["ignore"], + patterns: [sourceGitignore], + scopeName: "source.gitignore", + }, + { + extensions: [".sample", ".example", ".template"], + names: [".env.sample", ".env.example", ".env.template"], + patterns: [sourceDotenv], + scopeName: "source.dotenv", + }, + { + extensions: [".mod"], + names: ["go.mod"], + patterns: [goMod], + scopeName: "go.mode", + }, + { + extensions: [".sum"], + names: ["go.sum"], + patterns: [goSum], + scopeName: "go.sum", + }, + // A grammar that doesn't do any parsing, but needed for files without a known filetype. + { + extensions: [""], + names: ["raw-format"], + patterns: [], + scopeName: "text.raw", + }, +] satisfies Grammar[]; + +let starryNight: Awaited>; + +export async function highlight( + content: string, + grammar: string, +): Promise { + if (starryNight === undefined) { + starryNight = await createStarryNight(grammars, { + getOnigurumaUrlFetch: () => new URL(onigurumaWASMUrl, import.meta.url), + }); + } + const scope = starryNight.flagToScope(grammar); + return starryNight.highlight(content, scope ?? "text.raw"); +} diff --git a/desktop/src/lib/textareaCaret.ts b/desktop/src/lib/textareaCaret.ts new file mode 100644 index 000000000..3ce50cf16 --- /dev/null +++ b/desktop/src/lib/textareaCaret.ts @@ -0,0 +1,61 @@ +const mirroredProperties = [ + "boxSizing", + "borderBottomWidth", + "borderLeftWidth", + "borderRightWidth", + "borderTopWidth", + "fontFamily", + "fontSize", + "fontSizeAdjust", + "fontStretch", + "fontStyle", + "fontVariant", + "fontWeight", + "letterSpacing", + "lineHeight", + "paddingBottom", + "paddingLeft", + "paddingRight", + "paddingTop", + "tabSize", + "textIndent", + "textTransform", + "wordSpacing", +] as const; + +export function caretCoordinates( + textarea: HTMLTextAreaElement, + position: number, +): { top: number; left: number; height: number } { + const style = window.getComputedStyle(textarea); + const mirror = document.createElement("div"); + + mirror.style.position = "absolute"; + mirror.style.top = "0"; + mirror.style.left = "0"; + mirror.style.visibility = "hidden"; + mirror.style.whiteSpace = "pre-wrap"; + mirror.style.wordWrap = "break-word"; + mirror.style.overflow = "hidden"; + mirror.style.width = `${textarea.clientWidth}px`; + mirror.style.height = "auto"; + for (const property of mirroredProperties) { + mirror.style[property] = style[property]; + } + + mirror.textContent = textarea.value.slice(0, position).replace(/\n$/, "\n "); + + const marker = document.createElement("span"); + marker.textContent = "​"; + mirror.appendChild(marker); + + document.body.appendChild(mirror); + const { offsetTop, offsetLeft, offsetHeight } = marker; + document.body.removeChild(mirror); + + return { + top: offsetTop, + left: offsetLeft, + height: offsetHeight || parseFloat(style.lineHeight) || 0, + }; +} diff --git a/desktop/src/lib/threads.ts b/desktop/src/lib/threads.ts new file mode 100644 index 000000000..6368e02e5 --- /dev/null +++ b/desktop/src/lib/threads.ts @@ -0,0 +1,132 @@ +import type { Review } from "@bindings/cob/patch/Review"; +import type { Revision } from "@bindings/cob/patch/Revision"; +import type { CodeLocation } from "@bindings/cob/thread/CodeLocation"; +import type { Comment } from "@bindings/cob/thread/Comment"; +import type { Thread } from "@bindings/cob/thread/Thread"; + +/// Threads rooted at the comments `isRoot` picks, each with its replies oldest +/// first. +export function buildThreads( + comments: Comment[], + isRoot: (comment: Comment) => boolean, +): Thread[] { + return comments.filter(isRoot).map(root => ({ + root, + replies: comments + .filter(c => c.replyTo === root.id) + .sort((a, b) => a.edits[0].timestamp - b.edits[0].timestamp), + })); +} + +/// A code comment that starts a thread. +export function isCodeRoot(comment: Comment): boolean { + return Boolean(comment.location) && !comment.replyTo; +} + +/// Each review's code threads, for reviews that have any. +export function codeThreadsByReview( + reviews: Review[], +): Map[]> { + const map = new Map[]>(); + for (const review of reviews) { + const threads = buildThreads(review.comments ?? [], isCodeRoot); + if (threads.length > 0) map.set(review.id, threads); + } + return map; +} + +/// Comments on a review itself rather than on a line. The summary is a field, +/// not the root of this thread, so a comment replying to the review id starts +/// a thread of its own. +export function reviewDiscussionThreads( + review: Review, +): Thread[] { + return buildThreads( + review.comments ?? [], + c => (!c.location && !c.replyTo) || c.replyTo === review.id, + ); +} + +/// Each review's discussion threads, for reviews that have any. +export function discussionThreadsByReview( + reviews: Review[], +): Map[]> { + const map = new Map[]>(); + for (const review of reviews) { + const threads = reviewDiscussionThreads(review); + if (threads.length > 0) map.set(review.id, threads); + } + return map; +} + +/// Which review each comment in these threads belongs to. +export function reviewIdByComment( + ...sources: Map[]>[] +): Map { + const map = new Map(); + for (const source of sources) { + for (const [reviewId, threads] of source) { + for (const thread of threads) { + map.set(thread.root.id, reviewId); + for (const reply of thread.replies) map.set(reply.id, reviewId); + } + } + } + return map; +} + +/// Which revision each discussion comment was left on. +export function revisionIdByComment( + revisions: Revision[], +): Map { + const map = new Map(); + for (const revision of revisions) { + for (const comment of revision.discussion ?? []) { + map.set(comment.id, revision.id); + } + } + return map; +} + +/// One group per file, in order of first appearance, so a file's threads +/// render under a single header. +export function groupThreadsByFile( + threads: Thread[], +): { path: string; threads: Thread[] }[] { + const groups = new Map[]>(); + for (const thread of threads) { + const path = thread.root.location?.path; + if (!path) continue; + const group = groups.get(path) ?? []; + group.push(thread); + groups.set(path, group); + } + return [...groups.entries()].map(([path, threads]) => ({ path, threads })); +} + +/// The discussion threads of every revision, for the activity timeline. +/// +/// Code comments render on the diff instead. A comment whose body repeats one +/// of its author's review summaries is left out too, since the review already +/// shows that text. +export function revisionDiscussionThreads( + revisions: Revision[], +): Thread[] { + const summaries = new Set( + revisions + .flatMap(r => r.reviews ?? []) + .filter(r => r.summary && r.summary.trim() !== "") + .map(r => `${r.author.did} ${r.summary}`), + ); + return revisions.flatMap(revision => + buildThreads(revision.discussion ?? [], comment => { + const body = comment.edits[comment.edits.length - 1]?.body ?? ""; + return ( + ((comment.id !== revision.id && !comment.replyTo) || + comment.replyTo === revision.id) && + !comment.location && + !summaries.has(`${comment.author.did} ${body}`) + ); + }), + ); +} diff --git a/desktop/src/lib/timelineRuns.ts b/desktop/src/lib/timelineRuns.ts new file mode 100644 index 000000000..970a52271 --- /dev/null +++ b/desktop/src/lib/timelineRuns.ts @@ -0,0 +1,116 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Thread } from "@bindings/cob/thread/Thread"; + +import type { ActivityItem } from "@app/lib/cobActivity"; + +export type TimelineEntry = + | { kind: "thread"; key: string; timestamp: number; thread: Thread } + | { + kind: "activity"; + key: string; + timestamp: number; + data: A; + standalone: boolean; + }; + +type ActivityEntry = Extract, { kind: "activity" }>; + +export type Run = + | { kind: "thread"; entry: Extract, { kind: "thread" }> } + | { kind: "single"; entry: ActivityEntry; repeatsAuthor: boolean } + | { + kind: "group"; + author: Author; + entries: ActivityEntry[]; + repeatsAuthor: boolean; + }; + +/// Comment threads and activity in one list, oldest first. +export function mergeTimeline( + threads: Thread[], + activity: ActivityItem[], +): TimelineEntry[] { + return [ + ...threads.map( + thread => + ({ + kind: "thread", + key: thread.root.id, + timestamp: thread.root.edits[0].timestamp, + thread, + }) satisfies TimelineEntry, + ), + ...activity.map( + item => + ({ + kind: "activity", + key: item.key, + timestamp: item.timestamp, + data: item.data, + standalone: item.standalone === true, + }) satisfies TimelineEntry, + ), + ].sort((a, b) => a.timestamp - b.timestamp); +} + +/// Groups consecutive activity by the same author under one heading. +/// +/// A thread or a standalone entry breaks a run. A run only names its author +/// when the run before it was someone else's (`repeatsAuthor` is false), so the +/// same person's actions aren't re-attributed on the other side of their own +/// comment. +export function timelineRuns( + timeline: TimelineEntry[], + authorOf: (data: A) => Author | undefined = () => undefined, +): Run[] { + const entryAuthor = (entry: TimelineEntry) => + entry.kind === "thread" ? entry.thread.root.author : authorOf(entry.data); + const runAuthor = (run: Run) => { + if (run.kind === "thread") return run.entry.thread.root.author; + if (run.kind === "single") return entryAuthor(run.entry); + return run.author; + }; + + const result: Run[] = []; + for (const entry of timeline) { + if (entry.kind === "thread") { + result.push({ kind: "thread", entry }); + continue; + } + const author = entryAuthor(entry); + const last = result[result.length - 1]; + const groupable = + !entry.standalone && !(last?.kind === "single" && last.entry.standalone); + if ( + groupable && + author && + last && + ((last.kind === "single" && + entryAuthor(last.entry)?.did === author.did) || + (last.kind === "group" && last.author.did === author.did)) + ) { + if (last.kind === "single") { + result[result.length - 1] = { + kind: "group", + author, + entries: [last.entry, entry], + repeatsAuthor: last.repeatsAuthor, + }; + } else { + last.entries.push(entry); + } + } else { + result.push({ kind: "single", entry, repeatsAuthor: false }); + } + } + for (let i = 1; i < result.length; i++) { + const run = result[i]; + if (run.kind === "thread") continue; + const previous = runAuthor(result[i - 1]); + const current = runAuthor(run); + run.repeatsAuthor = Boolean( + previous && current && previous.did === current.did, + ); + } + return result; +} diff --git a/desktop/src/lib/updateChecker.svelte.ts b/desktop/src/lib/updateChecker.svelte.ts new file mode 100644 index 000000000..fe60fc3e7 --- /dev/null +++ b/desktop/src/lib/updateChecker.svelte.ts @@ -0,0 +1,131 @@ +import * as semver from "semver"; +import * as z from "zod"; + +import { invoke } from "./invoke"; +import useLocalStorage from "./useLocalStorage.svelte"; + +interface LatestVersionInfo { + version: string; +} + +const fetchLatestVersion = async (): Promise => { + const response = await fetch( + "https://files.radicle.dev/releases/radicle-desktop/latest/latest.json", + { cache: "no-store" }, + ); + const body: LatestVersionInfo = await response.json(); + return body; +}; + +// Check for new version every hour. +const VERSION_CHECK_INTERVAL = 3600 * 1000; + +const isEnabledStore = useLocalStorage( + "updateChecker.isEnabled", + z.boolean().nullable(), + null, + !window.localStorage, +); + +class UpdateChecker { + private checkInterval: number | undefined = $state(); + private latestVersionInfo: LatestVersionInfo | undefined = $state(); + private sanitizedCurrentVersion: string | undefined = $state(); + public currentVersion: string | undefined = $state(); + + public isEnabled = $derived.by(() => { + if (isEnabledStore.value === null) { + return false; + } else { + return isEnabledStore.value; + } + }); + + // True only when a version check has succeeded and found no newer + // version. Stays false when the checker is disabled or the check failed, + // so the UI doesn't claim "up to date" without evidence. + public upToDate = $derived.by(() => { + if ( + this.isEnabled && + this.latestVersionInfo && + this.sanitizedCurrentVersion + ) { + return !semver.gt( + this.latestVersionInfo.version, + this.sanitizedCurrentVersion, + ); + } else { + return false; + } + }); + + // A state that holds the `LatestVersionInfo` if this feature has + // been enabled and if there is a newer version available. + public newVersion = $derived.by(() => { + if (this.latestVersionInfo && this.sanitizedCurrentVersion) { + if ( + semver.gt(this.latestVersionInfo.version, this.sanitizedCurrentVersion) + ) { + return this.latestVersionInfo.version; + } else { + return undefined; + } + } else { + return undefined; + } + }); + + public static init(): UpdateChecker { + const updateChecker = new UpdateChecker(); + + void invoke("version").then(currentVersion => { + updateChecker.currentVersion = currentVersion.toString(); + const version = semver.coerce(currentVersion); + if (version) { + updateChecker.sanitizedCurrentVersion = version.toString(); + } + if (isEnabledStore.value) { + updateChecker.enable(); + } + }); + + return updateChecker; + } + + // Disable background update checking. + public disable = (): void => { + isEnabledStore.value = false; + + if (this.checkInterval) { + clearInterval(this.checkInterval); + this.checkInterval = undefined; + } + }; + + private async checkNewVersion(): Promise { + try { + this.latestVersionInfo = await fetchLatestVersion(); + } catch { + return false; + } + + return ( + this.sanitizedCurrentVersion !== undefined && + semver.gt(this.latestVersionInfo.version, this.sanitizedCurrentVersion) + ); + } + + // Enable background update checking. + public enable = (): void => { + isEnabledStore.value = true; + + void this.checkNewVersion(); + if (!this.checkInterval) { + this.checkInterval = window.setInterval(() => { + void this.checkNewVersion(); + }, VERSION_CHECK_INTERVAL); + } + }; +} + +export const updateChecker = UpdateChecker.init(); diff --git a/desktop/src/lib/useLocalStorage.svelte.ts b/desktop/src/lib/useLocalStorage.svelte.ts new file mode 100644 index 000000000..2192130fe --- /dev/null +++ b/desktop/src/lib/useLocalStorage.svelte.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; + +export default function useLocalStorage( + key: string, + schema: T, + initialValue: z.infer, + disableLocalStorage = false, +) { + const stored = !disableLocalStorage ? localStorage.getItem(key) : null; + + const parseFromJson = (content: string) => + z + .string() + .transform((_, ctx) => { + try { + return JSON.parse(content); + } catch { + ctx.addIssue({ + code: "custom", + message: "invalid json", + }); + return z.NEVER; + } + }) + .pipe(schema) + .safeParse(content); + + let value = $state>(initialValue); + + if (stored) { + try { + const parsed = parseFromJson(stored); + if (parsed.success) { + value = parsed.data; + } else { + console.error("Invalid stored data:", parsed.error); + } + } catch (error) { + console.error("Error parsing stored data:", error); + } + } + + function set(v: z.infer) { + value = v; + if (!disableLocalStorage) localStorage.setItem(key, JSON.stringify(value)); + } + + return { + get value() { + return value; + }, + set value(v: z.infer) { + set(v); + }, + update(fn: (v: z.infer) => z.infer) { + set(fn(value)); + }, + clear() { + value = initialValue; + if (!disableLocalStorage) localStorage.removeItem(key); + }, + }; +} diff --git a/desktop/src/lib/utils.ts b/desktop/src/lib/utils.ts new file mode 100644 index 000000000..417b98fb3 --- /dev/null +++ b/desktop/src/lib/utils.ts @@ -0,0 +1,516 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Issue } from "@bindings/cob/issue/Issue"; +import type { Patch } from "@bindings/cob/patch/Patch"; +import type { Review } from "@bindings/cob/patch/Review"; +import type { Config } from "@bindings/config/Config"; +import type { Commit } from "@bindings/repo/Commit"; +import type { RepoInfo } from "@bindings/repo/RepoInfo"; +import type { ComponentProps } from "svelte"; + +import escape from "lodash/escape.js"; +import md5 from "md5"; +import twemojiModule from "twemoji"; + +import type { RadReference } from "@app/lib/radUri"; +import { explorerUrl, isNodeId, isRepoId } from "@app/lib/radUri"; + +import NodeId from "@app/components/NodeId.svelte"; + +export const unreachable = (value: never): never => { + throw new Error(`Unreachable code: ${value}`); +}; + +export const REFS_HEADS = "refs/heads/"; + +// The single place to change once the API stops carrying the default branch +// in the project payload, which Heartwood no longer requires. +export function defaultBranch(repo: RepoInfo): string | undefined { + return repo.payloads["xyz.radicle.project"]?.data.defaultBranch; +} + +export function unqualifyBranch(refname: string): string { + return refname.startsWith(REFS_HEADS) + ? refname.slice(REFS_HEADS.length) + : refname; +} + +export function formatRepositoryId(id: string): string { + const parsedId = parseRepositoryId(id); + + if (parsedId) { + return `${parsedId.prefix}${truncateId(parsedId.pubkey)}`; + } + + return id; +} + +export function parseRepositoryId( + rid: string, +): { prefix: string; pubkey: string } | undefined { + const pubkey = rid.replace(/^rad:/, ""); + if (!isRepoId(pubkey)) return undefined; + + return { prefix: "rad:", pubkey }; +} + +export function truncateId(pubkey: string): string { + return `${pubkey.substring(0, 6)}…${pubkey.slice(-6)}`; +} + +export function truncateDid(did: string): string { + return `did:key:${truncateId(publicKeyFromDid(did))}`; +} + +export function didFromPublicKey(publicKey: string) { + return `did:key:${publicKey}`; +} + +export function publicKeyFromDid(did: string) { + return did.replace("did:key:", ""); +} + +export function formatOid(id: string): string { + return id.substring(0, 7); +} + +export const formatTimestamp = ( + timestamp: number, + current = new Date().getTime(), +): string => { + const SECOND = 1000; + const MINUTE = 60 * SECOND; + const HOUR = 60 * MINUTE; + const DAY = 24 * HOUR; + const MONTH = (365 * DAY) / 12; + const YEAR = 365 * DAY; + + const elapsed = current - timestamp; + if (elapsed < MINUTE) return "now"; + if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m`; + if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h`; + if (elapsed < MONTH) return `${Math.floor(elapsed / DAY)}d`; + if (elapsed < YEAR) return `${Math.floor(elapsed / MONTH)}mo`; + return `${Math.floor(elapsed / YEAR)}y`; +}; + +// Svelte action that swallows mousedown so the activating element does not +// steal focus from whatever is currently focused. Useful for click targets +// that should not collapse a focused input. +// +// Usage:
...
+export function preserveFocus(node: HTMLElement) { + const handler = (e: MouseEvent) => e.preventDefault(); + node.addEventListener("mousedown", handler); + return { + destroy() { + node.removeEventListener("mousedown", handler); + }, + }; +} + +// Svelte action for replacing emojis within an element with Twemoji SVGs. +// This action is non-reactive; it only runs when the element is mounted. +// +// Usage: 👍 +export function twemoji( + node: HTMLElement, + { exclude }: { exclude: string[] } = { exclude: [] }, +) { + twemojiModule.parse(node, { + callback: (icon, options) => { + const { base, size, ext } = options as Record; + if (!exclude.includes(icon)) { + return `${base}${size}/${icon}${ext}`; + } + return false; + }, + base: "/", + folder: "twemoji", + ext: ".svg", + className: `txt-emoji`, + }); +} + +// Converts a single emoji character to an tag using Twemoji SVG assets. +// This function is useful in reactive contexts where Svelte actions won't +// re-run automatically. +// +// Usage: {@html emojiToTwemoji("👍")} +export function emojiToTwemoji(emoji: string, exclude?: string[]) { + const filename = emoji.codePointAt(0)?.toString(16); + if (!filename || exclude?.includes(filename)) { + return ""; + } + return `${escape(emoji)}`; +} + +export function scrollIntoView(id: string, options?: ScrollIntoViewOptions) { + const lineElement = document.getElementById(id); + if (lineElement) { + lineElement.scrollIntoView(options); + } +} + +export const issueStatusColor: Record = { + open: "var(--color-text-open)", + closed: "var(--color-text-closed)", +}; + +export const issueStatusBackgroundColor: Record< + Issue["state"]["status"], + string +> = { + open: "var(--color-surface-open)", + closed: "var(--color-surface-closed)", +}; + +export const issueStatusIcon = { + open: "issue", + closed: "issue-closed", +} as const satisfies Record; + +export const issueStatusLabel: Record = { + open: "Open Issues", + closed: "Closed Issues", +}; + +export const patchStatusColor: Record = { + draft: "var(--color-text-draft)", + open: "var(--color-text-open)", + archived: "var(--color-text-archived)", + merged: "var(--color-text-merged)", +}; + +export const patchStatusBackgroundColor: Record< + Patch["state"]["status"], + string +> = { + draft: "var(--color-surface-draft)", + open: "var(--color-surface-open)", + archived: "var(--color-surface-archived)", + merged: "var(--color-surface-merged)", +}; + +export const patchStatusLabel: Record = { + draft: "Draft Patches", + open: "Open Patches", + archived: "Archived Patches", + merged: "Merged Patches", +}; + +export function authorForNodeId(author: Author): ComponentProps { + return { publicKey: publicKeyFromDid(author.did), alias: author.alias }; +} + +export function absoluteTimestamp(timestamp: number) { + return new Date(Number(timestamp)).toLocaleString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +export function formatEditedCaption(author: Author, timestamp: number) { + return `${author.alias ? author.alias : truncateDid(author.did)} edited ${absoluteTimestamp(timestamp)}`; +} + +export function formatResolvedCaption(author: Author, timestamp: number) { + return `${author.alias ? author.alias : truncateDid(author.did)} resolved this ${absoluteTimestamp(timestamp)}`; +} + +export function pluralize(singular: string, count: number): string { + if (count === 1) return singular; + if (/(?:ch|sh|s|x|z)$/i.test(singular)) { + return `${singular}es`; + } + return `${singular}s`; +} + +export function isMac() { + if ( + (navigator.platform && navigator.platform.includes("Mac")) || + navigator.userAgent.includes("OS X") + ) { + return true; + } else { + return false; + } +} + +export function modifierKey() { + return isMac() ? "⌘" : "ctrl"; +} + +export function parseNodeId( + nid: string, +): { prefix: string; pubkey: string } | undefined { + const pubkey = nid.replace(/^did:key:/, ""); + if (!isNodeId(pubkey)) return undefined; + + return { prefix: "did:key:", pubkey }; +} + +// Get the gravatar URL of an email. +export function gravatarURL(email: string): string { + const address = email.trim().toLowerCase(); + const hash = md5(address); + + return `https://www.gravatar.com/avatar/${hash}`; +} + +export function verdictIcon(verdict: Review["verdict"]) { + if (verdict === "accept") { + return "thumbs-up"; + } else if (verdict === "reject") { + return "stop"; + } else { + return "comment"; + } +} + +// `revisions_by_patch` returns revisions in the patch's timeline order — the +// causal order the COB recorded them in — and that order is what "revision N of +// M" means throughout the UI. Do not re-sort by `timestamp` to recover it: a +// revision's timestamp is supplied by whoever authored it, so a peer with a +// skewed clock would silently renumber everyone's revisions. +export function revisionPosition( + revisions: { id: string }[], + revisionId: string, +): number | undefined { + const index = revisions.findIndex(r => r.id === revisionId); + return index === -1 ? undefined : index + 1; +} + +// A review without a verdict is a comment-only review — what `rad patch review` +// produces with neither -a nor -r. The protocol has no "needs changes" or +// "request changes" state, so neither label may imply one: doing so would put +// words into the mouth of every reviewer who ever left a plain comment. +export function verdictAction(verdict: Review["verdict"]): string { + if (verdict === "accept") { + return "Accept"; + } else if (verdict === "reject") { + return "Reject"; + } else { + return "Comment"; + } +} + +// The protocol rejects a verdict-less review with no summary (EmptyReview), +// so a comment-only review requires summary text; accept/reject don't. +export function isPublishableReview( + verdict: Review["verdict"], + summary: string, +): boolean { + return verdict !== undefined || summary.trim() !== ""; +} + +export function verdictBadge(verdict: Review["verdict"]): { + label: string; + variant: "accept" | "reject" | "comment"; +} { + if (verdict === "accept") { + return { label: "Accepted", variant: "accept" }; + } else if (verdict === "reject") { + return { label: "Rejected", variant: "reject" }; + } else { + return { label: "Reviewed", variant: "comment" }; + } +} + +export function safeHttpUrl(url: string): string | undefined { + try { + const parsed = new URL(url); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + return parsed.toString(); + } + } catch { + // fall through + } + return undefined; +} + +// Seed to fall back to when `config.preferredSeeds` is empty. +const DEFAULT_SEED = "rosa.radicle.network"; + +// The host of the first entry in `config.preferredSeeds`, if any, parsed from +// its "@:" address form. Used to pick a seed that's actually +// known to replicate the user's repos, instead of a hardcoded default. +function preferredSeedHost(config: Config): string | undefined { + const address = config.preferredSeeds[0]; + if (!address) { + return undefined; + } + return address.split("@")[1]?.split(":")[0] ?? address; +} + +// The hostname of the configured public explorer, e.g. "radicle.network". +// Used to label links with the site they actually open. +export function explorerHost(config: Config): string { + try { + return new URL(config.publicExplorer).host; + } catch { + return config.publicExplorer; + } +} + +export function explorerLink( + reference: RadReference, + config: Config, +): string | undefined { + return explorerUrl(reference, explorerBase(config), explorerSeed(config)); +} + +function explorerBase(config: Config): string { + const nodes = config.publicExplorer.indexOf("/nodes/$host"); + if (nodes !== -1) return config.publicExplorer.slice(0, nodes); + try { + return new URL(config.publicExplorer).origin; + } catch { + return config.publicExplorer; + } +} + +function explorerSeed(config: Config): string { + return preferredSeedHost(config) ?? DEFAULT_SEED; +} + +export interface GitIdentity { + name: string; + email: string; +} + +// Two git identities are the same person when they share an address, compared +// case-insensitively the way `gravatarURL` normalises it. An identity may carry +// no address at all, so those fall back to the name rather than collapsing into +// each other. +export function identityKey(who: GitIdentity): string { + const email = who.email.trim().toLowerCase(); + return email ? `email:${email}` : `name:${who.name.trim().toLowerCase()}`; +} + +// A commit carries an author date and a commit date, which come apart on a +// rebase or an amend. Views show the commit date, so the tooltip only names the +// two apart when there is something to tell apart. +export function commitTimes(commit: Commit): string { + const committed = absoluteTimestamp(commit.committer.time * 1000); + if (commit.author.time === commit.committer.time) { + return committed; + } + + return [ + `Authored — ${absoluteTimestamp(commit.author.time * 1000)}`, + `Committed — ${committed}`, + ].join("\n"); +} + +// Extract `Co-authored-by:` trailers from a commit message. +// +// Git only treats the final paragraph of a message as trailers, so a line +// appearing anywhere earlier — quoted in prose, or in a nested patch — is not +// one and is ignored here too. Matching is case-insensitive because git's own +// trailer handling is. The name may be empty, since a git identity only +// requires an address. +export function coAuthors(message: string): GitIdentity[] { + const paragraphs = message.trimEnd().split(/(?:\r?\n){2,}/); + const trailers = paragraphs[paragraphs.length - 1] ?? ""; + const authors: GitIdentity[] = []; + + for (const line of trailers.split("\n")) { + const match = /^co-authored-by:\s*(.*?)\s*<([^<>\s]+)>$/i.exec(line.trim()); + if (match) { + authors.push({ name: match[1], email: match[2] }); + } + } + + return authors; +} + +// The identity as it appears in a commit trailer, so that a copied result can +// be pasted straight into `Co-authored-by:` and the like. +export function formatGitIdentity(who: GitIdentity): string { + return who.name ? `${who.name} <${who.email}>` : who.email; +} + +// Co-authors are not part of the commit's git identity fields; they are a +// convention carried in the message trailers. The same person can be +// trailered more than once, and squashing gathers several messages' trailers +// into one, so only the first mention of each is kept. +export function creditedCoAuthors(commit: { + author: GitIdentity; + committer: GitIdentity; + message: string; +}): GitIdentity[] { + const credited = new Set([ + identityKey(commit.author), + identityKey(commit.committer), + ]); + return coAuthors(commit.message).filter(who => { + const key = identityKey(who); + if (credited.has(key)) return false; + credited.add(key); + return true; + }); +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ["KiB", "MiB", "GiB", "TiB"]; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`; +} + +export function formatUptime(seconds: number): string { + if (seconds < 60) { + return `${Math.floor(seconds)}s`; + } + if (seconds < 3600) { + return `${Math.floor(seconds / 60)}m`; + } + if (seconds < 86400) { + return `${Math.floor(seconds / 3600)}h`; + } + return `${Math.floor(seconds / 86400)}d`; +} + +// CIDs in a release share a long prefix, so the head grows until each label is +// distinct. +export function shortenCids( + cids: string[], + minHead = 7, + tailLength = 6, + maxHead = 16, +): Map { + const display = new Map(); + const unique = [...new Set(cids)]; + if (unique.length === 0) { + return display; + } + + let head = minHead; + while ( + head < maxHead && + new Set(unique.map(cid => cid.slice(0, head))).size < unique.length + ) { + head += 1; + } + + for (const cid of unique) { + display.set( + cid, + head + tailLength >= cid.length + ? cid + : `${cid.slice(0, head)}…${cid.slice(-tailLength)}`, + ); + } + + return display; +} diff --git a/desktop/src/lib/window.ts b/desktop/src/lib/window.ts new file mode 100644 index 000000000..c0d80bb70 --- /dev/null +++ b/desktop/src/lib/window.ts @@ -0,0 +1,68 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; + +// The strip along the top edge of the window that drags it. Deliberately a +// fixed pixel height rather than rem: the root font size is user-configurable, +// and scaling the strip with it would swallow clicks at the top of every view. +export const DRAG_REGION_HEIGHT = 32; + +const INTERACTIVE_TAGS = new Set([ + "a", + "button", + "input", + "select", + "textarea", +]); + +// A press in the top strip on something that isn't itself interactive. Anything +// carrying `data-tauri-drag-region` is excluded because Tauri's own script +// already owns it -- that script drags on mousedown and maximizes on double +// click, and doing either here as well would fight it. +export function isDraggableArea(e: MouseEvent): boolean { + if (e.clientY > DRAG_REGION_HEIGHT) return false; + let el = e.target as HTMLElement | null; + while (el && el !== document.body) { + if (INTERACTIVE_TAGS.has(el.tagName.toLowerCase())) return false; + if (el.getAttribute("role") === "button") return false; + if (el.classList.contains("txt-selectable")) return false; + if (el.hasAttribute("data-tauri-drag-region")) return false; + el = el.parentElement; + } + return true; +} + +// A window drag suppresses the mouse events that follow it, so the second press +// of a double click can arrive with `detail` back at 1 and `dblclick` never +// arrives at all. Pair the presses by time and position instead, the way the +// sidebar's resize edge does. +const DOUBLE_PRESS_MS = 500; +const DOUBLE_PRESS_SLOP_PX = 6; +let lastPressAt = 0; +let lastPressX = 0; +let lastPressY = 0; + +function isDoublePress(e: MouseEvent): boolean { + const paired = + e.timeStamp - lastPressAt < DOUBLE_PRESS_MS && + Math.abs(e.clientX - lastPressX) <= DOUBLE_PRESS_SLOP_PX && + Math.abs(e.clientY - lastPressY) <= DOUBLE_PRESS_SLOP_PX; + // Cleared so a third press starts a new pair instead of toggling again. + lastPressAt = paired ? 0 : e.timeStamp; + lastPressX = e.clientX; + lastPressY = e.clientY; + return paired; +} + +// Drag the window by its top strip, and maximize it when that strip is double +// clicked, matching what Tauri's own drag regions do elsewhere in the app. +export function dragWindow(e: MouseEvent): void { + // Primary button only; a right-click here would otherwise start a drag. + if (!window.__TAURI_INTERNALS__ || e.button !== 0 || !isDraggableArea(e)) { + return; + } + + if (isDoublePress(e)) { + void getCurrentWindow().toggleMaximize(); + } else { + void getCurrentWindow().startDragging(); + } +} diff --git a/desktop/src/main.ts b/desktop/src/main.ts new file mode 100644 index 000000000..f3ae2816f --- /dev/null +++ b/desktop/src/main.ts @@ -0,0 +1,13 @@ +import "overlayscrollbars/overlayscrollbars.css"; +import { mount } from "svelte"; + +import App from "./App.svelte"; +import { disableDropNavigation } from "./lib/disableDropNavigation"; +import { disableNativeContextMenu } from "./lib/disableNativeContextMenu"; + +const app = mount(App, { target: document.body }); + +disableNativeContextMenu(); +disableDropNavigation(); + +export default app; diff --git a/desktop/src/modals/ConfirmDeleteRelease.svelte b/desktop/src/modals/ConfirmDeleteRelease.svelte new file mode 100644 index 000000000..c0f5445a6 --- /dev/null +++ b/desktop/src/modals/ConfirmDeleteRelease.svelte @@ -0,0 +1,57 @@ + + + + + "Could not delete the release."} + {confirm}> + {#snippet body()} +
+ + {title} +
+ + This removes the release and its artifact list, and your node stops + seeding its artifacts. Peers that already fetched the release keep their + copy until they sync. + + {#if shared} + + Others registered artifacts in this release, so it stays visible with + their changes. + + {/if} + {/snippet} +
diff --git a/desktop/src/modals/ConfirmModal.svelte b/desktop/src/modals/ConfirmModal.svelte new file mode 100644 index 000000000..0efe16127 --- /dev/null +++ b/desktop/src/modals/ConfirmModal.svelte @@ -0,0 +1,138 @@ + + + + + diff --git a/desktop/src/modals/ConfirmRedact.svelte b/desktop/src/modals/ConfirmRedact.svelte new file mode 100644 index 000000000..f46bbb26b --- /dev/null +++ b/desktop/src/modals/ConfirmRedact.svelte @@ -0,0 +1,100 @@ + + + + + confirm(reason.trim())}> + {#snippet body({ working, run })} + {name} + + + This marks the artifact as one that should no longer be used, and hides it + from the release by default. + + + + +
+ +
+ + Nothing is deleted. The CID stays in the release history, and anyone + who already downloaded it keeps their copy. + + {#if seeding} + + Your node stops seeding it and removes the location it added. + + {/if} + + This cannot be undone, and you will not be able to attest to this + artifact afterwards. + +
+
+ {/snippet} +
diff --git a/desktop/src/modals/ConfirmRegisterArtifacts.svelte b/desktop/src/modals/ConfirmRegisterArtifacts.svelte new file mode 100644 index 000000000..d08278f00 --- /dev/null +++ b/desktop/src/modals/ConfirmRegisterArtifacts.svelte @@ -0,0 +1,211 @@ + + + + + 0 ? "Register" : "Update"} + busyLabel={newCount > 0 ? "Registering…" : "Updating…"} + failure="Unable to register these artifacts." + canConfirm={acting.length > 0} + confirm={() => confirm(staged, includeRedacted)}> + {#snippet body({ working })} +
+ {#each plan.items as p} +
+ + + + {p.item.name} + {planDetail(p, includeRedacted)} +
+ {/each} +
+ + {#if acting.length > 0 && (acting.length > 1 || acting.some(p => p.item.digest.directory))} +
+ {totalFiles} + {totalFiles === 1 ? "file" : "files"} in total, {formatBytes( + totalBytes, + )} +
+ {/if} + + {#if anyExisting || anyDuplicate} +
+ An artifact is identified by the hash of its contents, not by its name, + so anything already in this release stays the single entry it is. + {#if anyDuplicate} + Picks with the same contents are registered once. + {/if} + {#if acting.length === 0} + Nothing will be registered. + {:else} + {summary}. + {/if} + {#if anyKeepsName} + A name marked “stays” is kept: only its author can rename it. + {/if} +
+ {/if} + + {#if redacted.length > 0} +
+ +
+ {#each redacted as item (item.digest.cid)} + + “{item.existing?.name}” was redacted{item.existing?.redaction + ? `: ${item.existing.redaction}` + : "."} + + {/each} + + Register {redacted.length === 1 ? "it" : "them"} again + +
+
+ {/if} + + {#if acting.length > 0} +
+ +
+ {#if registering.length > 0} + + {#if registersFolder} + A folder is registered whole, as one artifact. + {:else} + These names and CIDs are signed into the release and reach + everyone who has this repository. + {/if} + + {/if} + {#if willSeed} + + Your artifact node is running, so it seeds the artifact and adds + your node's location. + + {/if} +
+
+ {/if} + {/snippet} +
diff --git a/desktop/src/modals/ConfirmUnseed.svelte b/desktop/src/modals/ConfirmUnseed.svelte new file mode 100644 index 000000000..77405f192 --- /dev/null +++ b/desktop/src/modals/ConfirmUnseed.svelte @@ -0,0 +1,107 @@ + + + + + confirm(clean)}> + {#snippet body({ working })} +
+ + {name} +
+ {rid} + +
+ +
+ + Your node stops replicating and announcing this repository{repoListScope.value === + "seeded" + ? ", and it leaves the sidebar." + : "; the sidebar dims it."} + {#if !clean} + The files stay on disk. + {/if} + + + {#if clean} + + The files go too, except the refs your node signed and the + delegates'. Other nodes have already fetched yours, so replacing + them later would fork your own history, and the repository can't be + verified without the delegates'. If you have never published here + there is nothing signed, and it is deleted outright. + + {/if} + + Delete the files from local storage + +
+
+ {/snippet} +
diff --git a/desktop/src/modals/CreateIssue.svelte b/desktop/src/modals/CreateIssue.svelte new file mode 100644 index 000000000..0393acd2e --- /dev/null +++ b/desktop/src/modals/CreateIssue.svelte @@ -0,0 +1,221 @@ + + + + + diff --git a/desktop/src/modals/CreateRelease.svelte b/desktop/src/modals/CreateRelease.svelte new file mode 100644 index 000000000..44e3c1d7b --- /dev/null +++ b/desktop/src/modals/CreateRelease.svelte @@ -0,0 +1,683 @@ + + + + + diff --git a/desktop/src/modals/Guide.svelte b/desktop/src/modals/Guide.svelte new file mode 100644 index 000000000..783c9450c --- /dev/null +++ b/desktop/src/modals/Guide.svelte @@ -0,0 +1,155 @@ + + + + + +
+
+ +
+
+
+
Getting started
+ +
+ Hello , your identity has been created and stored + on your machine. +
+
+ Your public key is + {truncateDid(config.publicKey)} + + you can share this with anyone to find you on the network. +
+
+ We release a new version of the app every two weeks. To stay up to + date, go to + + and enable 'Notify on new versions' to receive notifications about new releases. +
+ + {#if !radicleInstalled() && !$nodeRunning} +
+
+ Radicle CLI is not installed +
+
+ To interact with repos on the Radicle network, you'll need to + install Radicle node along with its accompanying CLI tools. The + node runs in the background, enabling seamless pushing and pulling + of changes, while the CLI tools let you manage the node and + provide interoperability between Git and Radicle. +
+
+ To install Radicle node and CLI tooling, run this in your shell: +
+ +
+ {/if} + +
+ + To get started, + + +
+
+
+
+
diff --git a/desktop/src/modals/KeyboardShortcuts.svelte b/desktop/src/modals/KeyboardShortcuts.svelte new file mode 100644 index 000000000..debee34a2 --- /dev/null +++ b/desktop/src/modals/KeyboardShortcuts.svelte @@ -0,0 +1,134 @@ + + + + +{#snippet shortcutRow(row: (typeof rows)[number])} +
+ {#each row.keys as key, index (key)} + {#if index > 0} + + + {/if} + {key} + {/each} +
+ {row.description} +{/snippet} + + diff --git a/desktop/src/modals/NotOnThisNode.svelte b/desktop/src/modals/NotOnThisNode.svelte new file mode 100644 index 000000000..744488ffb --- /dev/null +++ b/desktop/src/modals/NotOnThisNode.svelte @@ -0,0 +1,135 @@ + + + + + diff --git a/desktop/src/modals/RawIdentityDocument.svelte b/desktop/src/modals/RawIdentityDocument.svelte new file mode 100644 index 000000000..7f6d7fe07 --- /dev/null +++ b/desktop/src/modals/RawIdentityDocument.svelte @@ -0,0 +1,115 @@ + + + + + diff --git a/desktop/src/modals/Settings.svelte b/desktop/src/modals/Settings.svelte new file mode 100644 index 000000000..98a3e2e78 --- /dev/null +++ b/desktop/src/modals/Settings.svelte @@ -0,0 +1,294 @@ + + + + + diff --git a/desktop/src/modals/UnopenableLink.svelte b/desktop/src/modals/UnopenableLink.svelte new file mode 100644 index 000000000..f92190a6c --- /dev/null +++ b/desktop/src/modals/UnopenableLink.svelte @@ -0,0 +1,77 @@ + + + + + diff --git a/desktop/src/views/Inbox.svelte b/desktop/src/views/Inbox.svelte new file mode 100644 index 000000000..5ff8a38d2 --- /dev/null +++ b/desktop/src/views/Inbox.svelte @@ -0,0 +1,128 @@ + + + + + + {#if notificationCount.value === 0} +
+
+ You're all caught up +
+
+ {:else} + +
+
+ +
+
+
+ {/if} +
diff --git a/desktop/src/views/auth/Auth.svelte b/desktop/src/views/auth/Auth.svelte new file mode 100644 index 000000000..7b6d8d3cc --- /dev/null +++ b/desktop/src/views/auth/Auth.svelte @@ -0,0 +1,143 @@ + + + + + +
+
Unlock keys
+
+ Your passphrase is needed to unlock your keys. +
+
+ +
+
+
+
Alias
+
{profile.alias}
+
+
+
DID
+
{truncateDid(profile.did)}
+
+
+ +
+
Passphrase
+ { + error = undefined; + }} + placeholder="Enter passphrase to unlock your keys" + type="password" + bind:value={passphrase} /> + {#if error?.code === "PassphraseError.InvalidPassphrase"} +
+ + Not able to decrypt keys with provided passphrase. +
+ {/if} +
+ + +
+
diff --git a/desktop/src/views/auth/CreateIdentity.svelte b/desktop/src/views/auth/CreateIdentity.svelte new file mode 100644 index 000000000..ccd7526cf --- /dev/null +++ b/desktop/src/views/auth/CreateIdentity.svelte @@ -0,0 +1,211 @@ + + + + + +
+
Create identity
+
+ Set up your Radicle identity to get started. +
+
+ +
+
+
Alias
+ { + errors.alias = []; + if (alias.length > 0) { + validateInput("alias"); + } + }} + placeholder="Enter desired alias" + type="text" + bind:value={alias} /> + {#if errors.alias.some(e => e.code.startsWith("AliasError"))} + {#each errors.alias as error} +
+ + {#if error.code === "AliasError.EmptyAlias"} + Alias cannot be empty. + {:else if error.code === "AliasError.TooLongAlias"} + Alias is too long, max 32 characters. + {:else if error.code === "AliasError.InvalidAlias"} + Alias cannot contain whitespace. + {/if} +
+ {/each} + {:else} +
+ Max 32 characters, no whitespace. +
+ {/if} +
+ +
+
Passphrase
+ { + errors.passphrase = []; + notMatchingPassphrases = false; + if (passphrase.length > 0) { + validateInput("passphrase"); + } + }} + placeholder="Enter passphrase to protect your keys" + type="password" + bind:value={passphrase} /> + {#if errors.passphrase.some(e => e.code.startsWith("PassphraseError"))} + {#each errors.passphrase as error} +
+ + {#if error.code === "PassphraseError.InvalidPassphrase"} + Passphrase cannot be empty. + {:else} + {error.message} + {/if} +
+ {/each} + {/if} +
+ +
+ { + errors.passphrase = []; + notMatchingPassphrases = false; + validatePassphraseRepeat(); + }} + placeholder="Repeat passphrase" + type="password" + bind:value={passphraseRepeat} /> + {#if notMatchingPassphrases} +
+ Passphrases don't match. +
+ {/if} +
+ + +
+
diff --git a/desktop/src/views/auth/Layout.svelte b/desktop/src/views/auth/Layout.svelte new file mode 100644 index 000000000..be6675342 --- /dev/null +++ b/desktop/src/views/auth/Layout.svelte @@ -0,0 +1,90 @@ + + + + +
+
+ +
+
+
+ +
+
+
+ + {@render children()} +
+
+
+
diff --git a/desktop/src/views/repo/Identity.svelte b/desktop/src/views/repo/Identity.svelte new file mode 100644 index 000000000..827dcb7de --- /dev/null +++ b/desktop/src/views/repo/Identity.svelte @@ -0,0 +1,463 @@ + + + + + +
+
+ +
+ + +
+

Identity document

+
+ {#if current} + Current revision + + + created {formatTimestamp(current.timestamp)} + + {:else} + No accepted revision matches refs/rad/id + {/if} +
+ + + +
+

History

+ + {identity.revisions.length} + {pluralize("revision", identity.revisions.length)} + +
+ +
1}> + {#each identity.revisions as rev (rev.id)} + {@const open = expandedId === rev.id} +
+ + {#if open} + {@const voters = + rev.accepted.length + + rev.rejected.length + + rev.pending.length} + {@const caption = stateCaption(rev.state)} +
+ {#if caption} +
{caption}
+ {/if} + + {#if rev.description} +
+
Description
+
+ +
+
+ {/if} + +
+
Changes
+ +
+ +
+
+
Signatures
+ {#if rev.majority !== undefined} + + {rev.accepted.length} of {voters} + {pluralize("delegate", voters)} signed, {rev.majority} + needed + + {/if} +
+
+ {#if rev.parent} + The delegates of the parent revision, whose votes decide + this one. + {:else} + The first revision is accepted when it is created, with + its author's signature. + {/if} +
+
+ {#each rev.accepted as delegate (delegate.did)} +
+ + + + + signed +
+ {/each} + {#each rev.rejected as delegate (delegate.did)} +
+ + + + + rejected +
+ {/each} + {#each rev.pending as delegate (delegate.did)} +
+ + + + + no verdict +
+ {/each} +
+
+ +
+ + + + {formatTimestamp(rev.timestamp)} + + +
+
+ {/if} +
+ {/each} +
+
+
+
+
diff --git a/desktop/src/views/repo/Issue.svelte b/desktop/src/views/repo/Issue.svelte new file mode 100644 index 000000000..84def4c40 --- /dev/null +++ b/desktop/src/views/repo/Issue.svelte @@ -0,0 +1,388 @@ + + + + + +
+ + +
+ {#if isOwnIssue} + + {/if} + + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+ {#if issue.body} + {@const body = issue.body} + editComment(body.id, text, embeds)} + reactOnComment={(authors, reaction) => + reactOnComment(body.id, authors, reaction)} /> + {/if} + + {#snippet renderActivity( + op: FlattenedIssueOperation, + { hideAuthor }: { hideAuthor: boolean }, + )} + + {/snippet} + + op.author} /> +
+
+
+
+
diff --git a/desktop/src/views/repo/Issues.svelte b/desktop/src/views/repo/Issues.svelte new file mode 100644 index 000000000..81c42aef2 --- /dev/null +++ b/desktop/src/views/repo/Issues.svelte @@ -0,0 +1,304 @@ + + + + + +
+ + Issues + +
+ 0} + placeholder={`Search issues ${modifierKey()} + f`} + icon={loading ? "clock" : "search"} + onFocus={async () => { + try { + loading = true; + await list.loadMore(true); + } catch (e) { + console.error("Loading all issues failed: ", e); + } finally { + loading = false; + } + }} + onSubmit={async () => { + if (searchResults.length === 1) { + await router.push({ + resource: "repo.issue", + rid: repo.rid, + issue: searchResults[0].id, + status, + }); + } + }} + bind:show={showSearch} + bind:value={searchInput} /> + +
+ + + {#if searchResults.length === 0} + {#if issueCountMismatch(status)} + + {/if} +
+
+ {#if list.items.length > 0} + No matching issues + {:else} + No {status === "all" ? "" : status} issues + {/if} +
+
+ {:else} + +
+ {#if issueCountMismatch(status)} + + {/if} +
+ list.loadMore()} + startMargin={chromeHeight} + estimatedItemSize={80} + getKey={issue => issue.id} + initialCache={list.initialCache} + initialScrollOffset={list.initialScrollOffset} + onRestored={list.consumeRestoredScroll} + onState={list.persistScroll}> + {#snippet row(issue)} +
+ +
+ {/snippet} +
+
+ {/if} +
+ diff --git a/desktop/src/views/repo/Layout.svelte b/desktop/src/views/repo/Layout.svelte new file mode 100644 index 000000000..34e43cb35 --- /dev/null +++ b/desktop/src/views/repo/Layout.svelte @@ -0,0 +1,24 @@ + + +{#if selfScroll} +
+ {@render children()} +
+{:else} + + {@render children()} + +{/if} diff --git a/desktop/src/views/repo/Patch.svelte b/desktop/src/views/repo/Patch.svelte new file mode 100644 index 000000000..e536bc433 --- /dev/null +++ b/desktop/src/views/repo/Patch.svelte @@ -0,0 +1,1530 @@ + + + + + +
+ + +
+ + {#if isOwnPatch && !currentReview} + + {/if} + + + + {#if !currentReview} + {#if ownDraftReview} + {#if patchView !== "changes"} + + {/if} + {:else} + + {/if} + {/if} +
+
+ + + {#snippet patchHeader()} +
+ { + void saveState(newState); + }} + disabled={!canEditPatch} /> + +
+
+ { + selectedRevisionId = latestRevision.id; + setView("changes"); + }} /> +
+ {/snippet} + + {#snippet tabs()} +
+
+ + +
+ {#if patchView === "activity"} + {#if bottomOffscreen} +
+ +
+ {/if} + {:else if patchView === "changes"} + {@const onLatestRevision = selectedRevision.id === latestRevision.id} +
+ {#if commentSources.length > 0} + +
+ + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} +
+ + {#snippet item(source)} + {@const hidden = hiddenCommentSources.has(source.id)} + { + if (hidden) { + hiddenCommentSources.delete(source.id); + } else { + hiddenCommentSources.add(source.id); + } + }}> + + + {#each source.nids.slice(0, 2) as nid (nid)} + + {/each} + {#if source.nids.length > 2} + + + {/if} + + + {source.name} + +
+ + {source.count} +
+
+ {/snippet} +
+
+ {/snippet} +
+ +
+ {/if} + {#if !onLatestRevision} + + {/if} + {#if orderedRevisions.length > 1} + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} + + +
{ + if ( + columnMenuEl && + !e.composedPath().includes(columnMenuEl) + ) { + columnMenuOpen = false; + } + }}> +
+ Sort by + + Group by + + + + {#if columnMenuOpen} +
+ + revisionListSettings.toggle("showNumber")}> + + Revision number + + + revisionListSettings.toggle("showStats")}> + + Changed lines + + + revisionListSettings.toggle("showReviewers")}> + + Reviewers + +
+ {/if} +
+
+ {#if listSettings.showReviewers} + + {/if} + + {#snippet item(rev)} + {@const title = revisionTitle(rev)} + {@const commitCount = commitCountsByRevisionId[rev.id]} + { + void selectRevision(rev.id); + closeFocused(); + }}> + {#if listSettings.showNumber} + + r{revisionNumberById[rev.id]} + + {/if} + + + {rev.id.substring(0, 7)} + + + {formatTimestamp(rev.timestamp)} + + + {title ?? "No description"} + + + + + + + {#if commitCount !== undefined} + + {commitCount} + {/if} + + {#if listSettings.showStats} + {@const stats = statsByRevisionId[rev.id]} + + + {#if stats} + + +{stats.insertions} + + + -{stats.deletions} + + {:else} + + + + {/if} + + {/if} + {#if listSettings.showReviewers} + 0 + ? "1rem" + : "0"}> + + {#if draftRevisionIds.includes(rev.id)} + + {/if} + + {/if} + + {/snippet} + +
+ {/snippet} +
+ {/if} + +
+ {/if} +
+ {/snippet} + + {#snippet revisionBody()} + { + pendingRevisionId = revisionId; + selectedRevisionId = revisionId; + setView("changes"); + }} + chrome={patchHeader} + {tabs} + bind:showingRevisionDiff + bind:filesExpanded + bind:commentPosition /> + {/snippet} + + {#if changesPane} +
+ {@render revisionBody()} +
+ {:else if currentReview} +
+ +
+ {:else} + +
+
+ {@render patchHeader()} + +
+ {#if patchView !== "changes" && !currentReview} +
+
+ +
+ {#if descriptionOverflows && !descriptionEditing} +
+ +
+ {/if} +
+ {/if} + + {@render tabs()} + {@render revisionBody()} +
+
+
+ +
+ {/if} + + {#if ownDraftReview && patchView !== "activity"} + { + await loadPatch(); + const updatedRev = revisions.find(r => r.id === revisionId); + const newReview = updatedRev?.reviews?.find( + r => r.author.did === ownDid, + ); + if (newReview) { + void router.push({ + resource: "repo.patch", + rid: repo.rid, + patch: patch.id, + status: undefined, + reviewId: newReview.id, + view: patchView === "changes" ? "changes" : undefined, + }); + } + }} + onCancel={() => { + draftReviewStorage.delete(ownDraftReview.id); + void loadPatch(); + }} + onSelectComment={currentReview + ? // A published review page renders its own diff and none of this + // draft's comments, so there is nothing to scroll to; the location + // falls back to plain text. + undefined + : comment => { + if (comment.location) { + void revisionComponent?.revealComment(comment.location); + } + }} /> + {/if} +
+
diff --git a/desktop/src/views/repo/Patches.svelte b/desktop/src/views/repo/Patches.svelte new file mode 100644 index 000000000..8864e311f --- /dev/null +++ b/desktop/src/views/repo/Patches.svelte @@ -0,0 +1,332 @@ + + + + + +
+ + Patches + +
+ 0} + placeholder={`Search patches ${modifierKey()} + f`} + icon={loading ? "clock" : "search"} + onFocus={async () => { + try { + loading = true; + await list.loadMore(true); + } catch (e) { + console.error("Loading all patches failed: ", e); + } finally { + loading = false; + } + }} + onSubmit={async () => { + if (searchResults.length === 1) { + await router.push({ + patch: searchResults[0].id, + resource: "repo.patch", + reviewId: undefined, + rid: repo.rid, + status, + }); + } + }} + bind:show={showSearch} + bind:value={searchInput} /> + +
+
+ + {#if searchResults.length === 0} + {#if patchCountMismatch(status)} + + {/if} +
+
+ {#if list.items.length > 0} + No matching patches + {:else} + No {status === undefined ? "" : status} patches + {/if} +
+
+ {:else} + +
+ {#if patchCountMismatch(status)} + + {/if} +
+ list.loadMore()} + startMargin={chromeHeight} + estimatedItemSize={80} + getKey={patch => patch.id} + initialCache={list.initialCache} + initialScrollOffset={list.initialScrollOffset} + onRestored={list.consumeRestoredScroll} + onState={list.persistScroll}> + {#snippet row(patch)} +
+ +
+ {/snippet} +
+
+ {/if} +
+
diff --git a/desktop/src/views/repo/Release.svelte b/desktop/src/views/repo/Release.svelte new file mode 100644 index 000000000..5b3b40307 --- /dev/null +++ b/desktop/src/views/repo/Release.svelte @@ -0,0 +1,521 @@ + + + + + +
+ + +
+ {#if release.creator.did === ownDid} + + {/if} + + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} +
+ + +
+ {/snippet} +
+
+
+ + +
+
+
+ +
+ +
+ + + + {#if view.showFilters || view.redactedCount > 0} +
+ {#if view.showFilters} + + + {/if} + {#if view.redactedCount > 0} +
+ +
+ {/if} +
+ {/if} + + {#if untrustedWarning} +
+ +
+ {/if} + + {#if registerError} +
{registerError}
+ {/if} + + {#if view.shown.length === 0} +
+ + No artifacts +
+ {/if} + +
+ {#each view.shown as artifact (artifact.cid)} + + {/each} +
+
+
+
+
diff --git a/desktop/src/views/repo/Releases.svelte b/desktop/src/views/repo/Releases.svelte new file mode 100644 index 000000000..1ee02bfc9 --- /dev/null +++ b/desktop/src/views/repo/Releases.svelte @@ -0,0 +1,193 @@ + + + + + { + const auxiliarKey = isMac() ? e.metaKey : e.ctrlKey; + if (auxiliarKey && e.key.toLowerCase() === "n" && !$modalStore) { + e.preventDefault(); + openCreateRelease(); + } + }} /> + + +
+ + Releases + {#if showFilters} + + {/if} +
+ +
+
+ + {#if list.items.length === 0} +
+
+ {!showFilters + ? "No releases" + : scope === "trusted" + ? "No releases by delegates" + : "No releases by others"} +
+
+ {:else} + {#if scope === "untrusted"} +
+ +
+ {/if} + + list.loadMore()} + estimatedItemSize={80} + getKey={release => release.id} + initialCache={list.initialCache} + initialScrollOffset={list.initialScrollOffset} + onRestored={list.consumeRestoredScroll} + onState={list.persistScroll}> + {#snippet row(release)} +
+ +
+ {/snippet} +
+
+ {/if} +
+
diff --git a/desktop/src/views/repo/RepoCommit.svelte b/desktop/src/views/repo/RepoCommit.svelte new file mode 100644 index 000000000..6ebad2b2a --- /dev/null +++ b/desktop/src/views/repo/RepoCommit.svelte @@ -0,0 +1,336 @@ + + + + +{#snippet commitMeta()} +
+
+
+
+ {#if !commit.summary} + No commit message + {:else} + {commit.summary} + {/if} +
+
+ + + {commit.author.name} + + {authorIsCommitter ? "committed" : "authored"} + + + {formatTimestamp(commit.committer.time * 1000)} + + +
+
+ + {commit.parents.length === 1 ? "parent" : "parents"} + + {#if commit.parents.length === 0} + Initial commit + {:else} + {#each commit.parents as parent} + + {/each} + {/if} +
+
{commit.message
+            .replace(commit.summary, "")
+            .trim()}
+
+
+
+ {diff.stats.filesChanged} + {pluralize("file", diff.stats.filesChanged)} changed +
+ + Promise.resolve(patch)} + fileName={`${formatOid(commit.id)}.diff`} + title="Diff actions" /> +
+
+
+{/snippet} + + +
+ + + +
+ + + + +
+
+
+ {#if diffOptions.showTree} +
+ diffView?.scrollToFile(path)} /> +
+ {/if} + + + getDiffText(repo.rid, undefined, commit.id, 3, path)} + cacheKeyPrefix={commit.id} + header={commitMeta} /> +
+
+
diff --git a/desktop/src/views/repo/RepoCommits.svelte b/desktop/src/views/repo/RepoCommits.svelte new file mode 100644 index 000000000..3932d13f5 --- /dev/null +++ b/desktop/src/views/repo/RepoCommits.svelte @@ -0,0 +1,234 @@ + + + + + +
+ + + {#snippet extra()} + 0} + placeholder={`Search commits ${modifierKey()} + f`} + styleHeight="1.75rem" + icon={loading ? "clock" : "search"} + onFocus={async () => { + try { + loading = true; + await list.loadMore(true); + } catch (e) { + console.error("Loading all commits failed: ", e); + } finally { + loading = false; + } + }} + onSubmit={() => { + if (filteredCommits.length === 1) { + void router.push({ + resource: "repo.commit", + rid: repo.rid, + commit: filteredCommits[0].id, + }); + } + }} + bind:show={showSearch} + bind:value={searchInput} /> + {/snippet} + + {#if filteredCommits.length === 0} +
+
+ {#if list.items.length > 0} + No matching commits + {:else} + No commits + {/if} +
+
+ {:else} + + list.loadMore()} + getKey={r => + r.type === "header" ? `h:${r.key}` : `c:${r.commit.id}`} + initialCache={list.initialCache} + initialScrollOffset={list.initialScrollOffset} + onRestored={list.consumeRestoredScroll} + onState={list.persistScroll}> + {#snippet row(item)} + {#if item.type === "header"} +
{item.label}
+ {:else} +
{ + void router.push({ + resource: "repo.commit", + rid: repo.rid, + commit: item.commit.id, + }); + }} + onkeydown={e => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + void router.push({ + resource: "repo.commit", + rid: repo.rid, + commit: item.commit.id, + }); + } + }}> + +
+ {/if} + {/snippet} +
+
+ {/if} +
+
diff --git a/desktop/src/views/repo/RepoHome.svelte b/desktop/src/views/repo/RepoHome.svelte new file mode 100644 index 000000000..6319f7b4a --- /dev/null +++ b/desktop/src/views/repo/RepoHome.svelte @@ -0,0 +1,372 @@ + + + + + +
+ + +
+
+ {#if tree.entries.length > 0} + + {#key oid} + + {/key} + + {/if} +
+ +
+
+ {#if blob === null && !error} +
+
+ No README.md +
+
+ {:else} + + {#snippet leftHeader()} +
+ +
+ {#if blob && !error} +
+ + + {blob.commit.message} + +
+ {/if} + {/snippet} + + {#snippet rightHeader()} + {#if previewable && !error} + + {/if} + {/snippet} + +
+
+ {#if blob && showLineNumbers} + {#each blob.content + .trimEnd() + .split("\n") + .map((_, index) => index) as line} +
+ {line + 1} +
+ {/each} + {/if} +
+
+ {#if error || !blob} +
+ + {#if error?.code === "PayloadError.TooLarge"} + File size exceeds limit of 10 MB. + {:else} + {capitalize(error?.message)} + {/if} +
+ {:else if blob.binary} + {#if blob.mimeType.startsWith("image")} + {`Preview + {:else} +
+ Binary file +
+ {/if} + {:else if preview} +
+ {#if isAsciidocPath(currentPath)} + + {:else} + + {/if} +
+ {:else if blob.content.trim() === ""} +
+ Empty file +
+ {:else} + +
{#await highlight(blob.content, currentPath
+                              .split(".")
+                              .at(-1) || "raw")}{blob.content}{:then tree}{@html toHtml(
+                              tree,
+                            )}{/await}
+
+ {/if} +
+
+
+ {/if} +
+
+
+
+
+
diff --git a/desktop/src/views/repo/router.ts b/desktop/src/views/repo/router.ts new file mode 100644 index 000000000..01aab3f0c --- /dev/null +++ b/desktop/src/views/repo/router.ts @@ -0,0 +1,841 @@ +import type { Action as IssueAction } from "@bindings/cob/issue/Action"; +import type { Issue } from "@bindings/cob/issue/Issue"; +import type { Operation } from "@bindings/cob/Operation"; +import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery"; +import type { Action as PatchAction } from "@bindings/cob/patch/Action"; +import type { Patch } from "@bindings/cob/patch/Patch"; +import type { Review } from "@bindings/cob/patch/Review"; +import type { Revision } from "@bindings/cob/patch/Revision"; +import type { Release } from "@bindings/cob/release/Release"; +import type { ReleaseCounts } from "@bindings/cob/release/ReleaseCounts"; +import type { ReleaseFilter } from "@bindings/cob/release/ReleaseFilter"; +import type { ReleaseScope } from "@bindings/cob/release/ReleaseScope"; +import type { Thread } from "@bindings/cob/thread/Thread"; +import type { Config } from "@bindings/config/Config"; +import type { Diff } from "@bindings/diff/Diff"; +import type { Identity } from "@bindings/identity/Identity"; +import type { Commit } from "@bindings/repo/Commit"; +import type { Readme } from "@bindings/repo/Readme"; +import type { RepoInfo } from "@bindings/repo/RepoInfo"; +import type { Tree } from "@bindings/source/Tree"; + +import { + cachedGetCommitDiff, + cachedGetDiffText, + invoke, +} from "@app/lib/invoke"; +import { releaseListScope } from "@app/lib/releases"; +import type { LoadedRoute, SidebarData } from "@app/lib/router/definitions"; +import { loadSidebarData } from "@app/lib/router/definitions"; +import { unreachable } from "@app/lib/utils"; + +export type IssueStatus = "all" | Issue["state"]["status"]; + +export const DEFAULT_TAKE = 20; +export const COMMITS_PAGE_SIZE = 300; +export const RELEASES_PER_PAGE = 30; + +export interface RepoHomeRoute { + resource: "repo.home"; + rid: string; + peer?: string; + revision?: string; + // The file shown in the source view. Trailing URL segments are already + // consumed by `revision`, so this travels as a query parameter. + path?: string; +} + +export interface RepoCommitsRoute { + resource: "repo.commits"; + rid: string; + peer?: string; + revision?: string; +} + +export type SourceBaseRoute = RepoHomeRoute | RepoCommitsRoute; + +export interface RepoCommitRoute { + resource: "repo.commit"; + rid: string; + commit: string; +} + +export interface RepoIdentityRoute { + resource: "repo.identity"; + rid: string; +} + +export interface LoadedRepoIdentityRoute { + resource: "repo.identity"; + params: { + repo: RepoInfo; + identity: Identity; + sidebarData: SidebarData; + }; +} + +export interface RepoIssueRoute { + resource: "repo.issue"; + rid: string; + issue: string; + status: IssueStatus; +} + +export interface LoadedRepoHomeRoute { + resource: "repo.home"; + params: { + repo: RepoInfo; + peer?: string; + revision?: string; + oid: string; + commit: Commit; + tree: Tree; + readme: Readme | null; + path?: string; + sidebarData: SidebarData; + }; +} + +export interface LoadedRepoCommitsRoute { + resource: "repo.commits"; + params: { + repo: RepoInfo; + peer?: string; + revision?: string; + oid: string; + commit: Commit; + commits: PaginatedQuery; + sidebarData: SidebarData; + }; +} + +export interface LoadedRepoCommitRoute { + resource: "repo.commit"; + params: { + repo: RepoInfo; + commit: Commit; + diff: Diff; + patch: string; + sidebarData: SidebarData; + }; +} + +export interface LoadedRepoIssueRoute { + resource: "repo.issue"; + params: { + repo: RepoInfo; + config: Config; + issue: Issue; + activity: Operation[]; + threads: Thread[]; + sidebarData: SidebarData; + }; +} + +export interface RepoIssuesRoute { + resource: "repo.issues"; + rid: string; + status: IssueStatus; +} + +export interface LoadedRepoIssuesRoute { + resource: "repo.issues"; + params: { + repo: RepoInfo; + issues: PaginatedQuery; + status: IssueStatus; + sidebarData: SidebarData; + }; +} + +export type PatchStatus = Patch["state"]["status"]; + +export type PatchView = "activity" | "changes"; + +export interface RepoPatchRoute { + resource: "repo.patch"; + rid: string; + patch: string; + status: PatchStatus | undefined; + reviewId: string | undefined; + view?: PatchView; +} + +export interface LoadedRepoPatchRoute { + resource: "repo.patch"; + params: { + repo: RepoInfo; + config: Config; + patch: Patch; + patches: PaginatedQuery; + status: PatchStatus | undefined; + view?: PatchView; + review: Review | undefined; + revisions: Revision[]; + activity: Operation[]; + sidebarData: SidebarData; + }; +} + +export interface RepoPatchesRoute { + resource: "repo.patches"; + rid: string; + status: PatchStatus | undefined; +} + +export interface LoadedRepoPatchesRoute { + resource: "repo.patches"; + params: { + repo: RepoInfo; + patches: PaginatedQuery; + status: PatchStatus | undefined; + sidebarData: SidebarData; + }; +} + +export interface RepoReleasesRoute { + resource: "repo.releases"; + rid: string; + scope?: ReleaseScope; +} + +export interface LoadedRepoReleasesRoute { + resource: "repo.releases"; + params: { + repo: RepoInfo; + releases: PaginatedQuery; + releaseCounts: ReleaseCounts; + scope: ReleaseScope; + showFilters: boolean; + sidebarData: SidebarData; + }; +} + +export interface RepoReleaseRoute { + resource: "repo.release"; + rid: string; + release: string; + // The release list the user came from, shown in the breadcrumb. + scope?: ReleaseScope; + artifactScope?: ReleaseScope; +} + +export interface LoadedRepoReleaseRoute { + resource: "repo.release"; + params: { + repo: RepoInfo; + config: Config; + release: Release; + scope?: ReleaseScope; + artifactScope?: ReleaseScope; + sidebarData: SidebarData; + }; +} + +export type RepoRoute = + | RepoHomeRoute + | RepoCommitsRoute + | RepoCommitRoute + | RepoIdentityRoute + | RepoIssueRoute + | RepoIssuesRoute + | RepoPatchRoute + | RepoPatchesRoute + | RepoReleaseRoute + | RepoReleasesRoute; +export type LoadedRepoRoute = + | LoadedRepoHomeRoute + | LoadedRepoCommitsRoute + | LoadedRepoCommitRoute + | LoadedRepoIdentityRoute + | LoadedRepoIssueRoute + | LoadedRepoIssuesRoute + | LoadedRepoPatchRoute + | LoadedRepoPatchesRoute + | LoadedRepoReleaseRoute + | LoadedRepoReleasesRoute; + +export async function loadPatch( + route: RepoPatchRoute, + previousLoaded?: LoadedRoute, +): Promise { + // Switching tab (view) or review on the same patch shouldn't refetch the + // repo-wide data, so the sidebar and patch list are reused when we stay on + // the same patch. The patch itself, its revisions and its activity are + // always refetched: they change on every comment, review and edit, and a + // reused copy resurrects pre-mutation state when the tab changes. + const reuse = + previousLoaded?.resource === "repo.patch" && + previousLoaded.params.repo.rid === route.rid && + previousLoaded.params.patch.id === route.patch && + previousLoaded.params.status === route.status + ? previousLoaded.params + : undefined; + + const [[sidebarData, repo, patches], [patch, revisions, activity]] = + await Promise.all([ + reuse + ? ([reuse.sidebarData, reuse.repo, reuse.patches] as const) + : Promise.all([ + loadSidebarData(), + invoke("repo_by_id", { + rid: route.rid, + }), + invoke>("list_patches", { + rid: route.rid, + status: route.status, + take: DEFAULT_TAKE, + }), + ] as const), + Promise.all([ + invoke("patch_by_id", { + rid: route.rid, + id: route.patch, + }), + invoke("revisions_by_patch", { + rid: route.rid, + id: route.patch, + }), + invoke[]>("activity_by_patch", { + rid: route.rid, + id: route.patch, + }), + ] as const), + ]); + + // The patch may have been deleted (removed here, on another device, or a + // stale link); fall back to the patch list instead of crashing. + if (!patch) { + return loadPatches({ + resource: "repo.patches", + rid: route.rid, + status: route.status, + }); + } + + const config = sidebarData.config; + + const review = revisions + .flatMap(r => r.reviews || []) + .find(review => review.id === route.reviewId); + + return { + resource: "repo.patch", + params: { + repo, + config, + patch, + patches, + revisions, + status: route.status, + view: route.view, + review, + activity, + sidebarData, + }, + }; +} + +export async function loadPatches( + route: RepoPatchesRoute, +): Promise { + const [sidebarData, repo, patches] = await Promise.all([ + loadSidebarData(), + invoke("repo_by_id", { + rid: route.rid, + }), + invoke>("list_patches", { + rid: route.rid, + status: route.status, + take: DEFAULT_TAKE, + }), + ]); + + return { + resource: "repo.patches", + params: { sidebarData, repo, patches, status: route.status }, + }; +} + +interface SourceContext { + sidebarData: SidebarData; + repo: RepoInfo; + peer?: string; + revision?: string; + oid: string; + commit: Commit; +} + +// Revision resolution happens in the backend: content commands accept +// (peer, revision) directly, so everything loads in parallel. The resolved +// OID is read off the commit response. Repo refs for the peer selector are +// expensive to enumerate and are fetched lazily by the SourceHeader after +// render instead of blocking navigation here. +async function loadSourceContext(route: { + rid: string; + peer?: string; + revision?: string; +}): Promise { + const [sidebarData, repo, commit] = await Promise.all([ + loadSidebarData(), + invoke("repo_by_id", { + rid: route.rid, + }), + invoke("repo_commit", { + rid: route.rid, + peer: route.peer, + revision: route.revision, + }), + ]); + + return { + sidebarData, + repo, + peer: route.peer, + revision: route.revision, + oid: commit.id, + commit, + }; +} + +export async function loadRepoHome( + route: RepoHomeRoute, +): Promise { + const [context, readme, tree] = await Promise.all([ + loadSourceContext(route), + invoke("repo_readme", { + rid: route.rid, + peer: route.peer, + revision: route.revision, + }), + invoke("repo_tree", { + rid: route.rid, + path: "", + peer: route.peer, + revision: route.revision, + }), + ]); + + return { + resource: "repo.home", + params: { + ...context, + readme, + tree, + path: route.path, + }, + }; +} + +export async function loadRepoCommits( + route: RepoCommitsRoute, +): Promise { + const [context, commits] = await Promise.all([ + loadSourceContext(route), + invoke>("list_repo_commits", { + rid: route.rid, + peer: route.peer, + revision: route.revision, + skip: 0, + take: COMMITS_PAGE_SIZE, + }), + ]); + + return { + resource: "repo.commits", + params: { + ...context, + commits, + }, + }; +} + +export async function loadRepoCommit( + route: RepoCommitRoute, +): Promise { + const [sidebarData, repo, commit, diff, patch] = await Promise.all([ + loadSidebarData(), + invoke("repo_by_id", { + rid: route.rid, + }), + invoke("repo_commit", { + rid: route.rid, + sha: route.commit, + }), + // The diff manifest drives the file tree, per-file binary/empty notes and + // status labels, and the stats badge; the diff itself is rendered from the + // patch text below. + cachedGetCommitDiff(route.rid, route.commit), + // Patch text that feeds the rendered diff; fetched in parallel so it isn't a + // serial round-trip, and cached since a commit's patch is immutable. + cachedGetDiffText(route.rid, undefined, route.commit, 3), + ]); + + return { + resource: "repo.commit", + params: { sidebarData, repo, commit, diff, patch }, + }; +} + +export async function loadIdentity( + route: RepoIdentityRoute, +): Promise { + const [sidebarData, repo, identity] = await Promise.all([ + loadSidebarData(), + invoke("repo_by_id", { + rid: route.rid, + }), + invoke("identity_by_repo", { + rid: route.rid, + }), + ]); + + return { + resource: "repo.identity", + params: { sidebarData, repo, identity }, + }; +} + +export async function loadIssue( + route: RepoIssueRoute, +): Promise { + const [sidebarData, repo, issue, activity, threads] = await Promise.all([ + loadSidebarData(), + invoke("repo_by_id", { + rid: route.rid, + }), + invoke("issue_by_id", { + rid: route.rid, + id: route.issue, + }), + invoke[]>("activity_by_issue", { + rid: route.rid, + id: route.issue, + }), + invoke("comment_threads_by_issue_id", { + rid: route.rid, + id: route.issue, + }), + ]); + + return { + resource: "repo.issue", + params: { + sidebarData, + repo, + config: sidebarData.config, + issue, + activity, + threads, + }, + }; +} + +export async function loadIssues( + route: RepoIssuesRoute, +): Promise { + const [sidebarData, repo, issues] = await Promise.all([ + loadSidebarData(), + invoke("repo_by_id", { + rid: route.rid, + }), + invoke>("list_issues", { + rid: route.rid, + status: route.status, + take: DEFAULT_TAKE, + }), + ]); + + return { + resource: "repo.issues", + params: { sidebarData, repo, issues, status: route.status }, + }; +} + +export function listReleases( + rid: string, + scope: ReleaseScope, + skip: number, + // Undefined lists every release. + take: number | undefined, +) { + return invoke>("list_releases", { + rid, + filter: { scope, showRedacted: false } satisfies ReleaseFilter, + skip, + take, + }); +} + +export async function loadReleases( + route: RepoReleasesRoute, +): Promise { + let scope = route.scope ?? "trusted"; + const [sidebarData, repo, firstPage, releaseCounts] = await Promise.all([ + loadSidebarData(), + invoke("repo_by_id", { + rid: route.rid, + }), + listReleases(route.rid, scope, 0, RELEASES_PER_PAGE), + invoke("release_counts", { + rid: route.rid, + }), + ]); + let releases = firstPage; + + const fallback = releaseListScope(route.scope, releaseCounts); + if (fallback !== scope) { + scope = fallback; + releases = await listReleases(route.rid, scope, 0, RELEASES_PER_PAGE); + } + + const showFilters = releaseCounts.delegate > 0 && releaseCounts.other > 0; + + return { + resource: "repo.releases", + params: { + sidebarData, + repo, + releases, + releaseCounts, + scope, + showFilters, + }, + }; +} + +export async function loadRelease( + route: RepoReleaseRoute, +): Promise { + const [sidebarData, repo, release] = await Promise.all([ + loadSidebarData(), + invoke("repo_by_id", { + rid: route.rid, + }), + invoke("release_by_id", { + rid: route.rid, + id: route.release, + }), + ]); + + // A redacted or stale release falls back to the list. + if (!release) { + return loadReleases({ + resource: "repo.releases", + rid: route.rid, + scope: route.scope, + }); + } + + return { + resource: "repo.release", + params: { + sidebarData, + repo, + config: sidebarData.config, + release, + scope: route.scope, + artifactScope: route.artifactScope, + }, + }; +} + +export function repoRouteToPath(route: RepoRoute): string { + const pathSegments = ["/repos", route.rid]; + const searchParams = new URLSearchParams(); + + if (route.resource === "repo.home") { + const segments = [...pathSegments, "home"]; + if (route.peer !== undefined) { + segments.push("remotes", route.peer); + } + if (route.revision !== undefined) { + segments.push(route.revision); + } + if (route.path) { + searchParams.set("path", route.path); + return `${segments.join("/")}?${searchParams}`; + } + return segments.join("/"); + } else if (route.resource === "repo.commits") { + const segments = [...pathSegments, "commits"]; + if (route.peer !== undefined) { + segments.push("remotes", route.peer); + } + if (route.revision !== undefined) { + segments.push(route.revision); + } + return segments.join("/"); + } else if (route.resource === "repo.commit") { + return [...pathSegments, "commits", route.commit].join("/"); + } else if (route.resource === "repo.identity") { + return [...pathSegments, "identity"].join("/"); + } else if (route.resource === "repo.issue") { + let url = [...pathSegments, "issues", route.issue].join("/"); + searchParams.set("status", route.status); + url += `?${searchParams}`; + return url; + } else if (route.resource === "repo.issues") { + let url = [...pathSegments, "issues"].join("/"); + searchParams.set("status", route.status); + url += `?${searchParams}`; + return url; + } else if (route.resource === "repo.patch") { + let url = [...pathSegments, "patches", route.patch].join("/"); + if (route.status) { + searchParams.set("status", route.status); + } + if (route.reviewId) { + searchParams.set("review", route.reviewId); + } + if (route.view) { + searchParams.set("view", route.view); + } + if (searchParams.size > 0) { + url += `?${searchParams}`; + } + return url; + } else if (route.resource === "repo.patches") { + let url = [...pathSegments, "patches"].join("/"); + if (route.status) { + searchParams.set("status", route.status); + url += `?${searchParams}`; + } + return url; + } else if (route.resource === "repo.release") { + let url = [...pathSegments, "releases", route.release].join("/"); + if (route.scope) { + searchParams.set("scope", route.scope); + } + if (route.artifactScope === "untrusted") { + searchParams.set("artifacts", "untrusted"); + } + if (searchParams.size > 0) { + url += `?${searchParams}`; + } + return url; + } else if (route.resource === "repo.releases") { + let url = [...pathSegments, "releases"].join("/"); + if (route.scope) { + searchParams.set("scope", route.scope); + url += `?${searchParams}`; + } + return url; + } else { + return unreachable(route); + } +} + +function parseReleaseScope(value: string | null): ReleaseScope | undefined { + return value === "trusted" || value === "untrusted" ? value : undefined; +} + +export function repoUrlToRoute( + segments: string[], + searchParams: URLSearchParams, +): RepoRoute | null { + const rid = segments.shift(); + const resource = segments.shift(); + + if (rid) { + if (resource === "home") { + let peer: string | undefined; + if (segments[0] === "remotes") { + segments.shift(); + peer = segments.shift(); + } + const revision = segments.length > 0 ? segments.join("/") : undefined; + const path = searchParams.get("path") ?? undefined; + return { resource: "repo.home", rid, peer, revision, path }; + } else if (resource === "commits") { + let peer: string | undefined; + if (segments[0] === "remotes") { + segments.shift(); + peer = segments.shift(); + } + if (segments.length === 0) { + return { resource: "repo.commits", rid, peer }; + } + if ( + peer === undefined && + segments.length === 1 && + /^[0-9a-f]{40}$/.test(segments[0]) + ) { + return { resource: "repo.commit", rid, commit: segments[0] }; + } + return { + resource: "repo.commits", + rid, + peer, + revision: segments.join("/"), + }; + } else if (resource === "identity") { + return { resource: "repo.identity", rid }; + } else if (resource === "issues") { + const idOrAction = segments.shift(); + if (idOrAction) { + const status = (searchParams.get("status") ?? "all") as IssueStatus; + if (idOrAction !== "create") { + return { + resource: "repo.issue", + rid, + issue: idOrAction, + status, + }; + } + return null; + } else { + const status = searchParams.get("status"); + if (status === "open" || status === "closed") { + return { resource: "repo.issues", rid, status }; + } else { + return { resource: "repo.issues", rid, status: "all" }; + } + } + } else if (resource === "patches") { + const id = segments.shift(); + const status = (searchParams.get("status") ?? undefined) as + PatchStatus | undefined; + const reviewId = searchParams.get("review") ?? undefined; + const viewParam = searchParams.get("view"); + const view: PatchView | undefined = + viewParam === "activity" || viewParam === "changes" + ? viewParam + : undefined; + if (id) { + return { + resource: "repo.patch", + rid, + patch: id, + status, + reviewId, + view, + }; + } else { + return { resource: "repo.patches", rid, status }; + } + } else if (resource === "releases") { + const id = segments.shift(); + const scope = parseReleaseScope(searchParams.get("scope")); + if (id) { + const artifactScope = + searchParams.get("artifacts") === "untrusted" + ? "untrusted" + : undefined; + return { + resource: "repo.release", + rid, + release: id, + scope, + artifactScope, + }; + } else { + return { resource: "repo.releases", rid, scope }; + } + } else { + return null; + } + } else { + return null; + } +} diff --git a/desktop/src/vite-env.d.ts b/desktop/src/vite-env.d.ts new file mode 100644 index 000000000..8433e7809 --- /dev/null +++ b/desktop/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// +/// + +interface ImportMetaEnv { + // eslint-disable-next-line @typescript-eslint/naming-convention + readonly VITE_LOG_INVOKE_TIMINGS?: string; +} diff --git a/desktop/svelte.config.js b/desktop/svelte.config.js new file mode 100644 index 000000000..de2ddd659 --- /dev/null +++ b/desktop/svelte.config.js @@ -0,0 +1,7 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +export default { + // Consult https://svelte.dev/docs#compile-time-svelte-preprocess + // for more information about preprocessors + preprocess: vitePreprocess(), +}; diff --git a/desktop/tests/e2e/auth.spec.ts b/desktop/tests/e2e/auth.spec.ts new file mode 100644 index 000000000..0a38e6f1f --- /dev/null +++ b/desktop/tests/e2e/auth.spec.ts @@ -0,0 +1,39 @@ +import { + expect, + gitOptions, + test, + useBackend, +} from "@tests/support/fixtures.js"; + +test("unlocks encrypted keys through the ssh-agent", async ({ + page, + peerManager, + sshAuthSock, +}) => { + const peer = await peerManager.createPeer({ + name: "locked", + gitOptions: gitOptions["bob"], + passphrase: "asdf", + sshAuthSock, + }); + await peer.logOut(); + await peer.startHttpd(); + await useBackend(page, peer); + + await page.goto("/"); + await expect(page.getByText("Unlock keys")).toBeVisible(); + + await page + .getByPlaceholder("Enter passphrase to unlock your keys") + .fill("wrong"); + await page.getByRole("button", { name: "Unlock" }).click(); + await expect( + page.getByText("Not able to decrypt keys with provided passphrase."), + ).toBeVisible(); + + await page + .getByPlaceholder("Enter passphrase to unlock your keys") + .fill("asdf"); + await page.getByRole("button", { name: "Unlock" }).click(); + await expect(page.getByText("Unlock keys")).toBeHidden(); +}); diff --git a/desktop/tests/e2e/clipboard.spec.ts b/desktop/tests/e2e/clipboard.spec.ts new file mode 100644 index 000000000..e96056ee1 --- /dev/null +++ b/desktop/tests/e2e/clipboard.spec.ts @@ -0,0 +1,32 @@ +import { expect, markdownRid, test } from "@tests/support/fixtures.js"; +import { chromium } from "playwright"; + +// We explicitly run all clipboard tests within the context of a single test +// so that we don't run into race conditions, because there is no way to isolate +// the clipboard in Playwright yet. +test("copy to clipboard", async () => { + const browser = await chromium.launch(); + const context = await browser.newContext(); + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + const page = await context.newPage(); + + await page.goto("/inbox"); + + // Reset system clipboard to a known state. + await page.evaluate("navigator.clipboard.writeText('')"); + + // Repo ID. + { + const repoLink = page.getByRole("link", { name: "markdown" }); + await repoLink.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Copy RID" }).click(); + const clipboardContent = await page.evaluate( + "navigator.clipboard.readText()", + ); + expect(clipboardContent).toBe(markdownRid); + } + + // Clear the system clipboard contents so developers don't wonder why there's + // random stuff in their clipboard after running tests. + await page.evaluate("navigator.clipboard.writeText('')"); +}); diff --git a/desktop/tests/e2e/createIdentity.spec.ts b/desktop/tests/e2e/createIdentity.spec.ts new file mode 100644 index 000000000..a335ff637 --- /dev/null +++ b/desktop/tests/e2e/createIdentity.spec.ts @@ -0,0 +1,71 @@ +import * as Fs from "node:fs/promises"; +import * as Path from "node:path"; + +import { + expect, + gitOptions, + test, + useBackend, +} from "@tests/support/fixtures.js"; + +test("explains why an alias is invalid", async ({ page }) => { + await page.route( + url => url.pathname === "/startup", + route => + route.fulfill({ + status: 500, + json: { + code: "IdentityError.MissingProfile", + message: "No profile found", + }, + }), + ); + await page.goto("/"); + + const alias = page.getByPlaceholder("Enter desired alias"); + await alias.fill("has space"); + await expect( + page.getByText("Alias cannot contain whitespace."), + ).toBeVisible(); + + await alias.fill("a".repeat(33)); + await expect( + page.getByText("Alias is too long, max 32 characters."), + ).toBeVisible(); + await expect(page.getByText("Alias cannot contain whitespace.")).toBeHidden(); + + await alias.fill("alice"); + await expect( + page.getByText("Max 32 characters, no whitespace."), + ).toBeVisible(); +}); + +test("creates an identity and opens the guide", async ({ + page, + peerManager, + sshAuthSock, + stateDir, +}) => { + const peer = await peerManager.createPeer({ + name: "newcomer", + gitOptions: gitOptions["alice"], + sshAuthSock, + }); + // Start from a node that has never had an identity. + const home = Path.join(stateDir, "peers", "newcomer", "home"); + await Fs.rm(home, { recursive: true, force: true }); + await Fs.mkdir(home, { recursive: true }); + await peer.startHttpd(); + await useBackend(page, peer); + + await page.goto("/"); + await page.getByPlaceholder("Enter desired alias").fill("newcomer"); + await page + .getByPlaceholder("Enter passphrase to protect your keys") + .fill("secret"); + await page.getByPlaceholder("Repeat passphrase").fill("secret"); + await page.getByRole("button", { name: /Create new identity/ }).click(); + + await expect(page).toHaveURL(/\/guide/); + await expect(page.getByText("newcomer").first()).toBeVisible(); +}); diff --git a/desktop/tests/e2e/inbox.spec.ts b/desktop/tests/e2e/inbox.spec.ts new file mode 100644 index 000000000..70e01bb9a --- /dev/null +++ b/desktop/tests/e2e/inbox.spec.ts @@ -0,0 +1,67 @@ +import type { Page } from "@playwright/test"; +import type { PeerManager } from "@tests/support/peerManager.js"; + +import * as Path from "node:path"; + +import { + clone, + createCollaborators, + createProject, + waitForCobFrom, +} from "@tests/support/collaboration.js"; +import { + expect, + reload, + test, + useBackend, + waitForCommand, +} from "@tests/support/fixtures.js"; + +// Eve comments on bob's issue; the app runs as bob, who gets notified. +async function withNotification(page: Page, peerManager: PeerManager) { + const { bob, eve } = await createCollaborators(peerManager); + const { rid, issueId } = await createProject(bob); + await clone(eve, rid); + await eve.rad( + ["issue", "comment", issueId, "--message", "They are fine by me"], + { cwd: Path.join(eve.checkoutPath, "project") }, + ); + await waitForCobFrom(bob, eve, rid, issueId); + await bob.startHttpd(); + await useBackend(page, bob); + await page.goto("/inbox"); + const teaser = page.getByText("The notes are too quiet"); + await expect(teaser).toBeVisible(); + return teaser; +} + +test("a notification can be deleted", async ({ page, peerManager }) => { + const teaser = await withNotification(page, peerManager); + + await teaser.hover(); + await waitForCommand(page, "clear_notifications", () => + page.getByTitle("Delete", { exact: true }).click(), + ); + await expect(teaser).toBeHidden(); + await reload(page); + await expect(teaser).toBeHidden(); +}); + +test("all notifications can be cleared", async ({ page, peerManager }) => { + const teaser = await withNotification(page, peerManager); + + // The clear button only shows while the header is hovered. + await page + .getByRole("button", { name: "icon-search", exact: true }) + .last() + .hover(); + await page + .getByRole("button", { name: "icon-clear-all", exact: true }) + .click(); + await waitForCommand(page, "clear_notifications", () => + page.getByRole("button", { name: /^icon-clear-all Delete \d+$/ }).click(), + ); + await expect(teaser).toBeHidden(); + await reload(page); + await expect(teaser).toBeHidden(); +}); diff --git a/desktop/tests/e2e/keyboard.spec.ts b/desktop/tests/e2e/keyboard.spec.ts new file mode 100644 index 000000000..cee212712 --- /dev/null +++ b/desktop/tests/e2e/keyboard.spec.ts @@ -0,0 +1,108 @@ +import type { RadiclePeer } from "@tests/support/peerManager.js"; + +import * as Fs from "node:fs/promises"; +import * as Path from "node:path"; + +import { cobRid, expect, test } from "@tests/support/fixtures.js"; +import { createRepo } from "@tests/support/repo.js"; + +test("? lists the shortcuts and Escape closes the list", async ({ page }) => { + await page.goto("/inbox"); + await page.keyboard.press("?"); + const entry = page.getByText("Toggle sidebar"); + await expect(entry).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(entry).toBeHidden(); +}); + +test("Mod+B toggles the sidebar", async ({ page }) => { + await page.goto("/inbox"); + await expect( + page.getByRole("button", { name: "Collapse sidebar" }), + ).toBeVisible(); + + await page.keyboard.press("ControlOrMeta+b"); + await expect( + page.getByRole("button", { name: "Expand sidebar" }), + ).toBeVisible(); + await page.keyboard.press("ControlOrMeta+b"); + await expect( + page.getByRole("button", { name: "Collapse sidebar" }), + ).toBeVisible(); +}); + +test("Mod+B in a comment makes text bold instead", async ({ page }) => { + await page.goto(`/repos/${cobRid}/issues?status=all`); + await page.getByText("This title has **markdown**").click(); + const comment = page.getByPlaceholder("Leave a comment"); + await comment.fill("loud"); + await comment.press("ControlOrMeta+a"); + await comment.press("ControlOrMeta+b"); + + await expect(comment).toHaveValue("**loud**"); + await expect( + page.getByRole("button", { name: "Collapse sidebar" }), + ).toBeVisible(); +}); + +test("Mod+N opens a new issue in a repo", async ({ page }) => { + await page.goto(`/repos/${cobRid}/issues`); + await expect(page.getByText("This title has **markdown**")).toBeVisible(); + await page.keyboard.press("ControlOrMeta+n"); + await expect(page.getByPlaceholder("Title")).toBeVisible(); +}); + +test("Mod+, opens the settings", async ({ page }) => { + await page.goto("/inbox"); + await page.keyboard.press("ControlOrMeta+,"); + await expect(page.getByText("Diff style")).toBeVisible(); +}); + +test("Mod+1 opens the first repo in the sidebar", async ({ page }) => { + await page.goto("/inbox"); + await expect(page.getByRole("link", { name: /cobs/ })).toBeVisible(); + await page.keyboard.press("ControlOrMeta+1"); + await expect(page).toHaveURL(/\/repos\/rad:/); +}); + +async function createTwoCommitPatch(peer: RadiclePeer) { + const { rid, repoFolder } = await createRepo(peer, { name: "steps" }); + await peer.git(["switch", "-c", "feature/steps"], { cwd: repoFolder }); + for (const name of ["first.txt", "second.txt"]) { + await Fs.writeFile(Path.join(repoFolder, name), `${name}\n`); + await peer.git(["add", name], { cwd: repoFolder }); + await peer.git(["commit", "-m", `Add ${name}`], { cwd: repoFolder }); + } + const { stderr } = await peer.git( + ["push", "-o", "patch.message=Add two files", "rad", "HEAD:refs/patches"], + { cwd: repoFolder }, + ); + const patchId = stderr.match(/✓ Patch ([0-9a-f]+) opened/)?.[1]; + if (!patchId) throw new Error("Not able to parse patch id"); + return { rid, patchId }; +} + +test("arrow keys step through a patch's commits", async ({ page, peer }) => { + const { rid, patchId } = await createTwoCommitPatch(peer); + await page.goto(`/repos/${rid}/patches/${patchId}?view=changes`); + const first = page.getByText("first.txt", { exact: true }).first(); + const second = page.getByText("second.txt", { exact: true }).first(); + await expect(first).toBeVisible(); + await expect(second).toBeVisible(); + + await page.keyboard.press("ArrowDown"); + const showAll = page.getByTitle("Show all changes"); + await expect(showAll).toBeVisible(); + const onlyFirst = await first.isVisible(); + await expect(onlyFirst ? second : first).toBeHidden(); + + await page.keyboard.press("ArrowDown"); + await expect(onlyFirst ? second : first).toBeVisible(); + await expect(onlyFirst ? first : second).toBeHidden(); + + await page.keyboard.press("Escape"); + await expect(showAll).toBeHidden(); + await expect(first).toBeVisible(); + await expect(second).toBeVisible(); +}); diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts new file mode 100644 index 000000000..fb0186f49 --- /dev/null +++ b/desktop/tests/e2e/navigation.spec.ts @@ -0,0 +1,110 @@ +import type { Page } from "@playwright/test"; + +import { expect, reload, test } from "@tests/support/fixtures.js"; + +function backButton(page: Page) { + return page.getByRole("button", { name: "Back", exact: true }); +} + +function forwardButton(page: Page) { + return page.getByRole("button", { name: "Forward", exact: true }); +} + +async function expectInbox(page: Page) { + await expect(page).toHaveURL(/\/inbox$/); + await expect(page.getByRole("link", { name: "Issues" })).toBeHidden(); +} + +async function expectRepo(page: Page) { + await expect(page).toHaveURL(/\/repos\/rad:[^/]+\/home$/); + await expect(page.getByRole("link", { name: "Issues" })).toBeVisible(); +} + +async function expectIssues(page: Page) { + await expect(page).toHaveURL(/\/issues/); + await expect(page.getByText("This title has **markdown**")).toBeVisible(); +} + +async function openRepo(page: Page) { + await page.getByRole("link", { name: "cobs" }).click(); + await expectRepo(page); +} + +async function openIssues(page: Page) { + await page.getByRole("link", { name: "Issues" }).click(); + await expectIssues(page); +} + +async function launch(page: Page) { + await page.goto("/inbox"); + await expect(page.getByRole("link", { name: "cobs" })).toBeVisible(); +} + +test("back and forward are disabled on launch", async ({ page }) => { + await launch(page); + await expect(backButton(page)).toBeDisabled(); + await expect(forwardButton(page)).toBeDisabled(); +}); + +test("back and forward follow the history", async ({ page }) => { + await launch(page); + await openRepo(page); + await expect(backButton(page)).toBeEnabled(); + await expect(forwardButton(page)).toBeDisabled(); + + await backButton(page).click(); + await expectInbox(page); + await expect(backButton(page)).toBeDisabled(); + await expect(forwardButton(page)).toBeEnabled(); + + await forwardButton(page).click(); + await expectRepo(page); + await expect(backButton(page)).toBeEnabled(); + await expect(forwardButton(page)).toBeDisabled(); +}); + +test("navigating after going back discards forward entries", async ({ + page, +}) => { + await launch(page); + await openRepo(page); + await openIssues(page); + await backButton(page).click(); + await expectRepo(page); + await backButton(page).click(); + await expectInbox(page); + await expect(forwardButton(page)).toBeEnabled(); + + await openRepo(page); + await expect(backButton(page)).toBeEnabled(); + await expect(forwardButton(page)).toBeDisabled(); +}); + +test("history position survives a reload", async ({ page }) => { + await launch(page); + await openRepo(page); + await page.waitForLoadState("networkidle"); + + await reload(page); + await expectRepo(page); + await expect(backButton(page)).toBeEnabled(); + + await backButton(page).click(); + await expectInbox(page); + await expect(backButton(page)).toBeDisabled(); +}); + +test("shortcuts stay within the app's history", async ({ page }) => { + await launch(page); + const mac = process.platform === "darwin"; + const backShortcut = mac ? "Meta+BracketLeft" : "Alt+ArrowLeft"; + const forwardShortcut = mac ? "Meta+BracketRight" : "Alt+ArrowRight"; + await openRepo(page); + + await page.keyboard.press(backShortcut); + await expectInbox(page); + + await page.keyboard.press(backShortcut); + await page.keyboard.press(forwardShortcut); + await expectRepo(page); +}); diff --git a/desktop/tests/e2e/nodeStatus.spec.ts b/desktop/tests/e2e/nodeStatus.spec.ts new file mode 100644 index 000000000..21e207f78 --- /dev/null +++ b/desktop/tests/e2e/nodeStatus.spec.ts @@ -0,0 +1,9 @@ +import { expect, test } from "@tests/support/fixtures.js"; + +test("shows whether the node is running", async ({ page, peer }) => { + await page.goto("/"); + await expect(page.getByRole("button", { name: "Online" })).toBeVisible(); + + await peer.stopNode(); + await expect(page.getByRole("button", { name: "Offline" })).toBeVisible(); +}); diff --git a/desktop/tests/e2e/persistence.spec.ts b/desktop/tests/e2e/persistence.spec.ts new file mode 100644 index 000000000..c1fa0b558 --- /dev/null +++ b/desktop/tests/e2e/persistence.spec.ts @@ -0,0 +1,72 @@ +import { cobRid, expect, reload, test } from "@tests/support/fixtures.js"; + +test("a collapsed sidebar stays collapsed", async ({ page }) => { + await page.goto("/inbox"); + await page.getByRole("button", { name: "Collapse sidebar" }).click(); + await expect( + page.getByRole("button", { name: "Expand sidebar" }), + ).toBeVisible(); + + await reload(page); + await expect( + page.getByRole("button", { name: "Expand sidebar" }), + ).toBeVisible(); +}); + +test("a pinned repo stays pinned and first", async ({ page }) => { + await page.goto("/inbox"); + const markdown = page.getByRole("link", { name: /markdown/ }); + await markdown.hover(); + await markdown.getByTitle("Pin repository").click(); + await expect(markdown.getByTitle("Unpin repository")).toBeAttached(); + + await reload(page); + await expect(markdown.getByTitle("Unpin repository")).toBeAttached(); + const repos = page.getByRole("link", { name: /Repository Avatar/ }); + await expect(repos.first()).toContainText("markdown"); +}); + +test("a dismissed hint stays dismissed", async ({ page }) => { + await page.goto(`/repos/${cobRid}/issues?status=all`); + await page.getByText("This title has **markdown**").click(); + const hint = page.getByText("Markdown is supported"); + await expect(hint).toBeVisible(); + + await page.getByTitle("Don't show again").click(); + await expect(hint).toBeHidden(); + await reload(page); + await expect(page.getByPlaceholder("Leave a comment")).toBeVisible(); + await expect(hint).toBeHidden(); +}); + +test("the split diff style is remembered", async ({ page }) => { + await page.goto(`/repos/${cobRid}/patches`); + await page.getByRole("button", { name: "Settings" }).click(); + await page.getByTitle("Split", { exact: true }).click(); + await page.keyboard.press("Escape"); + + await reload(page); + await page.getByText("Taking another stab at the README").click(); + await page.getByRole("button", { name: "icon-diff Changes" }).click(); + + await expect(page.locator("[data-deletions]").first()).toBeAttached(); + await expect(page.locator("[data-unified]")).toHaveCount(0); +}); + +test("the revision sort order is remembered", async ({ page }) => { + await page.goto(`/repos/${cobRid}/patches`); + await page.getByText("Taking another stab at the README").click(); + await page.getByRole("button", { name: "icon-diff Changes" }).click(); + + const picker = page.getByRole("button", { name: /Revision \d+ of 2/ }); + const sort = page.getByTitle(/^(Newest|Oldest) first$/); + await picker.click(); + const before = await sort.getAttribute("title"); + await sort.click(); + const after = before === "Newest first" ? "Oldest first" : "Newest first"; + await expect(page.getByTitle(after, { exact: true })).toBeVisible(); + + await reload(page); + await picker.click(); + await expect(page.getByTitle(after, { exact: true })).toBeVisible(); +}); diff --git a/desktop/tests/e2e/repo/asciidoc.spec.ts b/desktop/tests/e2e/repo/asciidoc.spec.ts new file mode 100644 index 000000000..21d1d298d --- /dev/null +++ b/desktop/tests/e2e/repo/asciidoc.spec.ts @@ -0,0 +1,167 @@ +import { expect, test } from "@tests/support/fixtures.js"; + +test("render an AsciiDoc readme", async ({ page }) => { + await page.goto("/inbox"); + await page.getByRole("link", { name: "asciidoc" }).click(); + + const readme = page.locator(".asciidoc"); + + await expect( + readme.getByRole("heading", { name: "AsciiDoc Fixture", level: 1 }), + ).toBeVisible(); + await expect(readme.locator("code.language-rust")).toBeVisible(); + await expect(readme.locator("table.tableblock").first()).toBeVisible(); + await expect(readme.getByText("An admonition.")).toBeVisible(); + await expect(readme.getByText("A footnote.")).toBeVisible(); +}); + +test("open a relative link in the source view", async ({ page }) => { + await page.goto("/inbox"); + await page.getByRole("link", { name: "asciidoc" }).click(); + + const readme = page.locator(".asciidoc"); + await expect(readme.getByRole("link", { name: "the notes" })).toBeVisible(); + + const url = page.url(); + await readme.getByRole("link", { name: "the notes" }).click(); + + // The link opens the file in the source view rather than navigating the + // window away, and the file it opened is part of the history. + await expect(page.getByText("Notes from a sibling file.")).toBeVisible(); + expect(page.url()).not.toBe(url); + + await page.goBack(); + await expect(readme.getByText("Alpha section text.")).toBeVisible(); + expect(page.url()).toBe(url); +}); + +test("keep wide AsciiDoc table cells inside the viewport", async ({ page }) => { + await page.goto("/inbox"); + await page.getByRole("link", { name: "asciidoc" }).click(); + + const readme = page.locator(".asciidoc"); + await expect(readme.locator("table.tableblock").nth(1)).toBeVisible(); + + const overflow = await readme.evaluate( + element => element.scrollWidth - element.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(1); +}); + +test("resolve includes in an AsciiDoc readme", async ({ page }) => { + await page.goto("/inbox"); + await page.getByRole("link", { name: "asciidoc" }).click(); + + const readme = page.locator(".asciidoc"); + + await expect(readme.getByText("Alpha section text.")).toBeVisible(); + // Only the requested tag is included. + await expect(readme.getByText("Beta section text.")).toHaveCount(0); +}); + +test("refuse remote includes without a request", async ({ page }) => { + const remoteRequests: string[] = []; + page.on("request", request => { + if (new URL(request.url()).hostname === "example.invalid") { + remoteRequests.push(request.url()); + } + }); + + await page.goto("/inbox"); + await page.getByRole("link", { name: "asciidoc" }).click(); + + const readme = page.locator(".asciidoc"); + await expect( + readme.getByText(/Unresolved directive.*example\.invalid/), + ).toBeVisible(); + expect(remoteRequests).toEqual([]); +}); + +test("bound the reads of recursive includes", async ({ page }) => { + let blobReads = 0; + page.on("request", request => { + if (new URL(request.url()).pathname === "/repo_blob") { + blobReads += 1; + } + }); + + await page.goto("/inbox"); + await page.getByRole("link", { name: "asciidoc" }).click(); + await page.getByText("bomb.adoc", { exact: true }).click(); + + await expect( + page + .locator(".asciidoc") + .getByRole("heading", { name: "Include Bomb", level: 1 }), + ).toBeVisible({ timeout: 30_000 }); + // The include budget is 256 reads, plus the reads for the file itself and + // the readme's own include. + expect(blobReads).toBeLessThanOrEqual(260); +}); + +test("go back from a file opened in the tree", async ({ page }) => { + await page.goto("/inbox"); + await page.getByRole("link", { name: "asciidoc" }).click(); + + const readme = page.locator(".asciidoc"); + await expect(readme.getByText("Alpha section text.")).toBeVisible(); + const url = page.url(); + + await page.getByText("notes.txt", { exact: true }).click(); + await expect(page.getByText("Notes from a sibling file.")).toBeVisible(); + await expect(page).toHaveURL(/\?path=notes\.txt/); + + await page.goBack(); + await expect(readme.getByText("Alpha section text.")).toBeVisible(); + expect(page.url()).toBe(url); +}); + +test("show the same path at another revision", async ({ page }) => { + await page.goto("/inbox"); + const commit = page.waitForResponse( + response => new URL(response.url()).pathname === "/repo_commit", + ); + await page.getByRole("link", { name: "asciidoc" }).click(); + const { parents } = (await (await commit).json()) as { parents: string[] }; + const rid = page.url().match(/\/repos\/(rad:[^/?]+)/)?.[1]; + + await page.getByText("notes.txt", { exact: true }).click(); + await expect(page.getByText("Notes from a sibling file.")).toBeVisible(); + + // Follow an in-app link to the same file at the previous commit, so the + // view is reused with only the revision changed. + await page.evaluate(href => { + const anchor = document.createElement("a"); + anchor.href = href; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + }, `/repos/${rid}/home/${parents[0]}?path=notes.txt`); + + await expect( + page.getByText("An earlier version of the notes."), + ).toBeVisible(); + await expect(page.getByText("Notes from a sibling file.")).toHaveCount(0); + + await page.goBack(); + await expect(page.getByText("Notes from a sibling file.")).toBeVisible(); +}); + +test("sanitize untrusted markup in an AsciiDoc readme", async ({ page }) => { + await page.goto("/inbox"); + await page.getByRole("link", { name: "asciidoc" }).click(); + + const readme = page.locator(".asciidoc"); + await expect(readme.getByText("Untrusted markup")).toBeVisible(); + + await expect(readme.locator("script")).toHaveCount(0); + await expect(readme.locator("iframe")).toHaveCount(0); + await expect(readme.locator("[onerror]")).toHaveCount(0); + await expect(readme.locator("[onclick]")).toHaveCount(0); + await expect(readme.locator('a[href^="javascript:"]')).toHaveCount(0); + + const marker = await page.evaluate( + () => (window as unknown as { xssMarker?: string }).xssMarker, + ); + expect(marker).toBeUndefined(); +}); diff --git a/desktop/tests/e2e/repo/cobCache.spec.ts b/desktop/tests/e2e/repo/cobCache.spec.ts new file mode 100644 index 000000000..5e69da52f --- /dev/null +++ b/desktop/tests/e2e/repo/cobCache.spec.ts @@ -0,0 +1,73 @@ +import type { Page } from "@playwright/test"; + +import * as Path from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { createProject } from "@tests/support/collaboration.js"; +import { + expect, + reload, + test, + waitForCommand, +} from "@tests/support/fixtures.js"; + +// Breaks a cached COB so the list can no longer read it, while the counts, +// which only look at its state, still include it. +function corruptCache( + stateDir: string, + table: "issues" | "patches", + rid: string, +) { + const db = new DatabaseSync( + Path.join(stateDir, "peers", "httpd", "home", "cobs", "cache.db"), + ); + const column = table === "issues" ? "issue" : "patch"; + const field = table === "issues" ? "$.thread" : "$.revisions"; + db.prepare( + `UPDATE ${table} SET ${column} = json_set(${column}, '${field}', 'broken') WHERE repo = ?`, + ).run(rid); + db.close(); +} + +async function rebuild(page: Page, command: string) { + const warning = page.getByText("There's a problem with your COB cache"); + await expect(warning).toBeVisible(); + await waitForCommand(page, command, () => + page.getByRole("button", { name: "Rebuild cache" }).click(), + ); + return warning; +} + +test("a broken issue cache can be rebuilt", async ({ + page, + peer, + stateDir, +}) => { + const { rid } = await createProject(peer); + corruptCache(stateDir, "issues", rid); + + await page.goto(`/repos/${rid}/issues`); + const warning = await rebuild(page, "rebuild_issue_cache"); + + await expect(page.getByText("The notes are too quiet")).toBeVisible(); + await reload(page); + await expect(page.getByText("The notes are too quiet")).toBeVisible(); + await expect(warning).toBeHidden(); +}); + +test("a broken patch cache can be rebuilt", async ({ + page, + peer, + stateDir, +}) => { + const { rid } = await createProject(peer); + corruptCache(stateDir, "patches", rid); + + await page.goto(`/repos/${rid}/patches`); + const warning = await rebuild(page, "rebuild_patch_cache"); + + await expect(page.getByText("Shout the third line")).toBeVisible(); + await reload(page); + await expect(page.getByText("Shout the third line")).toBeVisible(); + await expect(warning).toBeHidden(); +}); diff --git a/desktop/tests/e2e/repo/createRelease.spec.ts b/desktop/tests/e2e/repo/createRelease.spec.ts new file mode 100644 index 000000000..22866c10a --- /dev/null +++ b/desktop/tests/e2e/repo/createRelease.spec.ts @@ -0,0 +1,570 @@ +import type { Page } from "@playwright/test"; +import type { PeerManager, RadiclePeer } from "@tests/support/peerManager.js"; + +import * as Fs from "node:fs/promises"; +import * as Path from "node:path"; + +import { + clone, + createCollaborators, + createProject, +} from "@tests/support/collaboration.js"; +import { + expect, + goto, + reload, + test, + useBackend, +} from "@tests/support/fixtures.js"; + +const OTHER_DID = "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5"; + +test("the commit picker lists commits and rejects unknown SHAs", async ({ + page, + peer, +}) => { + const { rid, repoFolder } = await createProject(peer); + const { stdout: head } = await peer.git(["rev-parse", "main"], { + cwd: repoFolder, + }); + + await page.goto(`/repos/${rid}/releases`); + await page.getByRole("button", { name: "New release" }).click(); + + const input = page.getByPlaceholder("Pick a commit or paste a SHA"); + await input.click(); + await page.getByText("Add notes").click(); + await expect(input).toHaveValue(head.trim()); + + await input.fill("cafecafecafecafecafecafecafecafecafecafe"); + await expect( + page.getByText("Commit cafecaf is not in this repo."), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Create release" }), + ).toBeDisabled(); +}); + +// Fills the New release modal with one artifact, without submitting it. +async function fillRelease(page: Page, peer: RadiclePeer) { + const { rid, repoFolder } = await createProject(peer); + const file = Path.join(repoFolder, "build.tar"); + await Fs.writeFile(file, "artifact bytes"); + // The native file dialog only exists under Tauri. + await page.route(/\/pick_artifact_files$/, route => + route.fulfill({ json: [file] }), + ); + + await page.goto(`/repos/${rid}/releases`); + await page.getByRole("button", { name: "New release" }).click(); + await page.getByPlaceholder("Pick a commit or paste a SHA").click(); + await page.getByText("Add notes").click(); + await page.getByRole("button", { name: "Choose files" }).click(); + return rid; +} + +test("creating a release opens it and lists it", async ({ page, peer }) => { + const rid = await fillRelease(page, peer); + await page.getByRole("button", { name: "Create release" }).click(); + + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`)); + await expect(page.getByRole("button", { name: /build\.tar/ })).toBeVisible(); + // The test peer runs no artifact node, so there is nothing to seed with. + await expect( + page.getByTitle("Your artifact node is not running"), + ).toBeDisabled(); + + await goto(page, `/repos/${rid}/releases`); + await expect(page.getByText("Add notes")).toBeVisible(); +}); + +test("a release whose seeding failed still opens", async ({ page, peer }) => { + await page.route(/\/artifact_node_running$/, route => + route.fulfill({ json: true }), + ); + await page.route(/\/seed_artifact$/, route => + route.fulfill({ + status: 500, + json: { code: "UnknownError", message: "seeding failed" }, + }), + ); + const rid = await fillRelease(page, peer); + await page.getByRole("button", { name: "Create release" }).click(); + + await expect( + page.getByText("Registered, but 1 artifact could not be seeded."), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Retry" })).toBeEnabled(); + await page.getByRole("button", { name: "Open release" }).click(); + + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`)); + await expect(page.getByRole("button", { name: /build\.tar/ })).toBeVisible(); +}); + +test("a release can be deleted", async ({ page, peer }) => { + const rid = await fillRelease(page, peer); + await page.getByRole("button", { name: "Create release" }).click(); + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`)); + + await page.getByRole("button", { name: "Delete" }).click(); + await page + .locator("[data-modal-content]") + .getByRole("button", { name: "Delete" }) + .click(); + + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases$`)); + await expect(page.getByText("No releases")).toBeVisible(); +}); + +test("re-registering an artifact only signs what changes", async ({ + page, + peer, +}) => { + const rid = await fillRelease(page, peer); + await page.getByRole("button", { name: "Create release" }).click(); + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`)); + const modal = page.locator("[data-modal-content]"); + + // The same bytes under the same name change nothing. + await page.getByRole("button", { name: "Register artifacts" }).click(); + await page.getByRole("button", { name: "Files…" }).click(); + await expect(modal.getByText("Nothing will be registered.")).toBeVisible(); + await expect( + modal.getByRole("button", { name: "icon-plus Register" }), + ).toBeHidden(); + await modal.getByRole("button", { name: "Close", exact: true }).click(); + + // The same bytes under a new name rename your own artifact. + const renamed = Path.join(peer.checkoutPath, "renamed.tar"); + await Fs.writeFile(renamed, "artifact bytes"); + await page.route(/\/pick_artifact_files$/, route => + route.fulfill({ json: [renamed] }), + ); + await page.getByRole("button", { name: "Register artifacts" }).click(); + await page.getByRole("button", { name: "Files…" }).click(); + await expect(modal.getByText("renames “build.tar”")).toBeVisible(); + await modal.getByRole("button", { name: "icon-plus Update" }).click(); + await expect( + page.getByRole("button", { name: /renamed\.tar/ }), + ).toBeVisible(); +}); + +test("picks with the same contents are registered once", async ({ + page, + peer, +}) => { + const rid = await fillRelease(page, peer); + const copy = Path.join(peer.checkoutPath, "copy.tar"); + await Fs.writeFile(copy, "artifact bytes"); + await page.route(/\/pick_artifact_files$/, route => + route.fulfill({ json: [copy] }), + ); + await page.getByRole("button", { name: "Choose files" }).click(); + await expect(page.getByText("same as “build.tar”")).toBeVisible(); + await page.getByRole("button", { name: "Create release" }).click(); + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`)); + await expect(page.getByRole("button", { name: /build\.tar/ })).toBeVisible(); + + // Two new picks with the same contents in one go. + const first = Path.join(peer.checkoutPath, "a.tar"); + const second = Path.join(peer.checkoutPath, "b.tar"); + await Fs.writeFile(first, "other bytes"); + await Fs.writeFile(second, "other bytes"); + await page.route(/\/pick_artifact_files$/, route => + route.fulfill({ json: [first, second] }), + ); + const modal = page.locator("[data-modal-content]"); + await page.getByRole("button", { name: "Register artifacts" }).click(); + await page.getByRole("button", { name: "Files…" }).click(); + await expect(modal.getByText("same as “a.tar” · skipped")).toBeVisible(); + await modal.getByRole("button", { name: "icon-plus Register" }).click(); + await expect(modal).toBeHidden(); + await expect(page.getByRole("button", { name: /a\.tar/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /b\.tar/ })).toBeHidden(); +}); + +test("a location can be added and removed", async ({ page, peer }) => { + const rid = await fillRelease(page, peer); + await page.getByRole("button", { name: "Create release" }).click(); + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`)); + await page.getByRole("button", { name: /build\.tar/ }).click(); + + const add = page.getByTitle("Add a URL others can download"); + const input = page.getByPlaceholder("https://…"); + await add.click(); + await input.fill("iroh://abc"); + await input.press("Enter"); + await expect( + page.getByText("This is not a URL peers can fetch from."), + ).toBeVisible(); + + const url = "https://example.com/build.tar"; + await input.fill(url); + await input.press("Enter"); + await expect(page.getByText(url)).toBeVisible(); + + await page.getByTitle("Remove location").click(); + await page.getByRole("button", { name: "icon-trash Remove" }).click(); + await expect(page.getByText(url)).toBeHidden(); + await expect(page.getByText("No locations")).toBeVisible(); +}); + +test("a location added by someone else cannot be removed", async ({ + page, + peer, +}) => { + const rid = await fillRelease(page, peer); + await page.getByRole("button", { name: "Create release" }).click(); + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`)); + + // The test peers run no artifact node, so another user's location is + // injected into the release the view reads. + const url = "https://example.com/theirs.tar"; + await page.route(/\/release_by_id$/, async route => { + const release = await (await route.fetch()).json(); + for (const artifact of release.artifacts) { + artifact.locations.push({ + user: { did: OTHER_DID }, + url, + }); + } + await route.fulfill({ json: release }); + }); + await reload(page); + await page.getByRole("button", { name: /build\.tar/ }).click(); + + await expect(page.getByText(url)).toBeVisible(); + await expect(page.getByTitle("Remove location")).toBeHidden(); +}); + +test("removing your node's location while seeding stops seeding", async ({ + page, + peer, +}) => { + const rid = await fillRelease(page, peer); + await page.getByRole("button", { name: "Create release" }).click(); + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`)); + await page.getByRole("button", { name: /build\.tar/ }).click(); + await page.getByTitle("Add a URL others can download").click(); + await page.getByPlaceholder("https://…").fill("radiroh://"); + const added = page.waitForRequest(/\/add_artifact_location$/); + await page.getByPlaceholder("https://…").press("Enter"); + const { cid, releaseId } = (await added).postDataJSON(); + await expect(page.getByText("radiroh://")).toBeVisible(); + + // The test peers run no artifact node, so it is made to report the + // artifact as seeded and to accept the unseed. + await page.route(/\/seeded_artifacts$/, route => + route.fulfill({ json: [cid] }), + ); + const unseed = page.waitForRequest(/\/unseed_artifact$/); + await page.route(/\/unseed_artifact$/, route => + route.fulfill({ json: null }), + ); + await reload(page); + await page.getByRole("button", { name: /build\.tar/ }).click(); + + await page.getByTitle("Remove location").click(); + await expect(page.getByText("Unseed this artifact?")).toBeVisible(); + await page.getByRole("button", { name: "icon-trash Unseed" }).click(); + expect((await unseed).postDataJSON()).toMatchObject({ releaseId, cid }); +}); + +test("a release by a non-delegate shows a warning", async ({ page, peer }) => { + const rid = await fillRelease(page, peer); + await page.getByRole("button", { name: "Create release" }).click(); + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`)); + await expect(page.getByRole("note")).toBeHidden(); + + await page.route(/\/release_by_id$/, async route => { + const release = await (await route.fetch()).json(); + release.creator.did = OTHER_DID; + await route.fulfill({ json: release }); + }); + await reload(page); + + await expect( + page.getByRole("note").getByText("Not from a delegate."), + ).toBeVisible(); +}); + +test("an artifact by a non-delegate shows a warning", async ({ + page, + peer, +}) => { + const rid = await fillRelease(page, peer); + await page.getByRole("button", { name: "Create release" }).click(); + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`)); + + // A location makes the download button usable. + await page.route(/\/release_by_id$/, async route => { + const release = await (await route.fetch()).json(); + for (const artifact of release.artifacts) { + artifact.author.did = OTHER_DID; + artifact.locations.push({ + user: { did: OTHER_DID }, + url: "https://example.com/theirs.tar", + }); + } + await route.fulfill({ json: release }); + }); + await reload(page); + + await expect( + page.getByRole("note").getByText("Not from delegates."), + ).toBeVisible(); + await page.getByRole("button", { name: "icon-download Download" }).click(); + await expect(page.getByText("Not from a delegate.")).toBeVisible(); +}); + +test("a link by a non-delegate on a delegate's artifact shows a warning", async ({ + page, + peer, +}) => { + const rid = await fillRelease(page, peer); + await page.getByRole("button", { name: "Create release" }).click(); + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`)); + + await page.route(/\/release_by_id$/, async route => { + const release = await (await route.fetch()).json(); + for (const artifact of release.artifacts) { + artifact.locations.push({ + user: { did: OTHER_DID }, + url: "https://example.com/theirs.tar", + }); + } + await route.fulfill({ json: release }); + }); + await reload(page); + + await page.getByRole("button", { name: "icon-download Download" }).click(); + await expect(page.getByText("Not from a delegate.")).toBeHidden(); + await page + .getByRole("button", { name: "icon-open-external Browser" }) + .click(); + await expect( + page.getByRole("note").getByText("Links without a delegate badge"), + ).toBeVisible(); +}); + +test("metadata can be added, edited and removed", async ({ page, peer }) => { + const rid = await fillRelease(page, peer); + await page.getByRole("button", { name: "Create release" }).click(); + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`)); + await page.getByRole("button", { name: /build\.tar/ }).click(); + const metadata = page.locator(".section").filter({ hasText: /^Metadata/ }); + + await metadata.getByRole("button", { name: "Add" }).click(); + await metadata.getByPlaceholder("Key").fill("arch"); + await metadata.getByPlaceholder("Value").fill("x86_64"); + await metadata.getByPlaceholder("Value").press("Enter"); + await expect(metadata.getByText("x86_64")).toBeVisible(); + await reload(page); + await page.getByRole("button", { name: /build\.tar/ }).click(); + await expect(metadata.getByText("x86_64")).toBeVisible(); + + await metadata.getByTitle("Edit").click(); + await metadata.getByPlaceholder("Value").fill("aarch64"); + await metadata.getByRole("button", { name: "Save" }).click(); + await expect(metadata.getByText("aarch64")).toBeVisible(); + + await metadata.getByTitle("Remove").click(); + await page.getByRole("button", { name: "icon-trash Remove" }).click(); + await expect(metadata.getByText("No metadata")).toBeVisible(); +}); + +test("an artifact can be redacted", async ({ page, peer }) => { + const rid = await fillRelease(page, peer); + await page.getByRole("button", { name: "Create release" }).click(); + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`)); + await page.getByRole("button", { name: /build\.tar/ }).click(); + + await page.getByRole("button", { name: "Redact" }).click(); + const modal = page.locator("[data-modal-content]"); + await modal + .getByPlaceholder("Why should this no longer be used?") + .fill("bad"); + await modal.getByRole("button", { name: "Redact" }).click(); + await expect(modal).toBeHidden(); + + // A redaction by the author hides the artifact by default. + await expect(page.getByText("No artifacts")).toBeVisible(); + await page.getByRole("button", { name: /Show redacted/ }).click(); + await page.getByRole("button", { name: /build\.tar/ }).click(); + await expect(page.getByText("bad", { exact: true })).toBeVisible(); +}); + +// Calls a command on the peer's test backend directly, bypassing the app. +function backend(peer: RadiclePeer) { + return async (cmd: string, args: object) => { + const { hostname, port } = peer.httpdBaseUrl; + const response = await fetch(`http://${hostname}:${port}/${cmd}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(args), + }); + expect(response.ok).toBe(true); + return response.json(); + }; +} + +test("opening another release starts from a fresh page", async ({ + page, + peer, +}) => { + const { rid, repoFolder } = await createProject(peer); + const file = Path.join(repoFolder, "build.tar"); + await Fs.writeFile(file, "artifact bytes"); + const call = backend(peer); + const releaseIds: string[] = []; + // Both releases carry the same bytes, so their artifacts share a CID. + for (const revision of ["main~1", "main"]) { + const { stdout: oid } = await peer.git(["rev-parse", revision], { + cwd: repoFolder, + }); + const releaseId: string = await call("create_or_open_release", { + rid, + oid: oid.trim(), + tag: null, + }); + const { cid, sizeBytes } = await call("compute_artifact_cid", { + path: file, + }); + await call("register_artifact", { + rid, + releaseId, + cid, + name: "build.tar", + sizeBytes, + }); + releaseIds.push(releaseId); + } + const [first, second] = releaseIds; + + await page.goto(`/repos/${rid}/releases/${first}`); + await page.getByRole("button", { name: /build\.tar/ }).click(); + const attestations = page + .locator(".section") + .filter({ hasText: /^Attestations/ }); + await expect(attestations).toBeVisible(); + + // A link to another release, as a mention renders one, navigates in place. + await page.evaluate(href => { + const link = document.createElement("a"); + link.href = href; + link.textContent = "Next release"; + document.body.append(link); + }, `/repos/${rid}/releases/${second}`); + await page.getByRole("link", { name: "Next release" }).click(); + + await expect(page.locator(".title").getByText("Add notes")).toBeVisible(); + await expect(attestations).toBeHidden(); +}); + +test("a failed refresh after registering is not reported as a failure", async ({ + page, + peer, +}) => { + const rid = await fillRelease(page, peer); + await page.getByRole("button", { name: "Create release" }).click(); + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`)); + + const other = Path.join(peer.checkoutPath, "other.tar"); + await Fs.writeFile(other, "other bytes"); + await page.route(/\/pick_artifact_files$/, route => + route.fulfill({ json: [other] }), + ); + await page.route(/\/release_by_id$/, route => + route.fulfill({ + status: 500, + json: { code: "UnknownError", message: "refresh failed" }, + }), + ); + const modal = page.locator("[data-modal-content]"); + await page.getByRole("button", { name: "Register artifacts" }).click(); + await page.getByRole("button", { name: "Files…" }).click(); + await modal.getByRole("button", { name: "icon-plus Register" }).click(); + + await expect(modal).toBeHidden(); + await expect(page.getByText("Registering failed.")).toBeHidden(); +}); + +// Bob publishes an artifact; the app runs as eve, who isn't a delegate. +async function openBobsArtifact(page: Page, peerManager: PeerManager) { + const { bob, eve } = await createCollaborators(peerManager); + const { rid, repoFolder } = await createProject(bob); + const { stdout: head } = await bob.git(["rev-parse", "main"], { + cwd: repoFolder, + }); + const file = Path.join(bob.checkoutPath, "build.tar"); + await Fs.writeFile(file, "artifact bytes"); + + await bob.startHttpd(); + const call = backend(bob); + const releaseId: string = await call("create_or_open_release", { + rid, + oid: head.trim(), + tag: null, + }); + const { cid, sizeBytes } = await call("compute_artifact_cid", { path: file }); + await call("register_artifact", { + rid, + releaseId, + cid, + name: "build.tar", + sizeBytes, + }); + + await clone(eve, rid); + await eve.startHttpd(); + await useBackend(page, eve); + await page.goto(`/repos/${rid}/releases/${releaseId}`); + await page.getByRole("button", { name: /build\.tar/ }).click(); + return eve; +} + +test("a non-delegate can attest an artifact by reproducing it", async ({ + page, + peerManager, +}) => { + const eve = await openBobsArtifact(page, peerManager); + const other = Path.join(eve.checkoutPath, "other.tar"); + const same = Path.join(eve.checkoutPath, "same.tar"); + await Fs.writeFile(other, "other bytes"); + await Fs.writeFile(same, "artifact bytes"); + const attestations = page + .locator(".section") + .filter({ hasText: /^Attestations/ }); + + await page.route(/\/pick_artifact_files$/, route => + route.fulfill({ json: [other] }), + ); + await attestations.getByRole("button", { name: /Attest/ }).click(); + await expect( + page.getByText("Your build has a different CID, so it was not attested."), + ).toBeVisible(); + + await page.route(/\/pick_artifact_files$/, route => + route.fulfill({ json: [same] }), + ); + await attestations.getByRole("button", { name: /Attest/ }).click(); + await expect(attestations.getByText("eve")).toBeVisible(); + await expect( + attestations.getByRole("button", { name: /Attest/ }), + ).toBeHidden(); +}); + +test("a non-delegate cannot redact, edit or delete someone else's release", async ({ + page, + peerManager, +}) => { + await openBobsArtifact(page, peerManager); + + await expect( + page.locator(".section").filter({ hasText: /^Attestations/ }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Redact" })).toBeHidden(); + await expect(page.getByRole("button", { name: "Delete" })).toBeHidden(); + const metadata = page.locator(".section").filter({ hasText: /^Metadata/ }); + await expect(metadata.getByRole("button", { name: "Add" })).toBeHidden(); +}); diff --git a/desktop/tests/e2e/repo/documentLinks.spec.ts b/desktop/tests/e2e/repo/documentLinks.spec.ts new file mode 100644 index 000000000..f521ce499 --- /dev/null +++ b/desktop/tests/e2e/repo/documentLinks.spec.ts @@ -0,0 +1,287 @@ +import type { Page } from "@playwright/test"; +import type { RadiclePeer } from "@tests/support/peerManager.js"; + +import * as Fs from "node:fs/promises"; +import * as Path from "node:path"; + +import * as issue from "@tests/support/cobs/issue.js"; +import { expect, reload, test } from "@tests/support/fixtures.js"; +import { createRepo } from "@tests/support/repo.js"; + +async function createLinkedRepo(peer: RadiclePeer) { + const { rid, repoFolder } = await createRepo(peer, { name: "links" }); + await Fs.mkdir(Path.join(repoFolder, "docs")); + await Fs.writeFile( + Path.join(repoFolder, "README.md"), + [ + "# Links", + "", + "- [Relative guide](docs/guide.md)", + "- [Rooted guide](/docs/guide.md)", + "- [Missing file](docs/missing.md)", + "- [Odd scheme](ftp://example.invalid/file)", + "- [Website](https://example.invalid)", + "- [Down below](#bottom)", + "- [Down below, by file](README.md#Bottom)", + "- [Guide details](docs/guide.md#Details)", + "", + ...Array.from({ length: 200 }, (_, i) => `Filler line ${i}.\n`), + "## Bottom", + "", + "The end.", + "", + ].join("\n"), + ); + await Fs.writeFile( + Path.join(repoFolder, "docs", "guide.md"), + [ + "# Guide", + "", + "Guide body.", + "", + "[Back to the readme](../README.md)", + "", + "[Repository root](/)", + "", + "[Spec](spec.adoc)", + "", + ...Array.from({ length: 200 }, (_, i) => `Guide filler ${i}.\n`), + "## Details", + "", + "The details.", + "", + ].join("\n"), + ); + await Fs.writeFile( + Path.join(repoFolder, "docs", "spec.adoc"), + [ + "= Spec", + "", + "Spec body.", + "", + "link:/README.md[Rooted readme]", + "", + "link:ftp://example.invalid/file[Odd scheme]", + "", + ].join("\n"), + ); + await peer.git(["add", "."], { cwd: repoFolder }); + await peer.git(["commit", "-m", "Link the docs"], { cwd: repoFolder }); + await peer.git(["push", "rad", "main"], { cwd: repoFolder }); + return { rid, repoFolder }; +} + +// Following a link the app doesn't handle loads another page into the window, +// which restarts the app and drops this marker. +async function markWindow(page: Page) { + await page.evaluate(() => { + (window as unknown as { linkMarker?: boolean }).linkMarker = true; + }); +} + +async function expectSameWindow(page: Page) { + expect( + await page.evaluate( + () => (window as unknown as { linkMarker?: boolean }).linkMarker, + ), + ).toBe(true); +} + +test("follow relative links between Markdown files", async ({ page, peer }) => { + const { rid } = await createLinkedRepo(peer); + await page.goto(`/repos/${rid}/home`); + await markWindow(page); + + const doc = page.locator(".markdown"); + await doc.getByRole("link", { name: "Relative guide" }).click(); + await expect(doc.getByText("Guide body.")).toBeVisible(); + await expect(page).toHaveURL(/\?path=docs(%2F|\/)guide\.md/); + + await doc.getByRole("link", { name: "Back to the readme" }).click(); + await expect(doc.getByText("The end.")).toBeVisible(); + await expect(page).toHaveURL(/\?path=README\.md/); + + await page.goBack(); + await expect(doc.getByText("Guide body.")).toBeVisible(); + await expectSameWindow(page); +}); + +test("follow links rooted at the repository", async ({ page, peer }) => { + const { rid } = await createLinkedRepo(peer); + await page.goto(`/repos/${rid}/home`); + await markWindow(page); + + const doc = page.locator(".markdown"); + await doc.getByRole("link", { name: "Rooted guide" }).click(); + await expect(doc.getByText("Guide body.")).toBeVisible(); + + await doc.getByRole("link", { name: "Repository root" }).click(); + await expect(doc.getByText("The end.")).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/home$`)); + await expectSameWindow(page); +}); + +test("show that a linked file is missing", async ({ page, peer }) => { + const { rid } = await createLinkedRepo(peer); + await page.goto(`/repos/${rid}/home`); + await markWindow(page); + + await page + .locator(".markdown") + .getByRole("link", { name: "Missing file" }) + .click(); + await expect(page).toHaveURL(/\?path=docs(%2F|\/)missing\.md/); + await expect(page.getByText(/does not exist/)).toBeVisible(); + // The readme the link was followed from isn't shown as the missing file. + await expect(page.locator(".markdown")).toHaveCount(0); + await expectSameWindow(page); +}); + +test("show a missing file in a repository without a readme", async ({ + page, + peer, +}) => { + const { rid, repoFolder } = await createRepo(peer, { name: "empty" }); + const issueId = await issue.create( + peer, + "Write the docs", + "Start with the [guide](docs/guide.md).", + [], + { cwd: repoFolder }, + ); + await page.goto(`/repos/${rid}/issues/${issueId}`); + + await page + .locator(".markdown") + .getByRole("link", { name: "guide", exact: true }) + .click(); + await expect(page).toHaveURL(/\?path=docs(%2F|\/)guide\.md/); + await expect(page.getByText(/does not exist/)).toBeVisible(); + await expect(page.getByText("No README.md")).toBeHidden(); +}); + +test("explain a link the app can't open", async ({ page, peer }) => { + const { rid } = await createLinkedRepo(peer); + await page.goto(`/repos/${rid}/home`); + await markWindow(page); + const url = page.url(); + + await page + .locator(".markdown") + .getByRole("link", { name: "Odd scheme" }) + .click(); + await expect(page.getByText("Can't open link")).toBeVisible(); + await expect(page.getByText("ftp://example.invalid/file")).toBeVisible(); + expect(page.url()).toBe(url); + await expectSameWindow(page); +}); + +test("open external links outside the app", async ({ page, peer }) => { + const { rid } = await createLinkedRepo(peer); + await page.goto(`/repos/${rid}/home`); + + await expect( + page.locator(".markdown").getByRole("link", { name: "Website" }), + ).toHaveAttribute("target", "_blank"); +}); + +test("scroll to a heading in the same document", async ({ page, peer }) => { + const { rid } = await createLinkedRepo(peer); + await page.goto(`/repos/${rid}/home`); + await markWindow(page); + const url = page.url(); + + const doc = page.locator(".markdown"); + const heading = doc.getByRole("heading", { name: "Bottom" }); + await expect(heading).not.toBeInViewport(); + + await doc.getByRole("link", { name: "Down below", exact: true }).click(); + await expect(heading).toBeInViewport(); + expect(page.url()).toBe(url); + await expectSameWindow(page); + + await reload(page); + await markWindow(page); + await expect(heading).not.toBeInViewport(); + await doc.getByRole("link", { name: "Down below, by file" }).click(); + await expect(heading).toBeInViewport(); + expect(page.url()).toBe(url); + await expectSameWindow(page); +}); + +test("open a file at the section a link points to", async ({ page, peer }) => { + const { rid } = await createLinkedRepo(peer); + await page.goto(`/repos/${rid}/home`); + await markWindow(page); + + const doc = page.locator(".markdown"); + await doc.getByRole("link", { name: "Guide details" }).click(); + const heading = doc.getByRole("heading", { name: "Details" }); + await expect(heading).toBeInViewport(); + await expect(page).toHaveURL(/\?path=docs(%2F|\/)guide\.md#Details$/); + + await page.goBack(); + await expect(doc.getByText("The end.")).toBeVisible(); + await page.goForward(); + await expect(heading).toBeInViewport(); + await expectSameWindow(page); +}); + +test("follow links from AsciiDoc to Markdown", async ({ page, peer }) => { + const { rid } = await createLinkedRepo(peer); + await page.goto(`/repos/${rid}/home`); + await markWindow(page); + + const doc = page.locator(".markdown"); + await doc.getByRole("link", { name: "Relative guide" }).click(); + await doc.getByRole("link", { name: "Spec" }).click(); + await expect(page.locator(".asciidoc").getByText("Spec body.")).toBeVisible(); + + await page + .locator(".asciidoc") + .getByRole("link", { name: "Rooted readme" }) + .click(); + await expect(page.locator(".markdown").getByText("The end.")).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/home`)); + + await page.goBack(); + await page + .locator(".asciidoc") + .getByRole("link", { name: "Odd scheme" }) + .click(); + await expect(page.getByText("Can't open link")).toBeVisible(); + await expectSameWindow(page); +}); + +test("open a file linked from an issue", async ({ page, peer }) => { + const { rid, repoFolder } = await createLinkedRepo(peer); + const issueId = await issue.create( + peer, + "Improve the docs", + "The [guide](docs/guide.md) needs a section on links, next to its [details](docs/guide.md#details).", + [], + { cwd: repoFolder }, + ); + await page.goto(`/repos/${rid}/issues/${issueId}`); + await markWindow(page); + + await page + .locator(".markdown") + .getByRole("link", { name: "guide", exact: true }) + .click(); + await expect( + page.locator(".markdown").getByText("Guide body."), + ).toBeVisible(); + await expect(page).toHaveURL(/\?path=docs(%2F|\/)guide\.md/); + await expectSameWindow(page); + + await page.goBack(); + await page + .locator(".markdown") + .getByRole("link", { name: "details", exact: true }) + .click(); + await expect( + page.locator(".markdown").getByRole("heading", { name: "Details" }), + ).toBeInViewport(); + await expectSameWindow(page); +}); diff --git a/desktop/tests/e2e/repo/fileDrop.spec.ts b/desktop/tests/e2e/repo/fileDrop.spec.ts new file mode 100644 index 000000000..d09cb4b31 --- /dev/null +++ b/desktop/tests/e2e/repo/fileDrop.spec.ts @@ -0,0 +1,125 @@ +import type { JSHandle, Locator, Page } from "@playwright/test"; +import type { RadiclePeer } from "@tests/support/peerManager.js"; + +import { createProject } from "@tests/support/collaboration.js"; +import { expect, test, waitForCommand } from "@tests/support/fixtures.js"; + +const onePixelPng = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + +async function openIssue(page: Page, peer: RadiclePeer) { + const { rid, issueId } = await createProject(peer); + await page.goto(`/repos/${rid}/issues/${issueId}`); + await expect(page.getByText("Nobody reads them.")).toBeVisible(); + return rid; +} + +function fileTransfer( + page: Page, + name: string, +): Promise> { + return page.evaluateHandle(name => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(new File(["loud"], name, { type: "text/plain" })); + return dataTransfer; + }, name); +} + +async function dropFile(target: Locator, dataTransfer: JSHandle) { + for (const type of ["dragenter", "dragover", "drop"]) { + await target.dispatchEvent(type, { dataTransfer }); + } +} + +test("a dropped file only goes into the textarea under it", async ({ + page, + peer, +}) => { + await openIssue(page, peer); + + await page.getByText("Nobody reads them.").hover(); + await page.getByTitle("Edit description").click(); + const description = page.getByPlaceholder("Leave your comment"); + await expect(description).toHaveValue("Nobody reads them."); + + const composer = page.getByPlaceholder("Leave a comment"); + await waitForCommand(page, "save_embed_by_bytes", () => + fileTransfer(page, "notes.txt").then(transfer => + dropFile(composer, transfer), + ), + ); + await expect(composer).toHaveValue(/\[notes\.txt\]\(\w+\)/); + await expect(description).toHaveValue("Nobody reads them."); +}); + +test("a file dropped while previewing is refused", async ({ page, peer }) => { + await openIssue(page, peer); + let uploads = 0; + page.on("request", request => { + if (new URL(request.url()).pathname === "/save_embed_by_bytes") { + uploads++; + } + }); + + await page.getByPlaceholder("Leave a comment").fill("Draft"); + const composer = page.getByLabel("extended-textarea"); + await composer.getByRole("button", { name: "Preview" }).click(); + await dropFile(composer, await fileTransfer(page, "notes.txt")); + + await composer.getByRole("button", { name: "Edit" }).click(); + await expect(page.getByPlaceholder("Leave a comment")).toHaveValue("Draft"); + expect(uploads).toBe(0); +}); + +test("the drop highlight clears once the file leaves", async ({ + page, + peer, +}) => { + await openIssue(page, peer); + const transfer = await fileTransfer(page, "notes.txt"); + const composer = page.getByPlaceholder("Leave a comment"); + const highlight = page.getByText("Drop files to add them as embeds."); + + await composer.dispatchEvent("dragenter", { dataTransfer: transfer }); + await expect(highlight).toBeVisible(); + // The highlight covers the textarea, so it is entered before that is left. + await highlight.dispatchEvent("dragenter", { dataTransfer: transfer }); + await composer.dispatchEvent("dragleave", { dataTransfer: transfer }); + await expect(highlight).toBeVisible(); + + await highlight.dispatchEvent("dragleave", { dataTransfer: transfer }); + await expect(highlight).toBeHidden(); +}); + +test("a pasted image is stored as the image itself", async ({ page, peer }) => { + const rid = await openIssue(page, peer); + const composer = page.getByPlaceholder("Leave a comment"); + + await waitForCommand(page, "save_embed_by_bytes", () => + composer.evaluate((textarea, png) => { + const clipboardData = new DataTransfer(); + const bytes = Uint8Array.from(atob(png), c => c.charCodeAt(0)); + clipboardData.items.add( + new File([bytes], "image.png", { type: "image/png" }), + ); + textarea.dispatchEvent( + new ClipboardEvent("paste", { + clipboardData, + bubbles: true, + cancelable: true, + }), + ); + }, onePixelPng), + ); + await expect(composer).toHaveValue(/\[image\.png\]\(\w+\)/); + + const oid = /\[image\.png\]\((\w+)\)/.exec(await composer.inputValue())?.[1]; + const response = await page.request.post( + `http://127.0.0.1:${peer.httpdBaseUrl.port}/get_embed`, + { data: { rid, oid, name: "image.png" } }, + ); + const body = await response.body(); + const end = body.indexOf(0); + expect(body.subarray(0, end).toString()).toBe("image/png"); + expect(body.subarray(end + 1)).toEqual(Buffer.from(onePixelPng, "base64")); +}); diff --git a/desktop/tests/e2e/repo/issue.spec.ts b/desktop/tests/e2e/repo/issue.spec.ts new file mode 100644 index 000000000..074aa6c76 --- /dev/null +++ b/desktop/tests/e2e/repo/issue.spec.ts @@ -0,0 +1,176 @@ +import { cobRid, expect, test } from "@tests/support/fixtures.js"; + +test("navigate single issue", async ({ page }) => { + await page.goto(`/repos/${cobRid}/issues?status=all`); + await page.getByText("This title has **markdown**").click(); + + await expect(page).toHaveURL(/\/issues\/[0-9a-f]{40}/); +}); + +test("correct order of threads", async ({ page }) => { + await page.goto("/inbox"); + await page.getByRole("link", { name: "cobs" }).click(); + await page.getByRole("link", { name: "Issues" }).click(); + await page.getByText("This title has **markdown**").click(); + const body = page.locator(".description-body"); + await expect(body.getByText("This is a description")).toBeVisible(); + + const topLevelComments = await page.locator(".comments").all(); + expect(topLevelComments).toHaveLength(2); + + const [first, second] = topLevelComments; + await expect(first.getByText("This is a multiline comment")).toBeVisible(); + await expect( + first.getByText("This is a reply, to a first comment"), + ).toBeVisible(); + await expect( + second.getByText("A root level comment after a reply, for margins sake."), + ).toBeVisible(); +}); + +test("creation of top level comments", async ({ page }) => { + await page.goto("/inbox"); + await page.getByRole("link", { name: "cobs" }).click(); + await page.getByRole("link", { name: "Issues" }).click(); + await page.getByRole("button", { name: "New" }).click(); + await page + .getByPlaceholder("Title") + .fill("Make sure that comment creation is working"); + await page + .getByPlaceholder("Description") + .fill( + "It's important for us that the comment creation flow works as expected.", + ); + await page.getByRole("button", { name: /^Save/ }).click(); + await expect( + page.getByRole("button", { + name: "Make sure that comment creation is working", + }), + ).toBeVisible(); + await expect( + page.getByText( + "It's important for us that the comment creation flow works as expected.", + ), + ).toBeVisible(); + + await page + .getByPlaceholder("Leave a comment") + .fill("A top level comment by playwright"); + await page.getByRole("button", { name: /^Comment/ }).click(); + await expect( + page.getByText("A top level comment by playwright"), + ).toBeVisible(); + + await page.getByLabel("icon-reply").first().click(); + await page + .getByPlaceholder("Reply to comment") + .fill( + "A top level comment by playwright created by replying to the issue body", + ); + await page.getByRole("button", { name: /^Reply/ }).click(); + await expect( + page.getByText( + "A top level comment by playwright created by replying to the issue body", + ), + ).toBeVisible(); + + await page.getByLabel("icon-reply").click(); + await page + .getByPlaceholder("Reply to comment") + .fill("A reply comment by playwright replying to the first comment"); + await page.getByRole("button", { name: /^Reply/ }).click(); + await expect( + page.getByText( + "A reply comment by playwright replying to the first comment", + ), + ).toBeVisible(); +}); + +test("create issue with only a title", async ({ page }) => { + await page.goto(`/repos/${cobRid}/issues`); + await page.getByRole("button", { name: "New" }).click(); + const save = page.getByRole("button", { name: /^Save/ }); + await expect(save).toBeDisabled(); + + await page.getByPlaceholder("Title").fill("An issue without a description"); + await expect(save).toBeEnabled(); + await save.click(); + + await expect(page).toHaveURL(/\/issues\/[0-9a-f]{40}/); + await expect( + page.getByRole("button", { name: "An issue without a description" }), + ).toBeVisible(); +}); + +test("create issue with only a title via shortcut", async ({ page }) => { + await page.goto(`/repos/${cobRid}/issues`); + await page.getByRole("button", { name: "New" }).click(); + await page + .getByPlaceholder("Title") + .fill("An issue without a description via shortcut"); + await page.getByPlaceholder("Title").press("ControlOrMeta+Enter"); + + await expect(page).toHaveURL(/\/issues\/[0-9a-f]{40}/); + await expect( + page.getByRole("button", { + name: "An issue without a description via shortcut", + }), + ).toBeVisible(); +}); + +test("shortcut does not create issue without a title", async ({ page }) => { + await page.goto(`/repos/${cobRid}/issues`); + await page.getByRole("button", { name: "New" }).click(); + const url = page.url(); + + await page.getByPlaceholder("Title").press("ControlOrMeta+Enter"); + const description = page.getByPlaceholder("Description"); + await description.fill("A description without a title"); + await description.press("ControlOrMeta+Enter"); + + await page.getByRole("button", { name: "Preview" }).click(); + await page.keyboard.press("ControlOrMeta+Enter"); + + await expect(page.getByText("No title.")).toBeVisible(); + await expect(page.getByRole("button", { name: /^Save/ })).toBeDisabled(); + expect(page.url()).toBe(url); +}); + +test("shortcut does not submit an empty comment", async ({ page }) => { + await page.goto(`/repos/${cobRid}/issues?status=all`); + await page.getByText("This title has **markdown**").click(); + const comments = page.locator(".comments"); + await expect(comments).toHaveCount(2); + + const input = page.getByPlaceholder("Leave a comment"); + await input.fill(" "); + await input.press("ControlOrMeta+Enter"); + + await expect(input).toHaveValue(" "); + await expect(comments).toHaveCount(2); +}); + +test("each code block gets one copy button", async ({ page }) => { + await page.goto(`/repos/${cobRid}/issues`); + await page.getByRole("button", { name: "New" }).click(); + await page.getByPlaceholder("Title").fill("Code blocks in two places"); + await page + .getByPlaceholder("Description") + .fill("First:\n\n```js\nconsole.log('one')\n```"); + await page.getByRole("button", { name: /^Save/ }).click(); + await expect( + page.getByRole("button", { name: "Code blocks in two places" }), + ).toBeVisible(); + + // A second Markdown block on the page used to wrap the first one's code + // again. + await page + .getByPlaceholder("Leave a comment") + .fill("Second:\n\n```js\nconsole.log('two')\n```"); + await page.getByRole("button", { name: /^Comment/ }).click(); + + await expect(page.locator("pre")).toHaveCount(2); + await expect(page.locator(".pre-wrapper")).toHaveCount(2); + await expect(page.locator(".pre-wrapper radicle-clipboard")).toHaveCount(2); + await expect(page.locator(".pre-wrapper .pre-wrapper")).toHaveCount(0); +}); diff --git a/desktop/tests/e2e/repo/issueEditing.spec.ts b/desktop/tests/e2e/repo/issueEditing.spec.ts new file mode 100644 index 000000000..dee497f8f --- /dev/null +++ b/desktop/tests/e2e/repo/issueEditing.spec.ts @@ -0,0 +1,190 @@ +import type { Page } from "@playwright/test"; +import type { RadiclePeer } from "@tests/support/peerManager.js"; + +import { createProject } from "@tests/support/collaboration.js"; +import { + expect, + reload, + test, + waitForCommand, +} from "@tests/support/fixtures.js"; + +// Items in the menu behind a comment's "Comment actions" button. +async function commentAction(page: Page, body: string, action: string) { + await page.getByText(body).hover(); + const toggle = page.getByTitle("Comment actions").last(); + await toggle.click(); + await toggle + .locator( + `xpath=following::*[@role="button"][normalize-space()="${action}"][1]`, + ) + .click(); +} + +async function openIssue(page: Page, peer: RadiclePeer) { + const { rid, issueId } = await createProject(peer); + await page.goto(`/repos/${rid}/issues/${issueId}`); + await expect( + page.getByRole("button", { name: "The notes are too quiet" }), + ).toBeVisible(); + return { rid, issueId }; +} + +test("labels can be added and removed", async ({ page, peer }) => { + await openIssue(page, peer); + + await page.getByRole("button", { name: "Add labels" }).click(); + await page.getByPlaceholder("Add label").fill("documentation"); + await waitForCommand(page, "edit_issue", () => + page.getByPlaceholder("Add label").press("Enter"), + ); + const label = page.getByRole("button", { name: "documentation" }); + await expect(label).toBeVisible(); + + await page.getByRole("button", { name: "Add labels" }).click(); + await page.getByPlaceholder("Add label").fill("documentation"); + await expect(page.getByText("This label is already assigned")).toBeVisible(); + await page.getByPlaceholder("Add label").fill(""); + + await reload(page); + await expect(label).toBeVisible(); + + await label.click(); + await waitForCommand(page, "edit_issue", () => + label.getByRole("img", { name: "icon-close" }).click(), + ); + await expect(label).toBeHidden(); + await reload(page); + await expect(label).toBeHidden(); +}); + +test("an assignee can be added by DID", async ({ page, peer }) => { + await openIssue(page, peer); + + await page.getByRole("button", { name: "Add assignees" }).click(); + const input = page.getByPlaceholder(/^Alias or DID/); + await input.fill("did:key:alice"); + await expect(page.getByText("This is not a valid DID")).toBeVisible(); + + await input.fill(`did:key:${peer.nodeId}`); + await waitForCommand(page, "edit_issue", () => input.press("Enter")); + await expect(input).toBeHidden(); + + const assignees = page.getByRole("button", { + name: "icon-avatar-incognito Assignees", + exact: true, + }); + await expect(assignees).toBeVisible(); + await reload(page); + await assignees.click(); + await page.getByPlaceholder(/^Alias or DID/).fill(peer.nodeId); + await expect(page.getByText("This assignee is already added")).toBeVisible(); +}); + +test("an assignee can be picked by alias", async ({ page, peer }) => { + await openIssue(page, peer); + + await page.getByRole("button", { name: "Add assignees" }).click(); + const input = page.getByPlaceholder(/^Alias or DID/); + await input.fill("zzz"); + await expect(page.getByText("No one matches “zzz”")).toBeVisible(); + await input.fill("ali"); + const suggestion = page.locator(".suggestion", { hasText: "alice" }); + await expect(suggestion).toContainText("you"); + await waitForCommand(page, "edit_issue", () => input.press("Enter")); + await expect(input).toBeHidden(); + + const assignees = page.getByRole("button", { + name: "icon-avatar-incognito Assignees", + exact: true, + }); + await reload(page); + await assignees.click(); + await page.getByPlaceholder(/^Alias or DID/).fill("alice"); + await expect(page.getByText("This assignee is already added")).toBeVisible(); +}); + +test("an issue can be closed and reopened", async ({ page, peer }) => { + await openIssue(page, peer); + + await page.getByRole("button", { name: /^icon-issue Open/ }).click(); + await page.getByRole("button", { name: /Closed as solved$/ }).click(); + const closed = page.getByRole("button", { name: /Closed as solved/ }); + await expect(closed).toBeVisible(); + + await reload(page); + await expect(closed).toBeVisible(); + + await closed.click(); + await page + .getByRole("button", { name: "icon-issue Open", exact: true }) + .click(); + await expect(closed).toBeHidden(); +}); + +test("the title can be edited", async ({ page, peer }) => { + await openIssue(page, peer); + + await page.getByRole("button", { name: "The notes are too quiet" }).click(); + const input = page.locator("input:focus"); + await input.fill("The notes are much too quiet"); + await input.press("Enter"); + + const title = page.getByRole("button", { + name: "The notes are much too quiet", + }); + await expect(title).toBeVisible(); + await reload(page); + await expect(title).toBeVisible(); +}); + +test("a comment can be edited and deleted", async ({ page, peer }) => { + await openIssue(page, peer); + + await page.getByPlaceholder("Leave a comment").fill("I read them daily"); + await page.getByRole("button", { name: /^Comment/ }).click(); + await expect(page.getByText("I read them daily")).toBeVisible(); + + await commentAction(page, "I read them daily", "Edit"); + await page + .getByPlaceholder("Leave a comment") + .first() + .fill("I read them weekly"); + await page.getByRole("button", { name: /^Save/ }).click(); + await expect(page.getByText("I read them weekly")).toBeVisible(); + + await reload(page); + await expect(page.getByText("I read them weekly")).toBeVisible(); + + await commentAction(page, "I read them weekly", "Delete"); + await expect(page.getByText("I read them weekly")).toBeHidden(); + await reload(page); + await expect(page.getByText("I read them weekly")).toBeHidden(); +}); + +test("an issue can be deleted", async ({ page, peer }) => { + const { rid } = await openIssue(page, peer); + + await page.getByTitle("Delete issue from your node").click(); + await page + .getByRole("button", { name: "icon-trash Delete", exact: true }) + .last() + .click(); + + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/issues`)); + // The list renders with its items, so once its filters show it's loaded. + await expect(page.getByRole("link", { name: /Closed/ })).toBeVisible(); + await expect(page.getByText("The notes are too quiet")).toBeHidden(); +}); + +test("a reaction can be added to the description", async ({ page, peer }) => { + await openIssue(page, peer); + + await page.getByText("Nobody reads them.").hover(); + await page.getByTitle("React").first().click(); + await waitForCommand(page, "edit_issue", () => + page.getByRole("button", { name: "👍" }).click(), + ); + await reload(page); + await expect(page.getByTitle("Remove reaction")).toBeVisible(); +}); diff --git a/desktop/tests/e2e/repo/issues.spec.ts b/desktop/tests/e2e/repo/issues.spec.ts new file mode 100644 index 000000000..00ba90c8b --- /dev/null +++ b/desktop/tests/e2e/repo/issues.spec.ts @@ -0,0 +1,8 @@ +import { cobRid, expect, test } from "@tests/support/fixtures.js"; + +test("navigate issues listing", async ({ page }) => { + await page.goto(`/repos/${cobRid}/issues?status=all`); + await page.getByRole("link", { name: "Closed" }).click(); + await expect(page.locator(".issue-teaser")).toHaveCount(2); + await expect(page).toHaveURL(`/repos/${cobRid}/issues?status=closed`); +}); diff --git a/desktop/tests/e2e/repo/jobs.spec.ts b/desktop/tests/e2e/repo/jobs.spec.ts new file mode 100644 index 000000000..d729226fb --- /dev/null +++ b/desktop/tests/e2e/repo/jobs.spec.ts @@ -0,0 +1,80 @@ +import type { RadiclePeer } from "@tests/support/peerManager.js"; + +import { expect, test } from "@tests/support/fixtures.js"; +import { createRepo } from "@tests/support/repo.js"; +import { radJobBinary } from "@tests/support/support.js"; + +async function recordRun( + peer: RadiclePeer, + repoFolder: string, + head: string, + log: string, + outcome: "succeeded" | "failed" | "started", +) { + const job = (args: string[]) => + peer.spawn(radJobBinary, ["--no-sync", ...args], { cwd: repoFolder }); + const { stdout: runId } = await job(["run", head, log]); + if (outcome !== "started") { + await job([outcome, head, runId.trim()]); + } +} + +test("CI runs are summarised and link to their logs", async ({ + page, + peer, +}) => { + const { rid, repoFolder } = await createRepo(peer, { name: "ci" }); + const { stdout: head } = await peer.git(["rev-parse", "HEAD"], { + cwd: repoFolder, + }); + await peer.spawn(radJobBinary, ["--no-sync", "new", head.trim()], { + cwd: repoFolder, + }); + await recordRun( + peer, + repoFolder, + head.trim(), + "https://ci.example.com/runs/passing", + "succeeded", + ); + await recordRun( + peer, + repoFolder, + head.trim(), + "https://github.com/o/r/actions/runs/12345/job/1", + "failed", + ); + + await page.goto(`/repos/${rid}/home`); + const summary = page.getByRole("button", { name: /1 passed · 1 failed/ }); + await expect(summary).toBeVisible(); + + await summary.click(); + await expect(page.getByRole("link", { name: /run 12345/ })).toHaveAttribute( + "href", + "https://github.com/o/r/actions/runs/12345/job/1", + ); + await expect( + page.locator('a[href="https://ci.example.com/runs/passing"]'), + ).toBeVisible(); +}); + +test("a running job is shown as running", async ({ page, peer }) => { + const { rid, repoFolder } = await createRepo(peer, { name: "ci" }); + const { stdout: head } = await peer.git(["rev-parse", "HEAD"], { + cwd: repoFolder, + }); + await peer.spawn(radJobBinary, ["--no-sync", "new", head.trim()], { + cwd: repoFolder, + }); + await recordRun( + peer, + repoFolder, + head.trim(), + "https://ci.example.com/runs/slow", + "started", + ); + + await page.goto(`/repos/${rid}/home`); + await expect(page.getByRole("button", { name: /1 running/ })).toBeVisible(); +}); diff --git a/desktop/tests/e2e/repo/markdownRendering.spec.ts b/desktop/tests/e2e/repo/markdownRendering.spec.ts new file mode 100644 index 000000000..38b0ac1da --- /dev/null +++ b/desktop/tests/e2e/repo/markdownRendering.spec.ts @@ -0,0 +1,147 @@ +import type { RadiclePeer } from "@tests/support/peerManager.js"; + +import * as Fs from "node:fs/promises"; +import * as Path from "node:path"; +import * as Zlib from "node:zlib"; + +import { expect, test } from "@tests/support/fixtures.js"; +import { createRepo } from "@tests/support/repo.js"; + +function chunk(type: string, data: Buffer): Buffer { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, "latin1"), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(Zlib.crc32(body)); + return Buffer.concat([length, body, crc]); +} + +// A blank PNG saved at 144 DPI, the way macOS saves Retina screenshots. +function retinaPng(width: number, height: number): Buffer { + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header.set([8, 2, 0, 0, 0], 8); + const density = Buffer.alloc(9); + density.writeUInt32BE(5669, 0); + density.writeUInt32BE(5669, 4); + density[8] = 1; + const rows = Buffer.alloc((width * 3 + 1) * height); + + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", header), + chunk("pHYs", density), + chunk("IDAT", Zlib.deflateSync(rows)), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +const logo = + '\n'; + +async function createRichRepo(peer: RadiclePeer) { + const { rid, repoFolder } = await createRepo(peer, { name: "rich" }); + await Fs.mkdir(Path.join(repoFolder, "images")); + await Fs.mkdir(Path.join(repoFolder, "docs")); + await Fs.writeFile(Path.join(repoFolder, "images", "logo.svg"), logo); + await Fs.writeFile( + Path.join(repoFolder, "images", "shot.png"), + retinaPng(64, 8), + ); + await Fs.writeFile( + Path.join(repoFolder, "README.md"), + [ + "# Rich", + "", + "> [!NOTE]", + "> Read the guide first.", + "", + // A `%` that doesn't start an escape must not stop the rest of the + // document from being enhanced. + "![Half](50%.png)", + "", + "![Logo](images/logo.svg)", + "", + "![Screenshot](./images/shot.png)", + "", + "```mermaid", + "graph TD; Start-->Finish", + "```", + "", + ].join("\n"), + ); + await Fs.writeFile( + Path.join(repoFolder, "docs", "guide.adoc"), + ["= Guide", "", "image::../images/logo.svg[Guide logo]", ""].join("\n"), + ); + await peer.git(["add", "."], { cwd: repoFolder }); + await peer.git(["commit", "-m", "Write a rich readme"], { cwd: repoFolder }); + await peer.git(["push", "rad", "main"], { cwd: repoFolder }); + return { rid }; +} + +test("render alerts, diagrams and repository images in a readme", async ({ + page, + peer, +}) => { + const { rid } = await createRichRepo(peer); + await page.goto(`/repos/${rid}/home`); + + const readme = page.locator(".markdown"); + await expect( + readme.locator(".alert-note").getByText("Read the guide first."), + ).toBeVisible(); + await expect(readme.locator(".mermaid-diagram svg")).toBeVisible(); + await expect(readme.locator(".mermaid-diagram")).toContainText("Finish"); + + const logoImage = readme.getByRole("img", { name: "Logo" }); + await expect(logoImage).toHaveAttribute("src", /^blob:/); + await expect + .poll(() => + logoImage.evaluate(image => (image as HTMLImageElement).naturalWidth), + ) + .toBe(40); + + const screenshot = readme.getByRole("img", { name: "Screenshot" }); + await expect(screenshot).toHaveAttribute("src", /^blob:/); + await expect + .poll(() => + screenshot.evaluate(image => image.getBoundingClientRect().width), + ) + .toBe(32); +}); + +test("redraw a diagram when the theme changes", async ({ page, peer }) => { + const { rid } = await createRichRepo(peer); + await page.goto(`/repos/${rid}/home`); + + const diagram = page.locator(".mermaid-diagram svg"); + await expect(diagram).toBeVisible(); + const darkId = await diagram.getAttribute("id"); + + await page.getByRole("button", { name: "Settings" }).click(); + await page.getByRole("button", { name: "Light", exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + + await expect(diagram).not.toHaveAttribute("id", darkId ?? ""); + await expect(page.locator(".mermaid-diagram")).toHaveCount(1); +}); + +test("show repository images in an AsciiDoc file", async ({ page, peer }) => { + const { rid } = await createRichRepo(peer); + await page.goto(`/repos/${rid}/home`); + + await page.getByText("docs", { exact: true }).click(); + await page.getByText("guide.adoc", { exact: true }).click(); + + const image = page.locator(".asciidoc").getByRole("img", { + name: "Guide logo", + }); + await expect(image).toHaveAttribute("src", /^blob:/); + await expect + .poll(() => + image.evaluate(element => (element as HTMLImageElement).naturalWidth), + ) + .toBe(40); +}); diff --git a/desktop/tests/e2e/repo/mentions.spec.ts b/desktop/tests/e2e/repo/mentions.spec.ts new file mode 100644 index 000000000..c9ecd4ce1 --- /dev/null +++ b/desktop/tests/e2e/repo/mentions.spec.ts @@ -0,0 +1,343 @@ +import type { Page } from "@playwright/test"; +import type { RadiclePeer } from "@tests/support/peerManager.js"; + +import * as Fs from "node:fs/promises"; +import * as Path from "node:path"; + +import { + createCollaborators, + createProject, +} from "@tests/support/collaboration.js"; +import { + expect, + goto, + reload, + test, + useBackend, + waitForCommand, +} from "@tests/support/fixtures.js"; +import { createRepo } from "@tests/support/repo.js"; + +async function openIssue(page: Page, peer: RadiclePeer) { + const project = await createProject(peer); + await page.goto(`/repos/${project.rid}/issues/${project.issueId}`); + await expect( + page.getByRole("button", { name: "The notes are too quiet" }), + ).toBeVisible(); + return project; +} + +function commentBox(page: Page) { + return page.getByPlaceholder("Leave a comment"); +} + +async function type(page: Page, text: string) { + await commentBox(page).click(); + await page.keyboard.insertText(text); +} + +function suggestion(page: Page, text: string) { + return page.locator(".suggestion", { hasText: text }); +} + +async function comment(page: Page) { + await waitForCommand(page, "create_issue_comment", () => + page.getByRole("button", { name: /^Comment/ }).click(), + ); +} + +function rendered(page: Page) { + return page.locator(".markdown"); +} + +test("mentioning yourself inserts your DID", async ({ page, peer }) => { + await openIssue(page, peer); + + await type(page, "@ali"); + await expect(suggestion(page, "alice").first()).toContainText("you"); + await page.keyboard.press("Enter"); + await expect(commentBox(page)).toHaveValue( + `[@alice](did:key:${peer.nodeId}) `, + ); + + await comment(page); + await reload(page); + await expect( + rendered(page).locator(".mention-node", { hasText: "alice" }), + ).toBeVisible(); +}); + +test("a pasted patch URI becomes a chip that opens the patch", async ({ + page, + peer, +}) => { + const { rid, patchId } = await openIssue(page, peer); + const uri = `${rid}/cob/xyz.radicle.patch/${patchId}`; + + await type(page, uri); + await expect(suggestion(page, "Shout the third line")).toBeVisible(); + await page.keyboard.press("Enter"); + await expect(commentBox(page)).toHaveValue(`[Shout the third line](${uri}) `); + + await comment(page); + await reload(page); + const chip = rendered(page).locator("a.mention", { + hasText: "Shout the third line", + }); + await expect(chip).toBeVisible(); + await chip.click(); + await expect(page).toHaveURL(new RegExp(`/patches/${patchId}`)); +}); + +test("a pasted release ID becomes a chip that opens the release", async ({ + page, + peer, +}) => { + const { rid, issueId, repoFolder } = await openIssue(page, peer); + const file = Path.join(repoFolder, "build.tar"); + await Fs.writeFile(file, "artifact bytes"); + // The native file dialog only exists under Tauri. + await page.route(/\/pick_artifact_files$/, route => + route.fulfill({ json: [file] }), + ); + await goto(page, `/repos/${rid}/releases`); + await page.getByRole("button", { name: "New release" }).click(); + await page.getByPlaceholder("Pick a commit or paste a SHA").click(); + await page.getByText("Add notes").click(); + await page.getByRole("button", { name: "Choose files" }).click(); + await page.getByRole("button", { name: "Create release" }).click(); + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`)); + const releaseId = new URL(page.url()).pathname.split("/").pop()!; + const uri = `${rid}/cob/dev.radicle.artifact/${releaseId}`; + + await goto(page, `/repos/${rid}/issues/${issueId}`); + await type(page, "#Add"); + await expect(suggestion(page, "Add notes")).toBeVisible(); + await commentBox(page).fill(""); + + await type(page, releaseId); + await expect(suggestion(page, "Add notes")).toBeVisible(); + await page.keyboard.press("Enter"); + await expect(commentBox(page)).toHaveValue(`[Add notes](${uri}) `); + + await comment(page); + await reload(page); + const chip = rendered(page).locator("a.mention", { hasText: "Add notes" }); + await expect(chip).toBeVisible(); + await chip.click(); + await expect(page).toHaveURL(new RegExp(`/releases/${releaseId}`)); +}); + +test("unsafe and invalid references stay inert", async ({ page, peer }) => { + const { rid } = await openIssue(page, peer); + + await commentBox(page).fill( + `[click me](javascript:alert(1)) and ${rid}/bogus here`, + ); + await comment(page); + await reload(page); + + const link = rendered(page).locator("a", { hasText: "click me" }); + await expect(link).toBeVisible(); + expect(await link.getAttribute("href")).toBeNull(); + await expect(rendered(page).getByText(`${rid}/bogus here`)).toBeVisible(); + await expect(rendered(page).locator("a.mention")).toHaveCount(0); +}); + +test("a typed commit prefix expands to the commit", async ({ page, peer }) => { + const { rid, repoFolder } = await openIssue(page, peer); + const { stdout: head } = await peer.git(["rev-parse", "main"], { + cwd: repoFolder, + }); + + await type(page, `#${head.slice(0, 7)}`); + await expect(suggestion(page, "Add notes")).toBeVisible(); + await page.keyboard.press("Enter"); + await expect(commentBox(page)).toHaveValue( + `[Add notes](${rid}/commit/${head}) `, + ); + + await comment(page); + await reload(page); + await rendered(page).locator("a.mention", { hasText: "Add notes" }).click(); + await expect(page).toHaveURL(new RegExp(`/commits/${head}`)); +}); + +test("a pasted explorer file URL opens the file in-app", async ({ + page, + peer, +}) => { + const { rid } = await openIssue(page, peer); + const url = `https://radicle.network/nodes/seed.radicle.xyz/${rid}/tree/main/notes.txt#L3`; + const uri = `${rid}/commit/main?path=notes.txt#L3`; + + await type(page, url); + await expect(suggestion(page, "notes.txt")).toBeVisible(); + await page.keyboard.press("Enter"); + await expect(commentBox(page)).toHaveValue( + `[project: notes.txt#L3](${uri}) `, + ); + + await comment(page); + await reload(page); + const link = rendered(page).locator("a", { + hasText: "project: notes.txt#L3", + }); + await link.click({ button: "right" }); + await expect( + page.getByRole("menuitem", { name: "Open in radicle.network" }), + ).toHaveAttribute( + "href", + `https://radicle.network/nodes/rosa.radicle.network/${rid}/tree/main/notes.txt#L3`, + ); + await expect( + page.getByRole("menuitem", { name: "Copy rad: URI" }), + ).toBeVisible(); + await page.keyboard.press("Escape"); + + await link.click(); + await expect(page).toHaveURL(/\/home\/main\?path=notes\.txt/); + await expect(page.locator("code", { hasText: "three" })).toBeVisible(); +}); + +test("every reference type renders and links where it should", async ({ + page, + peer, +}) => { + const { rid, issueId, patchId, repoFolder } = await openIssue(page, peer); + const { stdout: head } = await peer.git(["rev-parse", "main"], { + cwd: repoFolder, + }); + const nid = peer.nodeId; + const missingRid = "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5"; + const missingOid = "f".repeat(40); + const explorer = `https://radicle.network/nodes/rosa.radicle.network`; + const repo = `/repos/${rid}`; + + const references: [string, string][] = [ + ["repo", rid], + ["issue", `${rid}/cob/xyz.radicle.issue/${issueId}`], + ["patch", `${rid}/cob/xyz.radicle.patch/${patchId}`], + ["commit", `${rid}/commit/${head}`], + ["person", `did:key:${nid}`], + ["file", `${rid}/commit/main?path=notes.txt#L2`], + ["root", `${rid}/commit/main?path=`], + ["branch", `${rid}/commit/main`], + ["tag", `${rid}/tag/v1.0`], + ["remote", `${rid}/${nid}`], + ["issues", `${rid}/cob/xyz.radicle.issue`], + ["patches", `${rid}/cob/xyz.radicle.patch`], + ["release", `${rid}/cob/dev.radicle.artifact/${missingOid}`], + ["releases", `${rid}/cob/dev.radicle.artifact`], + ["tree", `${rid}/tree/${missingOid}`], + ["missing repo", missingRid], + ["missing issue", `${rid}/cob/xyz.radicle.issue/${missingOid}`], + ]; + await commentBox(page).fill( + references.map(([label, uri]) => `- [${label}](${uri})`).join("\n"), + ); + await comment(page); + await reload(page); + const body = rendered(page).last(); + const link = (href: string) => body.locator(`a[href="${href}"]`); + + await expect(link(`${repo}/home`).first()).toHaveClass(/mention/); + await expect(link(`${repo}/issues/${issueId}?status=all`)).toContainText( + "The notes are too quiet", + ); + await expect(link(`${repo}/patches/${patchId}`)).toContainText( + "Shout the third line", + ); + await expect(link(`${repo}/commits/${head}`)).toContainText("Add notes"); + await expect(body.locator(".mention-node")).toContainText("alice"); + + await expect(link(`${repo}/home/main?path=notes.txt`)).toHaveText("file"); + await expect(link(`${repo}/home/main`)).toHaveText(["root", "branch"]); + await expect(link(`${repo}/home/v1.0`)).toHaveText("tag"); + await expect(link(`${repo}/home/remotes/${nid}`)).toHaveText("remote"); + await expect(link(`${repo}/issues?status=all`)).toHaveText("issues"); + await expect(link(`${repo}/patches`)).toHaveText("patches"); + const missingRelease = link(`${explorer}/${rid}/releases/${missingOid}`); + await expect(missingRelease).toHaveClass(/unresolved/); + await expect(missingRelease).toHaveAttribute("target", "_blank"); + await expect(link(`${repo}/releases`)).toHaveText("releases"); + await expect( + body.locator("a:not([href])", { hasText: "tree" }), + ).toBeVisible(); + + const missingRepo = link(`${explorer}/${missingRid}`); + await expect(missingRepo).toHaveClass(/unresolved/); + await expect(missingRepo).toContainText("z3gqcJ"); + const missingIssue = link(`${explorer}/${rid}/issues/${missingOid}`); + await expect(missingIssue).toHaveClass(/unresolved/); + await expect(missingIssue).toContainText("missing issue"); + await expect(missingIssue).toContainText("fffffff"); + + await link(`${repo}/patches/${patchId}`).click({ button: "right" }); + await expect(page.getByRole("menuitem")).toHaveText([ + "Open in radicle.network", + "Copy link to radicle.network", + "Copy rad: URI", + ]); + await page.keyboard.press("Escape"); + + await link(`${repo}/issues?status=all`).click(); + await expect(page).toHaveURL(/\/issues\?status=all$/); +}); + +test("a repo that isn't here can be seeded from its chip", async ({ + page, + peerManager, +}) => { + const { bob, eve } = await createCollaborators(peerManager); + const { rid: garden } = await createRepo(eve, { name: "garden" }); + await bob.startHttpd(); + await useBackend(page, bob); + await openIssue(page, bob); + + await commentBox(page).fill(`[garden](${garden})`); + await comment(page); + await reload(page); + const chip = rendered(page).last().locator("a.mention.unresolved"); + await expect(chip).toContainText("garden"); + + await chip.click({ button: "right" }); + await waitForCommand(page, "seed", () => + page.getByRole("menuitem", { name: "Seed repository" }).click(), + ); + await expect( + page.getByRole("navigation").getByRole("link", { name: /garden/ }), + ).toBeVisible({ timeout: 20_000 }); +}); + +test("a link to a repo that isn't here can be seeded without an explorer page", async ({ + page, + peerManager, +}) => { + const { bob, eve } = await createCollaborators(peerManager); + const { rid: garden } = await createRepo(eve, { name: "garden" }); + await bob.startHttpd(); + await useBackend(page, bob); + await openIssue(page, bob); + + await commentBox(page).fill( + `[garden tree](${garden}/tree/${"f".repeat(40)})`, + ); + await comment(page); + await reload(page); + + await rendered(page) + .last() + .locator("a", { hasText: "garden tree" }) + .click({ button: "right" }); + await expect(page.getByRole("menuitem")).toHaveText([ + "Copy rad: URI", + "Seed repository", + ]); + await waitForCommand(page, "seed", () => + page.getByRole("menuitem", { name: "Seed repository" }).click(), + ); + await expect( + page.getByRole("navigation").getByRole("link", { name: /garden/ }), + ).toBeVisible({ timeout: 20_000 }); +}); diff --git a/desktop/tests/e2e/repo/patch.spec.ts b/desktop/tests/e2e/repo/patch.spec.ts new file mode 100644 index 000000000..f1a756bfa --- /dev/null +++ b/desktop/tests/e2e/repo/patch.spec.ts @@ -0,0 +1,162 @@ +import type { Page } from "@playwright/test"; +import type { RadiclePeer } from "@tests/support/peerManager.js"; + +import * as Fs from "node:fs/promises"; +import * as Path from "node:path"; + +import * as patch from "@tests/support/cobs/patch.js"; +import { cobRid, expect, reload, test } from "@tests/support/fixtures.js"; +import { createRepo } from "@tests/support/repo.js"; + +async function createPatch(peer: RadiclePeer) { + const { rid, repoFolder } = await createRepo(peer, { name: "review" }); + const file = Path.join(repoFolder, "notes.txt"); + await Fs.writeFile(file, "one\ntwo\nthree\nfour\nfive\n"); + await peer.git(["add", "."], { cwd: repoFolder }); + await peer.git(["commit", "-m", "Add notes"], { cwd: repoFolder }); + await peer.git(["push", "rad", "main"], { cwd: repoFolder }); + const patchId = await patch.create( + peer, + ["Shout the third line"], + "feature/shout", + () => Fs.writeFile(file, "one\ntwo\nTHREE\nfour\nfive\n"), + [], + { cwd: repoFolder }, + ); + return { rid, patchId }; +} + +async function openChanges(page: Page, peer: RadiclePeer) { + const { rid, patchId } = await createPatch(peer); + await page.goto(`/repos/${rid}/patches/${patchId}?view=changes`); +} + +// Opens the composer on a line of the new side, the way a reviewer does: by +// hovering the line number and pressing the marker Pierre puts in the gutter. +async function composeOnNewLine(page: Page, line: number, body: string) { + await page + .locator( + `[data-unified] [data-column-number="${line}"]:not([data-line-type="change-deletion"])`, + ) + .hover(); + await page.locator("[data-utility-button]").click(); + await page.getByPlaceholder("Leave a comment").fill(body); +} + +function composer(page: Page) { + return page + .getByLabel("extended-textarea") + .filter({ has: page.getByPlaceholder("Leave a comment") }); +} + +async function startReview(page: Page) { + await composer(page) + .getByRole("button", { name: /^Start review/ }) + .click(); +} + +async function justComment(page: Page) { + await composer(page) + .getByRole("button", { name: "icon-chevron-down" }) + .click(); + await page.getByRole("button", { name: /^Just comment Post/ }).click(); + await composer(page) + .getByRole("button", { name: /^Just comment/ }) + .click(); +} + +test("a code comment keeps its line after a reload", async ({ page, peer }) => { + await openChanges(page, peer); + await composeOnNewLine(page, 3, "Why so loud?"); + await justComment(page); + + const thread = page.getByRole("group").filter({ hasText: "Why so loud?" }); + await thread.hover(); + await expect(thread.getByText("R3", { exact: true })).toBeVisible(); + + await reload(page); + await thread.hover(); + await expect(thread.getByText("R3", { exact: true })).toBeVisible(); +}); + +test("a resolved review comment stays resolved", async ({ page, peer }) => { + await openChanges(page, peer); + await composeOnNewLine(page, 3, "Please lower the volume"); + await startReview(page); + await page + .getByRole("button", { name: "Accept revision", exact: true }) + .click(); + + const thread = page + .getByRole("group") + .filter({ hasText: "Please lower the volume" }); + await thread.hover(); + await thread.getByTitle("Mark as resolved").click(); + await expect(thread.getByTitle("Mark as unresolved")).toBeVisible(); + + await reload(page); + await thread.hover(); + await expect(thread.getByTitle("Mark as unresolved")).toBeVisible(); +}); + +test("a draft review is published with its comments and verdict", async ({ + page, + peer, +}) => { + await openChanges(page, peer); + await composeOnNewLine(page, 3, "Shouting is fine here"); + await startReview(page); + + const summary = page.getByPlaceholder("Add an optional review summary"); + const pending = page + .getByRole("status") + .filter({ has: page.getByRole("img", { name: "icon-comment" }) }); + await expect(summary).toBeVisible(); + + // The draft lives in local storage until it is published. + await reload(page); + await expect(summary).toBeVisible(); + await pending.hover(); + await expect(page.getByText("notes.txt:R3")).toBeVisible(); + + await summary.fill("Looks good to me"); + await page + .getByRole("button", { name: "Accept revision", exact: true }) + .click(); + + await expect(summary).toBeHidden(); + await expect(page.getByText("Looks good to me").first()).toBeVisible(); + await expect( + page.getByText("Accepted", { exact: true }).first(), + ).toBeVisible(); + await expect(page.getByText("Rejected", { exact: true })).toHaveCount(0); + await expect(page.getByText("Shouting is fine here").first()).toBeVisible(); + + await reload(page); + await expect(summary).toBeHidden(); +}); + +test("a comment-only review needs a summary", async ({ page, peer }) => { + await openChanges(page, peer); + await composeOnNewLine(page, 3, "Just a thought"); + await startReview(page); + + await page + .getByRole("button", { name: "Accept revision", exact: true }) + .locator("xpath=following::button[1]") + .click(); + await page.getByRole("button", { name: /^Comment Leave feedback/ }).click(); + + const publish = page.getByRole("button", { name: "Comment", exact: true }); + await expect(publish).toBeDisabled(); + await page.getByPlaceholder("Add a review summary").fill("Some notes"); + await expect(publish).toBeEnabled(); +}); + +test("shows reviews from other peers", async ({ page }) => { + await page.goto(`/repos/${cobRid}/patches`); + await page.getByText("Taking another stab at the README").click(); + + await expect(page.getByText("This looks better")).toBeVisible(); + await expect(page.getByText("No this doesn't look better")).toBeVisible(); +}); diff --git a/desktop/tests/e2e/repo/patchEditing.spec.ts b/desktop/tests/e2e/repo/patchEditing.spec.ts new file mode 100644 index 000000000..7073ba519 --- /dev/null +++ b/desktop/tests/e2e/repo/patchEditing.spec.ts @@ -0,0 +1,219 @@ +import type { Page } from "@playwright/test"; +import type { RadiclePeer } from "@tests/support/peerManager.js"; + +import { createProject } from "@tests/support/collaboration.js"; +import { + expect, + reload, + test, + waitForCommand, +} from "@tests/support/fixtures.js"; + +async function openPatch(page: Page, peer: RadiclePeer) { + const { rid, patchId } = await createProject(peer); + await page.goto(`/repos/${rid}/patches/${patchId}`); + await expect( + page.getByRole("button", { name: "Shout the third line" }), + ).toBeVisible(); + return { rid, patchId }; +} + +test("a patch can be labelled", async ({ page, peer }) => { + await openPatch(page, peer); + + await page.getByRole("button", { name: "Add labels" }).click(); + await page.getByPlaceholder("Add label").fill("needs-review"); + await waitForCommand(page, "edit_patch", () => + page.getByPlaceholder("Add label").press("Enter"), + ); + const label = page.getByRole("button", { name: "needs-review" }); + await expect(label).toBeVisible(); + + await reload(page); + await expect(label).toBeVisible(); +}); + +test("a patch can be archived, drafted and reopened", async ({ + page, + peer, +}) => { + await openPatch(page, peer); + + const state = (name: string) => + page.getByRole("button", { name: new RegExp(`${name} icon-chevron`) }); + + await state("Open").click(); + await page.getByRole("button", { name: /Archived$/ }).click(); + await expect(state("Archived")).toBeVisible(); + await reload(page); + await expect(state("Archived")).toBeVisible(); + + await state("Archived").click(); + await page.getByRole("button", { name: /Draft$/ }).click(); + await expect(state("Draft")).toBeVisible(); + + await state("Draft").click(); + await page.getByRole("button", { name: /Open$/ }).click(); + await expect(state("Open")).toBeVisible(); + await reload(page); + await expect(state("Open")).toBeVisible(); +}); + +test("a patch can be deleted", async ({ page, peer }) => { + const { rid } = await openPatch(page, peer); + + await page.getByTitle("Delete patch from your node").click(); + await page + .getByRole("button", { name: "icon-trash Delete", exact: true }) + .last() + .click(); + + await expect(page).toHaveURL(new RegExp(`/repos/${rid}/patches`)); + // The list renders with its items, so once its filters show it's loaded. + await expect(page.getByRole("link", { name: /Merged/ })).toBeVisible(); + await expect(page.getByText("Shout the third line")).toBeHidden(); +}); + +// Items in the menu behind a comment's "Comment actions" button. +async function commentAction(page: Page, body: string, action: string) { + await page.getByText(body).hover(); + const toggle = page.getByTitle("Comment actions").last(); + await toggle.click(); + await toggle + .locator( + `xpath=following::*[@role="button"][normalize-space()="${action}"][1]`, + ) + .click(); +} + +test("the title and description can be edited", async ({ page, peer }) => { + const { rid, patchId } = await openPatch(page, peer); + + await page.getByRole("button", { name: "Shout the third line" }).click(); + const input = page.locator("input:focus"); + await input.fill("Shout the third line louder"); + await waitForCommand(page, "edit_patch", () => input.press("Enter")); + const title = page.getByRole("button", { + name: "Shout the third line louder", + }); + await expect(title).toBeVisible(); + + await page.goto(`/repos/${rid}/patches/${patchId}?view=changes`); + await page.getByText("Readers skim").hover(); + await page.getByTitle("Edit description").click(); + const editor = page.getByRole("textbox", { name: "textarea-comment" }); + await editor.fill("Readers skim, so make it loud."); + await waitForCommand(page, "edit_patch", () => + page.getByRole("button", { name: /^Save/ }).click(), + ); + await expect(page.getByText("Readers skim, so make it loud.")).toBeVisible(); + + await reload(page); + await expect(title).toBeVisible(); + await expect(page.getByText("Readers skim, so make it loud.")).toBeVisible(); +}); + +test("a patch comment can be edited and deleted", async ({ page, peer }) => { + await openPatch(page, peer); + + await page.getByPlaceholder("Leave a comment").fill("Needs more shouting"); + await page.getByRole("button", { name: /^Comment/ }).click(); + await expect(page.getByText("Needs more shouting")).toBeVisible(); + + await commentAction(page, "Needs more shouting", "Edit"); + await page + .getByPlaceholder("Leave a comment") + .first() + .fill("Needs less shouting"); + await waitForCommand(page, "edit_patch", () => + page.getByRole("button", { name: /^Save/ }).click(), + ); + await reload(page); + await expect(page.getByText("Needs less shouting")).toBeVisible(); + + await waitForCommand(page, "edit_patch", () => + commentAction(page, "Needs less shouting", "Delete"), + ); + await reload(page); + await expect(page.getByText("Needs less shouting")).toBeHidden(); +}); + +test("an assignee can be added to a patch", async ({ page, peer }) => { + await openPatch(page, peer); + + await page.getByRole("button", { name: "Add assignees" }).click(); + const input = page.getByPlaceholder(/^Alias or DID/); + await input.fill(peer.nodeId); + await waitForCommand(page, "edit_patch", () => input.press("Enter")); + + await reload(page); + await expect( + page.getByRole("button", { + name: "icon-avatar-incognito Assignees", + exact: true, + }), + ).toBeVisible(); +}); + +test("a reaction can be added to a patch comment", async ({ page, peer }) => { + await openPatch(page, peer); + await page.getByPlaceholder("Leave a comment").fill("Loud and clear"); + await page.getByRole("button", { name: /^Comment/ }).click(); + + await page.getByText("Loud and clear").hover(); + await page.getByTitle("React").last().click(); + await waitForCommand(page, "edit_patch", () => + page.getByRole("button", { name: "👍" }).click(), + ); + await reload(page); + await expect(page.getByTitle("Remove reaction")).toBeVisible(); +}); + +test("a published review's verdict and summary can be changed", async ({ + page, + peer, +}) => { + const { rid, patchId } = await createProject(peer); + await page.goto(`/repos/${rid}/patches/${patchId}?view=changes`); + // Only reviews with code comments get a page of their own. + await page + .locator( + '[data-unified] [data-column-number="3"]:not([data-line-type="change-deletion"])', + ) + .hover(); + await page.locator("[data-utility-button]").click(); + await page.getByPlaceholder("Leave a comment").fill("Loud enough"); + await page + .getByLabel("extended-textarea") + .filter({ has: page.getByPlaceholder("Leave a comment") }) + .getByRole("button", { name: /^Start review/ }) + .click(); + await page + .getByPlaceholder("Add an optional review summary") + .fill("Looks fine"); + await waitForCommand(page, "create_patch_review", () => + page.getByRole("button", { name: "Accept revision", exact: true }).click(), + ); + // Publishing opens the review's own page. + + await page.getByRole("button", { name: /Accepted/ }).click(); + await waitForCommand(page, "edit_patch", () => + page.getByRole("button", { name: /Reject$/ }).click(), + ); + const rejected = page.getByRole("button", { name: /Rejected/ }); + await expect(rejected).toBeVisible(); + + await page.getByText("Looks fine").hover(); + await page.getByTitle("Edit summary").click(); + await page + .getByRole("textbox", { name: "textarea-comment" }) + .first() + .fill("Too loud"); + await waitForCommand(page, "edit_patch", () => + page.getByRole("button", { name: /^Save/ }).click(), + ); + + await reload(page); + await expect(rejected).toBeVisible(); + await expect(page.getByText("Too loud")).toBeVisible(); +}); diff --git a/desktop/tests/e2e/repo/permissions.spec.ts b/desktop/tests/e2e/repo/permissions.spec.ts new file mode 100644 index 000000000..f75e55e15 --- /dev/null +++ b/desktop/tests/e2e/repo/permissions.spec.ts @@ -0,0 +1,133 @@ +import type { Page } from "@playwright/test"; +import type { PeerManager } from "@tests/support/peerManager.js"; + +import { + clone, + createCollaborators, + createProject, +} from "@tests/support/collaboration.js"; +import { + expect, + goto, + test, + useBackend, + waitForCommand, +} from "@tests/support/fixtures.js"; + +// Opens bob's project as eve, who cloned it but isn't a delegate. +async function asContributor(page: Page, peerManager: PeerManager) { + const { bob, eve } = await createCollaborators(peerManager); + const project = await createProject(bob); + await bob.rad( + [ + "issue", + "comment", + project.issueId, + "--message", + "Bob's own comment", + "--quiet", + ], + { cwd: project.repoFolder }, + ); + await clone(eve, project.rid); + await eve.startHttpd(); + await useBackend(page, eve); + return project; +} + +test("a contributor can't edit an issue's metadata or delete it", async ({ + page, + peerManager, +}) => { + const { rid, issueId } = await asContributor(page, peerManager); + await page.goto(`/repos/${rid}/issues/${issueId}`); + await expect(page.getByText("Nobody reads them.")).toBeVisible(); + + await expect(page.getByRole("button", { name: "Add labels" })).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Add assignees" }), + ).toBeDisabled(); + await expect( + page.getByTitle( + "Only the issue author and delegates can change the issue state", + ), + ).toBeVisible(); + await expect(page.getByTitle("Delete issue from your node")).toBeHidden(); +}); + +test("a contributor can't edit or delete someone else's comment", async ({ + page, + peerManager, +}) => { + const { rid, issueId } = await asContributor(page, peerManager); + await page.goto(`/repos/${rid}/issues/${issueId}`); + + await page.getByText("Bob's own comment").hover(); + const toggle = page.getByTitle("Comment actions").last(); + await toggle.click(); + const menuItem = (action: string) => + toggle.locator( + `xpath=following::*[@role="button"][normalize-space()="${action}"][1]`, + ); + await expect(menuItem("Copy ID")).toBeVisible(); + await expect(menuItem("Edit")).toHaveCount(0); + await expect(menuItem("Delete")).toHaveCount(0); +}); + +test("a contributor can't change a patch's state or delete it", async ({ + page, + peerManager, +}) => { + const { rid, patchId } = await asContributor(page, peerManager); + await page.goto(`/repos/${rid}/patches/${patchId}`); + await expect( + page.getByRole("button", { name: "Shout the third line" }), + ).toBeVisible(); + + await expect( + page.getByTitle( + "Only delegates and the patch author can change the patch state", + ), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Add labels" })).toBeDisabled(); + await expect(page.getByTitle("Delete patch from your node")).toBeHidden(); +}); + +test("a contributor can't resolve someone else's review comment", async ({ + page, + peerManager, +}) => { + const { bob, eve } = await createCollaborators(peerManager); + const { rid, patchId } = await createProject(bob); + await clone(eve, rid); + await bob.startHttpd(); + await useBackend(page, bob); + + // Bob reviews his own patch with a comment on the changed line. + await page.goto(`/repos/${rid}/patches/${patchId}?view=changes`); + await page + .locator( + '[data-unified] [data-column-number="3"]:not([data-line-type="change-deletion"])', + ) + .hover(); + await page.locator("[data-utility-button]").click(); + await page.getByPlaceholder("Leave a comment").fill("Keep it loud"); + await page + .getByLabel("extended-textarea") + .filter({ has: page.getByPlaceholder("Leave a comment") }) + .getByRole("button", { name: /^Start review/ }) + .click(); + await waitForCommand(page, "create_patch_review", () => + page.getByRole("button", { name: "Accept revision", exact: true }).click(), + ); + await eve.rad(["sync", rid, "--fetch"]); + + // Now as eve. + await eve.startHttpd(); + await useBackend(page, eve); + await goto(page, `/repos/${rid}/patches/${patchId}?view=changes`); + const thread = page.getByRole("group").filter({ hasText: "Keep it loud" }); + await thread.hover(); + await expect(thread.getByTitle("Reply")).toBeVisible(); + await expect(thread.getByTitle("Mark as resolved")).toHaveCount(0); +}); diff --git a/desktop/tests/e2e/repo/source.spec.ts b/desktop/tests/e2e/repo/source.spec.ts new file mode 100644 index 000000000..c23997b49 --- /dev/null +++ b/desktop/tests/e2e/repo/source.spec.ts @@ -0,0 +1,191 @@ +import type { RadiclePeer } from "@tests/support/peerManager.js"; + +import * as Fs from "node:fs/promises"; +import * as Path from "node:path"; + +import { clone, createCollaborators } from "@tests/support/collaboration.js"; +import { cobRid, expect, test, useBackend } from "@tests/support/fixtures.js"; +import { createRepo } from "@tests/support/repo.js"; + +async function createDocsRepo(peer: RadiclePeer) { + const { rid, repoFolder } = await createRepo(peer, { name: "docs" }); + await Fs.writeFile( + Path.join(repoFolder, "README.md"), + "# Docs\n\nWelcome to the docs.\n", + ); + await Fs.mkdir(Path.join(repoFolder, "guides")); + await Fs.writeFile( + Path.join(repoFolder, "guides", "setup.md"), + "Install it first.\n", + ); + await peer.git(["add", "."], { cwd: repoFolder }); + await peer.git(["commit", "-m", "Write the docs"], { cwd: repoFolder }); + await peer.git(["push", "rad", "main"], { cwd: repoFolder }); + return { rid, repoFolder }; +} + +test("the repo home shows the README and browses the tree", async ({ + page, + peer, +}) => { + const { rid } = await createDocsRepo(peer); + await page.goto(`/repos/${rid}/home`); + await expect(page.getByText("Welcome to the docs.")).toBeVisible(); + + await page.getByText("guides", { exact: true }).click(); + await page.getByText("setup.md", { exact: true }).click(); + await expect(page.getByText("Install it first.")).toBeVisible(); +}); + +async function createNestedDocsRepo(peer: RadiclePeer) { + const { rid, repoFolder } = await createRepo(peer, { name: "docs" }); + const files = { + "README.md": "# Docs\n\nWelcome to the docs.\n", + "guides/setup.md": "Install it first.\n", + "guides/usage.md": "Then use it.\n", + "api/index.md": "The API reference.\n", + }; + for (const [file, content] of Object.entries(files)) { + await Fs.mkdir(Path.dirname(Path.join(repoFolder, file)), { + recursive: true, + }); + await Fs.writeFile(Path.join(repoFolder, file), content); + } + await peer.git(["add", "."], { cwd: repoFolder }); + await peer.git(["commit", "-m", "Write the docs"], { cwd: repoFolder }); + await peer.git(["push", "rad", "main"], { cwd: repoFolder }); + return { rid, repoFolder }; +} + +test("selecting a file keeps the open folder's entries rendered", async ({ + page, + peer, +}) => { + const { rid } = await createNestedDocsRepo(peer); + await page.goto(`/repos/${rid}/home`); + await expect(page.getByText("Welcome to the docs.")).toBeVisible(); + + await page.getByText("guides", { exact: true }).click(); + const sibling = await page + .getByText("usage.md", { exact: true }) + .elementHandle(); + await page.getByText("setup.md", { exact: true }).click(); + await expect(page.getByText("Install it first.")).toBeVisible(); + + expect(await sibling?.evaluate(el => el.isConnected)).toBe(true); +}); + +test("folders stay open when selecting a file elsewhere", async ({ + page, + peer, +}) => { + const { rid } = await createNestedDocsRepo(peer); + await page.goto(`/repos/${rid}/home`); + + await page.getByText("guides", { exact: true }).click(); + await page.getByText("setup.md", { exact: true }).click(); + await expect(page.getByText("Install it first.")).toBeVisible(); + + await page.getByText("api", { exact: true }).click(); + await page.getByText("index.md", { exact: true }).click(); + await expect(page.getByText("The API reference.")).toBeVisible(); + await expect(page.getByText("usage.md", { exact: true })).toBeVisible(); + + await page.getByText("guides", { exact: true }).click(); + await expect(page.getByText("usage.md", { exact: true })).toBeHidden(); + await page.getByText("README.md", { exact: true }).click(); + await expect(page.getByText("Welcome to the docs.")).toBeVisible(); + await expect(page.getByText("usage.md", { exact: true })).toBeHidden(); + await expect(page.getByText("index.md", { exact: true })).toBeVisible(); +}); + +test("commits are listed and open with their changes", async ({ + page, + peer, +}) => { + const { rid } = await createDocsRepo(peer); + await page.goto(`/repos/${rid}/commits`); + + await page + .getByRole("button", { name: "commit-teaser" }) + .filter({ hasText: "Write the docs" }) + .click(); + await expect(page).toHaveURL(/\/commits\/[0-9a-f]{40}/); + await expect(page.getByText("Write the docs").first()).toBeVisible(); + await expect(page.getByText("guides/setup.md").first()).toBeVisible(); +}); + +test("a contributor's branch can be browsed", async ({ page, peerManager }) => { + const { bob, eve } = await createCollaborators(peerManager); + const { rid } = await createDocsRepo(bob); + await clone(eve, rid); + const eveCopy = Path.join(eve.checkoutPath, "docs"); + await eve.git(["switch", "-c", "friendlier"], { cwd: eveCopy }); + await Fs.writeFile( + Path.join(eveCopy, "README.md"), + "# Docs\n\nHello, and welcome.\n", + ); + await eve.git(["commit", "-am", "Greet readers"], { cwd: eveCopy }); + await eve.git(["push", "rad", "friendlier"], { cwd: eveCopy }); + await bob.rad(["sync", rid, "--fetch"]); + await bob.startHttpd(); + await useBackend(page, bob); + + await page.goto(`/repos/${rid}/home`); + await expect(page.getByText("Welcome to the docs.")).toBeVisible(); + + await page.getByTitle("Change branch or tag").click(); + await page.getByPlaceholder("Filter branches").fill("friendlier"); + await page + .getByRole("link", { name: /friendlier/ }) + .first() + .click(); + + await expect(page).toHaveURL(new RegExp(`/remotes/${eve.nodeId}/friendlier`)); + await expect(page.getByText("Hello, and welcome.")).toBeVisible(); +}); + +test("patches are filtered by state", async ({ page }) => { + await page.goto(`/repos/${cobRid}/patches`); + await expect( + page.getByText("Taking another stab at the README"), + ).toBeVisible(); + + await page.getByRole("link", { name: /Merged/ }).click(); + await expect(page.getByText("Let's add a README")).toBeVisible(); + await expect( + page.getByText("Taking another stab at the README"), + ).toBeHidden(); + + await page.getByRole("link", { name: /Archived/ }).click(); + await expect( + page.getByText("This patch is going to be archived"), + ).toBeVisible(); + await expect(page.getByText("Let's add a README")).toBeHidden(); +}); + +test("the identity page lists accepted revisions", async ({ page, peer }) => { + const { rid, repoFolder } = await createDocsRepo(peer); + await peer.rad( + [ + "id", + "update", + "--title", + "Describe the docs", + "--description", + "So people know what this is.", + "--payload", + "xyz.radicle.project", + "description", + '"Docs for everyone"', + ], + { cwd: repoFolder }, + ); + + await page.goto(`/repos/${rid}/identity`); + await expect( + page.getByRole("button", { + name: /^icon-checkmark .*Describe the docs Current/, + }), + ).toBeVisible(); +}); diff --git a/desktop/tests/e2e/repo/tasks.spec.ts b/desktop/tests/e2e/repo/tasks.spec.ts new file mode 100644 index 000000000..eff8bdd33 --- /dev/null +++ b/desktop/tests/e2e/repo/tasks.spec.ts @@ -0,0 +1,477 @@ +import type { Comment } from "@bindings/cob/thread/Comment"; +import type { Thread } from "@bindings/cob/thread/Thread"; +import type { Page } from "@playwright/test"; +import type { RadiclePeer } from "@tests/support/peerManager.js"; + +import * as Fs from "node:fs/promises"; +import * as Path from "node:path"; + +import * as issue from "@tests/support/cobs/issue.js"; +import * as patch from "@tests/support/cobs/patch.js"; +import { + clone, + createCollaborators, + createProject, +} from "@tests/support/collaboration.js"; +import { + expect, + goto, + reload, + test, + useBackend, + waitForCommand, +} from "@tests/support/fixtures.js"; + +const taskList = + "Before the release:\n\n- [ ] Check the tone\n- [ ] Ask a reader"; + +const onePixelPng = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", + "base64", +); + +function api(page: Page, peer: RadiclePeer, command: string, data: object) { + return page.request + .post(`http://127.0.0.1:${peer.httpdBaseUrl.port}/${command}`, { data }) + .then(response => response.json()); +} + +async function saveEmbed( + page: Page, + peer: RadiclePeer, + rid: string, + name: string, + bytes: Uint8Array, +) { + const response = await page.request.post( + `http://127.0.0.1:${peer.httpdBaseUrl.port}/save_embed_by_bytes`, + { + headers: { rid, "Content-Type": "application/octet-stream" }, + data: Buffer.concat([Buffer.from(`${name}\0`), bytes]), + }, + ); + return (await response.json()) as string; +} + +async function createComment( + page: Page, + peer: RadiclePeer, + rid: string, + issueId: string, + body: string, + embedOid?: string, + replyTo?: string, +) { + return (await api(page, peer, "create_issue_comment", { + rid, + new: { + id: issueId, + body: embedOid ? `${body}\n\n[megaphone.txt](${embedOid})` : body, + embeds: embedOid + ? [{ name: "megaphone.txt", content: `git:${embedOid}` }] + : [], + replyTo, + }, + opts: { announce: false }, + })) as Comment; +} + +async function storedComment( + page: Page, + peer: RadiclePeer, + rid: string, + issueId: string, + text: string, +) { + const threads = (await api(page, peer, "comment_threads_by_issue_id", { + rid, + id: issueId, + })) as Thread[]; + return threads + .flatMap(thread => [thread.root, ...thread.replies]) + .find(comment => comment.edits.at(-1)?.body.includes(text)); +} + +async function createTaskIssue(peer: RadiclePeer, description = taskList) { + const { rid, repoFolder } = await createProject(peer); + const issueId = await issue.create(peer, "Tidy the notes", description, [], { + cwd: repoFolder, + }); + return { rid, issueId, repoFolder }; +} + +async function openTaskIssue( + page: Page, + peer: RadiclePeer, + description = taskList, +) { + const { rid, issueId } = await createTaskIssue(peer, description); + await page.goto(`/repos/${rid}/issues/${issueId}`); + await expect(page.getByText("Before the release:")).toBeVisible(); +} + +test("ticking a task in an issue description is saved", async ({ + page, + peer, +}) => { + await openTaskIssue(page, peer); + + const tone = page.getByRole("checkbox", { name: "Check the tone" }); + const reader = page.getByRole("checkbox", { name: "Ask a reader" }); + await expect(tone).not.toBeChecked(); + + await waitForCommand(page, "edit_issue", () => tone.press("Space")); + await expect(tone).toBeChecked(); + await expect(tone).toBeFocused(); + + await reload(page); + await expect(tone).toBeChecked(); + await expect(reader).not.toBeChecked(); + + await waitForCommand(page, "edit_issue", () => tone.click()); + await reload(page); + await expect(tone).not.toBeChecked(); +}); + +test("a task that fails to save is unticked again", async ({ page, peer }) => { + await openTaskIssue(page, peer); + let release: (() => void) | undefined; + const released = new Promise(resolve => (release = resolve)); + await page.route("**/edit_issue", async route => { + await released; + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ code: "Unknown", message: "Not saved" }), + }); + }); + + const tone = page.getByRole("checkbox", { name: "Check the tone" }); + await tone.click(); + await expect(tone).toBeChecked(); + release?.(); + await expect(tone).not.toBeChecked(); +}); + +test("emojis stay rendered after ticking a task", async ({ page, peer }) => { + await openTaskIssue(page, peer, `${taskList}\n\nParty after 🎉`); + const party = page.getByRole("img", { name: "🎉" }); + await expect(party).toBeVisible(); + + await waitForCommand(page, "edit_issue", () => + page.getByRole("checkbox", { name: "Check the tone" }).click(), + ); + await expect( + page.getByRole("checkbox", { name: "Check the tone" }), + ).toBeChecked(); + await expect(party).toBeVisible(); +}); + +test("ticking a task doesn't load the embeds again", async ({ page, peer }) => { + const { rid, repoFolder } = await createProject(peer); + const oid = await saveEmbed(page, peer, rid, "dot.png", onePixelPng); + const issueId = await issue.create( + peer, + "Tidy the notes", + `${taskList}\n\n[dot.png](${oid})`, + [], + { cwd: repoFolder }, + ); + let loads = 0; + page.on("request", request => { + if (new URL(request.url()).pathname === "/get_embed") { + loads++; + } + }); + + await page.goto(`/repos/${rid}/issues/${issueId}`); + const preview = page.locator('img[src^="blob:"]'); + await expect(preview).toBeVisible(); + expect(loads).toBe(1); + + const tone = page.getByRole("checkbox", { name: "Check the tone" }); + await waitForCommand(page, "edit_issue", () => tone.click()); + await expect(tone).toBeChecked(); + await expect(preview).toBeVisible(); + expect(loads).toBe(1); +}); + +test("ticking a task in a comment keeps the comment's embeds", async ({ + page, + peer, +}) => { + const { rid, issueId } = await createTaskIssue(peer); + const oid = await saveEmbed( + page, + peer, + rid, + "megaphone.txt", + new TextEncoder().encode("loud"), + ); + await createComment( + page, + peer, + rid, + issueId, + "Before the meeting:\n\n- [ ] Bring a megaphone", + oid, + ); + + await page.goto(`/repos/${rid}/issues/${issueId}`); + const megaphone = page.getByRole("checkbox", { name: "Bring a megaphone" }); + await waitForCommand(page, "edit_issue", () => megaphone.click()); + await expect(megaphone).toBeChecked(); + + const comment = await storedComment( + page, + peer, + rid, + issueId, + "Bring a megaphone", + ); + expect(comment?.edits.at(-1)?.body).toContain("- [x] Bring a megaphone"); + expect(comment?.embeds).toEqual([ + { name: "megaphone.txt", content: `git:${oid}` }, + ]); +}); + +test("ticking a task in a reply keeps the reply's embeds", async ({ + page, + peer, +}) => { + const { rid, issueId } = await createTaskIssue(peer); + const oid = await saveEmbed( + page, + peer, + rid, + "megaphone.txt", + new TextEncoder().encode("loud"), + ); + const root = await createComment(page, peer, rid, issueId, "Who brings it?"); + await createComment( + page, + peer, + rid, + issueId, + "- [ ] Bring a megaphone", + oid, + root.id, + ); + + await page.goto(`/repos/${rid}/issues/${issueId}`); + const megaphone = page.getByRole("checkbox", { name: "Bring a megaphone" }); + await waitForCommand(page, "edit_issue", () => megaphone.click()); + await expect(megaphone).toBeChecked(); + + const reply = await storedComment( + page, + peer, + rid, + issueId, + "Bring a megaphone", + ); + expect(reply?.replyTo).toBe(root.id); + expect(reply?.embeds).toEqual([ + { name: "megaphone.txt", content: `git:${oid}` }, + ]); +}); + +test("editing a comment keeps its embeds", async ({ page, peer }) => { + const { rid, issueId } = await createTaskIssue(peer); + const oid = await saveEmbed( + page, + peer, + rid, + "megaphone.txt", + new TextEncoder().encode("loud"), + ); + await createComment(page, peer, rid, issueId, "Bring a megaphone", oid); + + await page.goto(`/repos/${rid}/issues/${issueId}`); + await page.getByText("Bring a megaphone").hover(); + const toggle = page.getByTitle("Comment actions").last(); + await toggle.click(); + await toggle + .locator(`xpath=following::*[@role="button"][normalize-space()="Edit"][1]`) + .click(); + await page + .getByPlaceholder("Leave a comment") + .first() + .fill(`Bring two megaphones\n\n[megaphone.txt](${oid})`); + await waitForCommand(page, "edit_issue", () => + page.getByRole("button", { name: /^Save/ }).click(), + ); + + const comment = await storedComment( + page, + peer, + rid, + issueId, + "Bring two megaphones", + ); + expect(comment?.embeds).toEqual([ + { name: "megaphone.txt", content: `git:${oid}` }, + ]); +}); + +test("a contributor can't tick tasks in someone else's issue", async ({ + page, + peerManager, +}) => { + const { bob, eve } = await createCollaborators(peerManager); + const { rid, issueId, repoFolder } = await createTaskIssue(bob); + await bob.rad( + [ + "issue", + "comment", + issueId, + "--message", + "Before the meeting:\n\n- [ ] Bring a megaphone", + "--quiet", + ], + { cwd: repoFolder }, + ); + await clone(eve, rid); + await eve.startHttpd(); + await useBackend(page, eve); + + await page.goto(`/repos/${rid}/issues/${issueId}`); + await expect(page.getByText("Check the tone")).toBeVisible(); + await expect(page.getByText("Bring a megaphone")).toBeVisible(); + await expect(page.getByRole("checkbox")).toHaveCount(0); +}); + +async function createTaskPatch(peer: RadiclePeer) { + const { rid, repoFolder } = await createProject(peer); + const notes = Path.join(repoFolder, "notes.txt"); + const patchId = await patch.create( + peer, + ["Quiet the notes"], + "feature/quiet", + () => Fs.writeFile(notes, "one\n"), + ["Quiet the notes", "- [ ] Lower the volume"], + { cwd: repoFolder }, + ); + await Fs.writeFile(notes, "one\ntwo\n"); + await peer.git(["commit", "-am", "Keep the second line"], { + cwd: repoFolder, + }); + // Push options can't hold line breaks, so each task is its own message. + await peer.git( + [ + "push", + "-o", + "patch.message=Quieter after all", + "-o", + "patch.message=- [ ] Check the tone", + "-o", + "patch.message=- [ ] Ask a reader", + "rad", + `HEAD:refs/heads/patches/${patchId}`, + ], + { cwd: repoFolder }, + ); + await peer.rad( + [ + "patch", + "review", + patchId, + "--message=Before merging:\n\n- [ ] Rename the file", + "--accept", + ], + { cwd: repoFolder }, + ); + return { rid, patchId }; +} + +async function openTaskPatch(page: Page, peer: RadiclePeer, query = "") { + const { rid, patchId } = await createTaskPatch(peer); + await page.goto(`/repos/${rid}/patches/${patchId}${query}`); + await expect(page.getByText("Quieter after all").first()).toBeVisible(); +} + +test("ticking a task in a patch description is saved", async ({ + page, + peer, +}) => { + await openTaskPatch(page, peer); + + const volume = page.getByRole("checkbox", { name: "Lower the volume" }); + await waitForCommand(page, "edit_patch", () => volume.click()); + await expect(volume).toBeChecked(); + + await reload(page); + await expect(volume).toBeChecked(); + await expect( + page.getByRole("checkbox", { name: "Check the tone" }), + ).not.toBeChecked(); +}); + +test("ticking a task in a revision keeps its subject", async ({ + page, + peer, +}) => { + await openTaskPatch(page, peer); + + const tone = page.getByRole("checkbox", { name: "Check the tone" }); + await waitForCommand(page, "edit_patch", () => tone.click()); + await expect(tone).toBeChecked(); + + await reload(page); + await expect(tone).toBeChecked(); + await expect( + page.getByRole("checkbox", { name: "Ask a reader" }), + ).not.toBeChecked(); + await expect(page.getByText("Quieter after all").first()).toBeVisible(); +}); + +test("ticking a task on the changes tab is saved", async ({ page, peer }) => { + await openTaskPatch(page, peer, "?view=changes"); + + const reader = page.getByRole("checkbox", { name: "Ask a reader" }); + await waitForCommand(page, "edit_patch", () => reader.click()); + await expect(reader).toBeChecked(); + + await reload(page); + await expect(reader).toBeChecked(); +}); + +test("ticking a task in your own review summary keeps the verdict", async ({ + page, + peer, +}) => { + await openTaskPatch(page, peer); + + const rename = page.getByRole("checkbox", { name: "Rename the file" }); + await waitForCommand(page, "edit_patch", () => rename.click()); + await expect(rename).toBeChecked(); + + await reload(page); + await expect(rename).toBeChecked(); + await expect(page.getByText("Accepted").first()).toBeVisible(); +}); + +test("a contributor can't tick tasks in someone else's patch", async ({ + page, + peerManager, +}) => { + const { bob, eve } = await createCollaborators(peerManager); + const { rid, patchId } = await createTaskPatch(bob); + await clone(eve, rid); + await eve.startHttpd(); + await useBackend(page, eve); + + await page.goto(`/repos/${rid}/patches/${patchId}`); + await expect( + page.getByText("Lower the volume", { exact: true }), + ).toBeVisible(); + await expect(page.getByText("Check the tone", { exact: true })).toBeVisible(); + await expect( + page.getByText("Rename the file", { exact: true }), + ).toBeVisible(); + await expect(page.getByRole("checkbox")).toHaveCount(0); + + await goto(page, `/repos/${rid}/patches/${patchId}?view=changes`); + await expect(page.getByText("Ask a reader", { exact: true })).toBeVisible(); + await expect(page.getByRole("checkbox")).toHaveCount(0); +}); diff --git a/desktop/tests/e2e/repos.spec.ts b/desktop/tests/e2e/repos.spec.ts new file mode 100644 index 000000000..9afa43507 --- /dev/null +++ b/desktop/tests/e2e/repos.spec.ts @@ -0,0 +1,9 @@ +import { expect, test } from "@tests/support/fixtures.js"; + +test("navigate to repo issues", async ({ page }) => { + await page.goto("/inbox"); + await page.getByRole("link", { name: "cobs" }).click(); + await page.getByRole("link", { name: "Issues" }).click(); + await page.getByText("This title has **markdown**").click(); + await expect(page).toHaveURL(/\/issues\/[0-9a-f]{40}/); +}); diff --git a/desktop/tests/e2e/seeding.spec.ts b/desktop/tests/e2e/seeding.spec.ts new file mode 100644 index 000000000..033f32850 --- /dev/null +++ b/desktop/tests/e2e/seeding.spec.ts @@ -0,0 +1,109 @@ +import type { Page } from "@playwright/test"; +import type { PeerManager } from "@tests/support/peerManager.js"; + +import * as Fs from "node:fs/promises"; +import * as Path from "node:path"; + +import { createCollaborators } from "@tests/support/collaboration.js"; +import { + expect, + reload, + test, + useBackend, + waitForCommand, +} from "@tests/support/fixtures.js"; +import { createRepo } from "@tests/support/repo.js"; + +test("seeds a repo from the network by its bare RID", async ({ + page, + peerManager, +}) => { + const { bob, eve } = await createCollaborators(peerManager); + const { rid } = await createRepo(eve, { name: "garden" }); + await bob.startHttpd(); + await useBackend(page, bob); + + await page.goto("/inbox"); + await page.getByRole("button", { name: "icon-plus", exact: true }).click(); + const input = page.getByPlaceholder(/^RID, e\.g\./); + await input.fill(rid.replace(/^rad:/, "")); + await waitForCommand(page, "seed", () => + page.getByRole("button", { name: "icon-seed Seed", exact: true }).click(), + ); + + await expect(page.getByRole("link", { name: /garden/ })).toBeVisible({ + timeout: 20_000, + }); + + await page.getByRole("button", { name: "icon-plus", exact: true }).click(); + await input.fill(rid); + await input.press("Enter"); + await expect( + page.getByText(/This repo is already (seeded|queued for fetching)/), + ).toBeVisible(); +}); + +async function stopSeeding(page: Page, name: string, deleteFiles: boolean) { + await page.getByRole("link", { name: new RegExp(name) }).click({ + button: "right", + }); + await page.getByRole("menuitem", { name: /Unseed/ }).click(); + if (deleteFiles) { + await page.getByText("Delete the files from local storage").click(); + } + await waitForCommand(page, deleteFiles ? "clean" : "unseed", () => + page.getByRole("button", { name: /Unseed$/ }).click(), + ); + await expect(page.getByRole("link", { name: new RegExp(name) })).toBeHidden(); +} + +async function seededFrom(peerManager: PeerManager, page: Page) { + const { bob, eve } = await createCollaborators(peerManager); + const { rid } = await createRepo(eve, { name: "garden" }); + await bob.rad(["seed", rid]); + await bob.startHttpd(); + await useBackend(page, bob); + await page.goto("/inbox"); + return rid; +} + +function storedRepo(stateDir: string, rid: string) { + return Path.join( + stateDir, + "peers", + "bob", + "home", + "storage", + rid.replace(/^rad:/, ""), + ); +} + +async function exists(path: string) { + return Fs.stat(path).then( + () => true, + () => false, + ); +} + +test("unseeding keeps the repo's files", async ({ + page, + peerManager, + stateDir, +}) => { + const rid = await seededFrom(peerManager, page); + await stopSeeding(page, "garden", false); + await reload(page); + await expect(page.getByRole("link", { name: /garden/ })).toBeHidden(); + expect(await exists(storedRepo(stateDir, rid))).toBe(true); +}); + +test("unseeding can delete the repo's files", async ({ + page, + peerManager, + stateDir, +}) => { + const rid = await seededFrom(peerManager, page); + expect(await exists(storedRepo(stateDir, rid))).toBe(true); + await stopSeeding(page, "garden", true); + expect(await exists(storedRepo(stateDir, rid))).toBe(false); +}); diff --git a/desktop/tests/e2e/theme.spec.ts b/desktop/tests/e2e/theme.spec.ts new file mode 100644 index 000000000..cc01c23e6 --- /dev/null +++ b/desktop/tests/e2e/theme.spec.ts @@ -0,0 +1,32 @@ +import { expect, reload, test } from "@tests/support/fixtures.js"; + +test("default theme", async ({ page }) => { + await page.goto("/inbox"); + + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); +}); + +test("theme persistence", async ({ page }) => { + await page.goto("/inbox"); + await expect(page.getByRole("link", { name: "markdown" })).toBeVisible(); + await page.getByRole("button", { name: "Settings" }).click(); + + await page.getByRole("button", { name: "Light", exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + + await reload(page); + + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); +}); + +test("change theme", async ({ page }) => { + await page.goto("/inbox"); + await expect(page.getByRole("link", { name: "markdown" })).toBeVisible(); + await page.getByRole("button", { name: "Settings" }).click(); + + await page.getByRole("button", { name: "Light", exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + + await page.getByRole("button", { name: "Dark", exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); +}); diff --git a/desktop/tests/fixtures/repos/markdown.tar.bz2 b/desktop/tests/fixtures/repos/markdown.tar.bz2 new file mode 100644 index 000000000..fd43e620c Binary files /dev/null and b/desktop/tests/fixtures/repos/markdown.tar.bz2 differ diff --git a/desktop/tests/support/cobs/issue.ts b/desktop/tests/support/cobs/issue.ts new file mode 100644 index 000000000..b50f29a57 --- /dev/null +++ b/desktop/tests/support/cobs/issue.ts @@ -0,0 +1,25 @@ +import type { RadiclePeer, SpawnOptions } from "@tests/support/peerManager.js"; + +export async function create( + peer: RadiclePeer, + title: string, + description: string, + labels: string[], + options: SpawnOptions, +): Promise { + const issueOptions: string[] = [ + "issue", + "open", + "--title", + title, + "--description", + description, + ...(labels.length > 0 ? ["--labels", labels.join(",")] : []), + ]; + const { stdout } = await peer.rad(issueOptions, options); + const match = stdout.match(/Issue {3}([a-zA-Z0-9]*)/); + if (!match) { + throw new Error("Not able to parse issue id"); + } + return match[1]; +} diff --git a/desktop/tests/support/cobs/patch.ts b/desktop/tests/support/cobs/patch.ts new file mode 100644 index 000000000..27c547a73 --- /dev/null +++ b/desktop/tests/support/cobs/patch.ts @@ -0,0 +1,45 @@ +import type { RadiclePeer, SpawnOptions } from "@tests/support/peerManager.js"; + +export async function create( + peer: RadiclePeer, + commitLines: string[], + branch: string, + changeFn: () => Promise, + messages: string[], + options: SpawnOptions, +): Promise { + if (branch) { + await peer.git(["reset", "--hard"], options); + await peer.git(["switch", "main"], options); + await peer.git(["switch", "-c", branch], options); + } + await changeFn(); + await peer.git(["add", "."], options); + await peer.git( + ["commit"].concat(...commitLines.map(line => ["-m", line])), + options, + ); + const cmd = [ + "push", + ...messages.map(msg => ["-o", `patch.message=${msg}`]).flat(), + "rad", + "HEAD:refs/patches", + ]; + const { stderr } = await peer.git(cmd, options); + const match = stderr.match(/✓ Patch ([a-zA-Z0-9]*) opened/); + if (!match) { + throw new Error("Not able to parse patch id"); + } + return match[1]; +} + +export async function merge( + peer: RadiclePeer, + targetBranch: string, + featureBranch: string, + options: SpawnOptions, +): Promise { + await peer.git(["switch", targetBranch], options); + await peer.git(["merge", featureBranch], options); + await peer.git(["push", "rad", targetBranch], options); +} diff --git a/desktop/tests/support/collaboration.ts b/desktop/tests/support/collaboration.ts new file mode 100644 index 000000000..c9871d078 --- /dev/null +++ b/desktop/tests/support/collaboration.ts @@ -0,0 +1,76 @@ +import type { PeerManager, RadiclePeer } from "@tests/support/peerManager.js"; + +import * as Fs from "node:fs/promises"; +import * as Path from "node:path"; + +import * as issue from "@tests/support/cobs/issue.js"; +import * as patch from "@tests/support/cobs/patch.js"; +import { defaultConfig, gitOptions } from "@tests/support/fixtures.js"; +import { createRepo } from "@tests/support/repo.js"; + +// Bob maintains the repo; eve is connected to him and can clone it, but isn't +// a delegate. +export async function createCollaborators(peerManager: PeerManager) { + const bob = await peerManager.createPeer({ + name: "bob", + gitOptions: gitOptions["bob"], + }); + await bob.startNode(); + const eve = await peerManager.createPeer({ + name: "eve", + gitOptions: gitOptions["eve"], + }); + await eve.startNode({ + node: { ...defaultConfig.node, connect: [bob.address], alias: "eve" }, + }); + return { bob, eve }; +} + +// A repo with one file, one issue and one patch, all by `peer`. +export async function createProject(peer: RadiclePeer, name = "project") { + const { rid, repoFolder } = await createRepo(peer, { name }); + const file = Path.join(repoFolder, "notes.txt"); + await Fs.writeFile(file, "one\ntwo\nthree\nfour\nfive\n"); + await peer.git(["add", "."], { cwd: repoFolder }); + await peer.git(["commit", "-m", "Add notes"], { cwd: repoFolder }); + await peer.git(["push", "rad", "main"], { cwd: repoFolder }); + const issueId = await issue.create( + peer, + "The notes are too quiet", + "Nobody reads them.", + [], + { cwd: repoFolder }, + ); + const patchId = await patch.create( + peer, + ["Shout the third line"], + "feature/shout", + () => Fs.writeFile(file, "one\ntwo\nTHREE\nfour\nfive\n"), + ["Shout the third line", "Readers skim, so the key line should stand out."], + { cwd: repoFolder }, + ); + return { rid, repoFolder, issueId, patchId }; +} + +export async function clone(peer: RadiclePeer, rid: string) { + await peer.rad(["clone", rid], { cwd: peer.checkoutPath }); +} + +// Resolves once `peer` has fetched `remote`'s changes to the COB `cobId`. +export async function waitForCobFrom( + peer: RadiclePeer, + remote: RadiclePeer, + rid: string, + cobId: string, +) { + await peer.waitForEvent( + event => + event.type === "refsFetched" && + event.rid === rid && + event.remote === remote.nodeId && + event.updated.some(update => + Object.values(update).some(ref => ref.name.endsWith(`/${cobId}`)), + ), + 15_000, + ); +} diff --git a/desktop/tests/support/fixtures.ts b/desktop/tests/support/fixtures.ts new file mode 100644 index 000000000..b451787b1 --- /dev/null +++ b/desktop/tests/support/fixtures.ts @@ -0,0 +1,857 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { PeerManager, RadiclePeer } from "./peerManager.js"; +import type { Page } from "@playwright/test"; + +import * as Fs from "node:fs/promises"; +import * as Os from "node:os"; +import * as Path from "node:path"; +import type * as Stream from "node:stream"; +import { stripVTControlCharacters } from "node:util"; + +import { expect, test as base } from "@playwright/test"; +import * as issue from "@tests/support/cobs/issue.js"; +import * as patch from "@tests/support/cobs/patch.js"; +import { createPeerManager } from "@tests/support/peerManager.js"; +import { createRepo } from "@tests/support/repo.js"; +import { createOptions, supportDir, tmpDir } from "@tests/support/support.js"; +import chalk from "chalk"; +import { execa } from "execa"; +import waitOn from "wait-on"; + +import { formatOid } from "@app/lib/utils.js"; + +export { expect }; + +const fixturesDir = Path.resolve(supportDir, "..", "./fixtures"); + +export const test = base.extend<{ + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + forAllTests: void; + stateDir: string; + peerManager: PeerManager; + peer: RadiclePeer; + sshAuthSock: string; + outputLog: Stream.Writable; +}>({ + forAllTests: [ + async ({ outputLog, page }, use) => { + // Otherwise the app opens the guide on its first launch, depending on + // how fast startup is. + await page.addInitScript(() => + localStorage.setItem("appFirstLaunch", "false"), + ); + + const browserLabel = " ".repeat(23) + "→ " + chalk.blue("browser") + ": "; + page.on("console", msg => { + // Ignore common console logs that we don't care about. + if ( + msg.text().startsWith("[vite] connected.") || + msg.text().startsWith("[vite] connecting...") || + msg.text().startsWith("Not able to parse url") || + msg + .text() + .includes("Please make sure it wasn't preloaded for nothing.") + ) { + return; + } + log(msg.text(), browserLabel, outputLog); + }); + + if (!process.env.CONTINUE_ON_ERRORS) { + page.on("pageerror", msg => { + if (tearingDown.has(page)) { + return; + } + if (navigating.has(page) && isCancelledFetch(msg)) { + return; + } + // Only says some resize notifications waited for the next frame. + if (msg.message.startsWith("ResizeObserver loop")) { + return; + } + expect( + false, + `Test failed because there was a console error in the app: ${msg}`, + ).toBeTruthy(); + }); + } + + const playwrightLabel = + " ".repeat(23) + "→ " + chalk.yellowBright("playwright") + ": "; + + function isLocalhost(url: URL): boolean { + // A blob URL has no hostname of its own; it belongs to the origin + // that created it. + if (url.protocol === "blob:") { + return isLocalhost(new URL(url.pathname)); + } + return url.hostname === "localhost" || url.hostname === "127.0.0.1"; + } + + await page.route( + url => !isLocalhost(url), + route => { + log( + `Aborted remote request: ${route.request().url()}`, + playwrightLabel, + outputLog, + ); + return route.abort(); + }, + ); + + await page.route( + url => + url.href.startsWith("https://www.gravatar.com/avatar/") || + (url.href.endsWith(".png") && !isLocalhost(url)), + route => { + return route.fulfill({ + status: 200, + path: "./public/radicle.svg", + }); + }, + ); + + await use(); + }, + { scope: "test", auto: true }, + ], + + outputLog: async ({ stateDir }, use) => { + const logFile = await Fs.open(Path.join(stateDir, "test.log"), "a"); + await use(logFile.createWriteStream()); + await logFile.close(); + }, + + peerManager: async ({ stateDir, outputLog, page }, use) => { + const peerManager = await createPeerManager({ + dataDir: Path.resolve(Path.join(stateDir, "peers")), + outputLog, + }); + await use(peerManager); + // The page outlives the peers and keeps polling them, so its requests + // fail once they shut down. The test is already over by then. + tearingDown.add(page); + await peerManager.shutdown(); + }, + + peer: async ({ page, peerManager }, use) => { + const peer = await peerManager.createPeer({ + name: "httpd", + gitOptions: gitOptions["bob"], + }); + + await peer.startNode(); + await peer.startHttpd(); + await useBackend(page, peer); + + await use(peer); + }, + + // eslint-disable-next-line no-empty-pattern + sshAuthSock: async ({}, use) => { + const dir = await Fs.mkdtemp(Path.join(Os.tmpdir(), "radicle-ssh-agent-")); + const socket = Path.join(dir, "agent.sock"); + const agent = execa("ssh-agent", ["-D", "-a", socket]); + agent.catch(() => undefined); + await waitOn({ resources: [`socket:${socket}`], timeout: 5000 }); + + await use(socket); + + agent.kill(); + await Fs.rm(dir, { recursive: true, force: true }); + }, + + // eslint-disable-next-line no-empty-pattern + stateDir: async ({}, use, testInfo) => { + const stateDir = testInfo.outputDir; + await Fs.rm(stateDir, { recursive: true, force: true }); + await Fs.mkdir(stateDir, { recursive: true }); + + await use(stateDir); + if ( + process.env.CI && + (testInfo.status === "passed" || testInfo.status === "skipped") + ) { + await Fs.rm(stateDir, { recursive: true }); + } + }, +}); + +const navigating = new WeakSet(); +const tearingDown = new WeakSet(); + +// WebKit reports a fetch that was cancelled because the page went away as an +// access control failure, or as a failed load. +function isCancelledFetch(error: Error) { + return /Fetch API cannot load .* due to access control checks|Load failed/.test( + error.message + error.stack, + ); +} + +async function navigate(page: Page, action: () => Promise) { + navigating.add(page); + try { + await action(); + // WebKit reports fetches the old page had in flight after the new one + // has loaded, so keep ignoring them until the network settles. + await page.waitForLoadState("networkidle"); + } finally { + navigating.delete(page); + } +} + +export async function reload(page: Page) { + await navigate(page, () => page.reload()); +} + +// Use instead of `page.goto` when the page already shows the app. +export async function goto(page: Page, url: string) { + await navigate(page, () => page.goto(url)); +} + +// Runs `action` and waits for the backend command it sends to finish. The UI +// often updates before the write lands, and a reload would cancel it. +export async function waitForCommand( + page: Page, + command: string, + action: () => Promise, +) { + const response = page.waitForResponse( + response => new URL(response.url()).pathname === `/${command}`, + ); + await action(); + await response; +} + +// Call before the first `page.goto`. +export async function useBackend(page: Page, peer: RadiclePeer) { + await page.addInitScript(port => { + window.__TEST_HTTP_API_PORT__ = port; + }, peer.httpdBaseUrl.port); +} + +function log(text: string, label: string, outputLog: Stream.Writable) { + const output = text + .split("\n") + .map(line => `${label}${chalk.dim(line)}`) + .join("\n"); + + outputLog.write(`${stripVTControlCharacters(output)}\n`); + if (!process.env.CI) { + console.log(output); + } +} + +export async function createCobsFixture( + peerManager: PeerManager, + peer: RadiclePeer, +) { + await peer.rad(["follow", peer.nodeId, "--alias", "palm"]); + await Fs.mkdir(Path.join(tmpDir, "repos", "cobs"), { recursive: true }); + const { repoFolder, rid, defaultBranch } = await createRepo(peer, { + name: "cobs", + }); + const eve = await peerManager.createPeer({ + name: "eve", + gitOptions: gitOptions["eve"], + }); + await eve.startNode({ + node: { ...defaultConfig.node, connect: [peer.address], alias: "eve" }, + }); + await eve.rad(["clone", rid], { cwd: eve.checkoutPath }); + + const issueOne = await issue.create( + peer, + "This `title` has **markdown**", + "This is a description\nWith some multiline text.", + ["bug", "feature-request"], + { cwd: repoFolder }, + ); + await peer.rad( + ["issue", "react", issueOne, "--emoji", "👍", "--to", issueOne], + { + cwd: repoFolder, + }, + ); + await peer.rad( + ["issue", "react", issueOne, "--emoji", "🎉", "--to", issueOne], + { + cwd: repoFolder, + }, + ); + await peer.rad( + ["issue", "assign", issueOne, "--add", `did:key:${peer.nodeId}`], + createOptions(repoFolder, 1), + ); + const { stdout: commentIssueOne } = await peer.rad( + [ + "issue", + "comment", + issueOne, + "--message", + "This is a multiline comment\n\nWith some more text.", + "--quiet", + "--no-announce", + ], + createOptions(repoFolder, 2), + ); + await peer.rad( + ["issue", "react", issueOne, "--emoji", "🙏", "--to", commentIssueOne], + { + cwd: repoFolder, + }, + ); + const { stdout: replyIssueOne } = await peer.rad( + [ + "issue", + "comment", + issueOne, + "--message", + "This is a reply, to a first comment.", + "--reply-to", + commentIssueOne, + "--quiet", + "--no-announce", + ], + createOptions(repoFolder, 3), + ); + await peer.rad( + ["issue", "react", issueOne, "--emoji", "🚀", "--to", replyIssueOne], + { + cwd: repoFolder, + }, + ); + await peer.rad( + [ + "issue", + "comment", + issueOne, + "--message", + "A root level comment after a reply, for margins sake.", + "--quiet", + "--no-announce", + ], + createOptions(repoFolder, 4), + ); + + const issueTwo = await issue.create( + peer, + "A closed issue", + "This issue has been closed\n\nsource: [link](https://radicle.xyz)", + [], + { cwd: repoFolder }, + ); + await peer.rad( + ["issue", "state", issueTwo, "--closed"], + createOptions(repoFolder, 1), + ); + + const issueThree = await issue.create( + peer, + "A solved issue", + "This issue has been solved\n\n```js\nconsole.log('hello world')\nconsole.log(\"\")\n```", + [], + { cwd: repoFolder }, + ); + await peer.rad( + ["issue", "state", issueThree, "--solved"], + createOptions(repoFolder, 1), + ); + + const patchOne = await patch.create( + peer, + ["Add README", "This commit adds more information to the README"], + "feature/add-readme", + () => Fs.writeFile(Path.join(repoFolder, "README.md"), "# Cobs Repo"), + ["Let's add a README", "This repo needed a README"], + { cwd: repoFolder }, + ); + const { stdout: commentPatchOne } = await peer.rad( + [ + "patch", + "comment", + patchOne, + "--message", + "I'll review the patch", + "--quiet", + "--no-announce", + ], + createOptions(repoFolder, 1), + ); + await peer.rad( + [ + "patch", + "comment", + patchOne, + "--message", + "Thanks for that!", + "--reply-to", + commentPatchOne, + "--quiet", + "--no-announce", + ], + createOptions(repoFolder, 2), + ); + await peer.rad( + [ + "patch", + "comment", + patchOne, + "--message", + "Yeah no problem!", + "--reply-to", + commentPatchOne, + "--quiet", + "--no-announce", + ], + createOptions(repoFolder, 3), + ); + const { stdout: commentTwo } = await peer.rad( + [ + "patch", + "comment", + patchOne, + "--message", + "Looking good so far", + "--quiet", + "--no-announce", + ], + createOptions(repoFolder, 4), + ); + await peer.rad( + [ + "patch", + "comment", + patchOne, + "--message", + "Thanks again!", + "--reply-to", + commentTwo, + "--quiet", + "--no-announce", + ], + createOptions(repoFolder, 5), + ); + await peer.rad( + ["patch", "review", patchOne, "-m", "LGTM", "--accept"], + createOptions(repoFolder, 6), + ); + await patch.merge( + peer, + defaultBranch, + "feature/add-readme", + createOptions(repoFolder, 7), + ); + + const patchTwo = await patch.create( + peer, + ["Add subtitle to README"], + "feature/add-more-text", + () => Fs.appendFile(Path.join(repoFolder, "README.md"), "\n\n## Subtitle"), + [], + { cwd: repoFolder }, + ); + await peer.rad( + [ + "patch", + "review", + patchTwo, + "-m", + "Not the README we are looking for", + "--reject", + ], + createOptions(repoFolder, 1), + ); + + const patchThree = await patch.create( + peer, + [ + "Rewrite subtitle to README", + "This was really necessary", + "Blazingly fast", + ], + "feature/better-subtitle", + () => Fs.appendFile(Path.join(repoFolder, "README.md"), "\n\n## Better?"), + [ + "Taking another stab at the README", + "This is a big improvement over the last one", + "Hopefully **this** is the last time", + ], + { cwd: repoFolder }, + ); + await peer.rad( + ["patch", "label", patchThree, "--add", "documentation"], + createOptions(repoFolder, 1), + ); + await eve.rad( + ["patch", "review", patchThree, "-m", "This looks better", "--accept"], + createOptions(repoFolder, 2), + ); + // Don't let palm act on the patch before it has eve's review. + await peer.waitForEvent( + event => + event.type === "refsFetched" && + event.rid === rid && + event.remote === eve.nodeId && + event.updated.some(update => + Object.values(update).some(ref => + ref.name.endsWith(`/xyz.radicle.patch/${patchThree}`), + ), + ), + 10_000, + ); + await Fs.appendFile( + Path.join(repoFolder, "README.md"), + "\n\nHad to push a new revision", + ); + await peer.git(["add", "."], { cwd: repoFolder }); + await peer.git(["commit", "-m", "Add more text"], { cwd: repoFolder }); + await peer.git( + [ + "push", + "-o", + "patch.message=Most of the missing README text was caused by the git-daemon not having a writers block. It seems like using an RNG was not a good enough solution.", + "-o", + "patch.message=After this change, the README seem to be written correctly", + "rad", + "feature/better-subtitle", + ], + createOptions(repoFolder, 3), + ); + await peer.rad( + [ + "patch", + "review", + patchThree, + "-m", + "No this doesn't look better", + "--reject", + ], + createOptions(repoFolder, 2), + ); + + const patchFour = await patch.create( + peer, + ["This patch is going to be archived"], + "feature/archived", + () => Fs.writeFile(Path.join(repoFolder, "CONTRIBUTING.md"), "# Archived"), + [], + { cwd: repoFolder }, + ); + await peer.rad( + [ + "patch", + "review", + patchFour, + "-m", + "No review due to patch being archived.", + "--accept", + ], + createOptions(repoFolder, 1), + ); + await peer.rad(["patch", "archive", patchFour], createOptions(repoFolder, 2)); + + const patchFive = await patch.create( + peer, + ["This patch is going to be reverted to draft"], + "feature/draft", + () => Fs.writeFile(Path.join(repoFolder, "LICENSE"), "Draft"), + [], + { cwd: repoFolder }, + ); + await peer.rad( + ["patch", "ready", patchFive, "--undo"], + createOptions(repoFolder, 1), + ); +} + +export async function createMarkdownFixture(peer: RadiclePeer) { + await Fs.mkdir(Path.join(tmpDir, "repos", "markdown"), { recursive: true }); + await execa("tar", [ + "-xf", + Path.join(fixturesDir, "repos", "markdown.tar.bz2"), + "-C", + Path.join(tmpDir, "repos", "markdown"), + ]); + const { repoFolder } = await createRepo(peer, { name: "markdown" }); + await Fs.cp(Path.join(tmpDir, "repos", "markdown"), repoFolder, { + recursive: true, + }); + + await peer.git(["add", "."], { cwd: repoFolder }); + const commitMessage = `Add Markdown cheat sheet + + Borrowed from [Adam Pritchard][ap]. + No modifications were made. + + [ap]: https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet`; + await peer.git(["commit", "-m", commitMessage], { + cwd: repoFolder, + }); + await peer.git(["push", "rad"], { cwd: repoFolder }); + await issue.create( + peer, + "This `title` has **markdown**", + 'This is a description\n\nWith some multiline text.\n\n```\n23-11-06 10:19 ➜ radicle-jetbrains-plugin git:(main) rad id update --title "Godify jchrist" --description "where jchrist ascends to a god of this project" --delegate did:key:z6MkpaATbhkGbSMysNomYTFVvKG5bnNKYZ2cCamfoHzX9SnL --threshold 1\n\n✓ Identity revision 029837dde8f5c49704e50a19cd709473ac66a456 created\n```', + ["bug", "feature-request"], + { cwd: repoFolder }, + ); +} + +export async function createAsciidocFixture(peer: RadiclePeer) { + const { repoFolder } = await createRepo(peer, { name: "asciidoc" }); + + // An older version of a file, so a test can show the same path at two + // revisions. + await Fs.writeFile( + Path.join(repoFolder, "notes.txt"), + "An earlier version of the notes.\n", + ); + await peer.git(["add", "."], { cwd: repoFolder }); + await peer.git(["commit", "-m", "Add notes"], { cwd: repoFolder }); + + await Fs.writeFile( + Path.join(repoFolder, "README.adoc"), + [ + "= AsciiDoc Fixture", + "", + "A paragraph with a `code span` and a https://radicle.xyz[link].", + "", + "== Rendering", + "", + "[source,rust]", + "----", + "fn main() {}", + "----", + "", + "|===", + "|Column A |Column B", + "", + "|1 |2", + "|===", + "", + // An AsciiDoc cell holding an unbreakable token: the column must stay + // inside the viewport instead of widening the page. + '[cols="2a,1",options="header"]', + "|===", + "|URI |Refers to", + "", + "|", + "....", + `rad:z${"L".repeat(120)}`, + "....", + "|A repository", + "|===", + "", + "NOTE: An admonition.", + "", + "A statement.footnote:[A footnote.]", + "", + "== Includes", + "", + "include::_include.adoc[tag=alpha]", + "", + // A remote include must be refused without any network request. + "include::https://example.invalid/remote.adoc[]", + "", + "A link to link:notes.txt[the notes] in the same repository.", + "", + "== Untrusted markup", + "", + // A passthrough block is the one place AsciiDoc emits author-supplied + // HTML verbatim, so it is what the sanitizer has to catch. + "++++", + '', + '', + '', + "link", + "
click
", + "++++", + "", + ].join("\n"), + ); + + await Fs.writeFile( + Path.join(repoFolder, "notes.txt"), + "Notes from a sibling file.\n", + ); + + await Fs.writeFile( + Path.join(repoFolder, "_include.adoc"), + [ + "// tag::alpha[]", + "Alpha section text.", + "// end::alpha[]", + "// tag::beta[]", + "Beta section text.", + "// end::beta[]", + "", + ].join("\n"), + ); + + // Each level includes the next one twice, so without a read budget the + // reads grow exponentially with Asciidoctor's include depth. + await Fs.writeFile( + Path.join(repoFolder, "bomb.adoc"), + ["= Include Bomb", "", "include::_bomb.adoc[]", ""].join("\n"), + ); + await Fs.writeFile( + Path.join(repoFolder, "_bomb.adoc"), + ["include::_bomb.adoc[]", "include::_bomb.adoc[]", ""].join("\n"), + ); + + await peer.git(["add", "."], { cwd: repoFolder }); + await peer.git(["commit", "-m", "Add AsciiDoc readme"], { cwd: repoFolder }); + await peer.git(["push", "rad"], { cwd: repoFolder }); +} + +export const aliceMainHead = "7babd25a74eb3752ec24672b5edf0e7ecb4daf24"; +export const aliceMainCommitMessage = + "Verify that crate::DoubleColon::should_work()"; +export const aliceMainCommitCount = 8; +export const aliceRemote = + "did:key:z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK"; +export const shortAliceHead = formatOid(aliceMainHead); +export const bobRemote = + "did:key:z6Mkg49NtQR2LyYRDCQFK4w1VVHqhypZSSRo7HsyuN7SV7v5"; +export const bobHead = "82f570ec909e77c7e1bb764f1429b1e01b1b4a90"; +export const bobMainCommitCount = 9; +export const shortBobHead = formatOid(bobHead); +export const cobRid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe"; +export const markdownRid = "rad:z2tchH2Ti4LxRKdssPQYs6VHE5rsg"; +export const shortNodeRemote = "z6MktU…1xB22S"; +export const gitOptions = { + alice: { + GIT_AUTHOR_NAME: "Alice Liddell", + GIT_AUTHOR_EMAIL: "alice@radicle.xyz", + GIT_AUTHOR_DATE: "1727621093", + GIT_COMMITTER_NAME: "Alice Liddell", + GIT_COMMITTER_EMAIL: "alice@radicle.xyz", + GIT_COMMITTER_DATE: "1727621093", + }, + bob: { + GIT_AUTHOR_NAME: "Bob Belcher", + GIT_AUTHOR_EMAIL: "bob@radicle.xyz", + GIT_AUTHOR_DATE: "1727621093", + GIT_COMMITTER_NAME: "Bob Belcher", + GIT_COMMITTER_EMAIL: "bob@radicle.xyz", + GIT_COMMITTER_DATE: "1730220293", + }, + + eve: { + GIT_AUTHOR_NAME: "Eve Johnson", + GIT_AUTHOR_EMAIL: "eve@radicle.xyz", + GIT_AUTHOR_DATE: "1727621093", + GIT_COMMITTER_NAME: "Eve Johnson", + GIT_COMMITTER_EMAIL: "eve@radicle.xyz", + GIT_COMMITTER_DATE: "1730220293", + }, +}; +export const defaultConfig: Config = { + publicExplorer: "https://radicle.network/nodes/$host/$rid$path", + preferredSeeds: [], + web: { + pinned: { + repositories: [], + }, + }, + cli: { + hints: true, + }, + node: { + alias: "alice", + listen: [], + peers: { + type: "dynamic", + }, + connect: [], + externalAddresses: [], + network: "main", + log: "INFO", + relay: "auto", + limits: { + routingMaxSize: 1000, + routingMaxAge: 604800, + gossipMaxAge: 1209600, + fetchConcurrency: 1, + maxOpenFiles: 4096, + rate: { + inbound: { + fillRate: 5.0, + capacity: 1024, + }, + outbound: { + fillRate: 10.0, + capacity: 2048, + }, + }, + connection: { + inbound: 128, + outbound: 16, + }, + }, + workers: 8, + seedingPolicy: { + default: "block", + }, + }, +}; + +export type Config = { + publicExplorer: string; + preferredSeeds: string[]; + cli: { hints: boolean }; + web: { + pinned: { + repositories: string[]; + }; + imageUrl?: string; + name?: string; + description?: string; + }; + node: NodeConfig; +}; + +export type NodeConfig = { + alias: string; + peers: { type: "static" } | { type: "dynamic" }; + listen: string[]; + connect: string[]; + externalAddresses: string[]; + proxy?: string; + onion?: { mode: "proxy"; address: string } | { mode: "forward" }; + log: "ERROR" | "WARN" | "INFO" | "DEBUG" | "TRACE"; + network: "main" | "test"; + relay: "always" | "never" | "auto"; + limits: { + routingMaxSize: number; + routingMaxAge: number; + fetchConcurrency: number; + gossipMaxAge: number; + maxOpenFiles: number; + rate: { + inbound: { + fillRate: number; + capacity: number; + }; + outbound: { + fillRate: number; + capacity: number; + }; + }; + connection: { + inbound: number; + outbound: number; + }; + }; + workers: number; + seedingPolicy: + | { + default: "block"; + } + | { + default: "allow"; + scope: "followed" | "all"; + }; +}; diff --git a/desktop/tests/support/globalSetup.ts b/desktop/tests/support/globalSetup.ts new file mode 100644 index 000000000..37be77721 --- /dev/null +++ b/desktop/tests/support/globalSetup.ts @@ -0,0 +1,110 @@ +import * as Fs from "node:fs"; +import * as Path from "node:path"; + +import { + createAsciidocFixture, + createCobsFixture, + createMarkdownFixture, + defaultConfig, + gitOptions, +} from "@tests/support/fixtures.js"; +import { createPeerManager } from "@tests/support/peerManager.js"; +import { + assertBinariesInstalled, + heartwoodRelease, + removeWorkspace, + tmpDir, +} from "@tests/support/support.js"; +import { execa } from "execa"; + +const heartwoodBinaryPath = Path.join( + tmpDir, + "bin", + "heartwood", + heartwoodRelease, +); + +process.env.PATH = [heartwoodBinaryPath, process.env.PATH].join(Path.delimiter); + +export default async function globalSetup(): Promise<() => void> { + try { + await assertBinariesInstalled("rad", heartwoodRelease, heartwoodBinaryPath); + } catch (error) { + console.error(error); + console.log(""); + console.log("To download the required test binaries, run:"); + console.log(" 👉 ./scripts/install-binaries"); + console.log(""); + process.exit(1); + } + + if (!process.env.SKIP_FIXTURE_CREATION) { + console.log( + "Recreating static fixtures. Set SKIP_FIXTURE_CREATION to skip this", + ); + await removeWorkspace(); + } + + console.log("Building test-http-api"); + await execa("cargo", [ + "build", + "--manifest-path", + "./crates/test-http-api/Cargo.toml", + ]); + // The job COB CLI, at the version of radicle-job the app is built with. + await execa("cargo", ["build", "-p", "radicle-job", "--bin", "rad-job"]); + + // Keeps test keys out of the developer's ssh-agent. + delete process.env.SSH_AUTH_SOCK; + delete process.env.SSH_AGENT_PID; + + const peerManager = await createPeerManager({ + dataDir: Path.resolve(tmpDir, "peers"), + outputLog: Fs.createWriteStream( + Path.resolve(tmpDir, "globalPeerManager.log"), + ) + // Workaround for fixing MaxListenersExceededWarning. + // Since every prefixOutput stream adds stream listeners that don't autoClose. + // TODO: We still seem to have some descriptors left open when running vitest, which we should handle. + .setMaxListeners(16), + }); + + const palm = await peerManager.createPeer({ + name: "palm", + gitOptions: gitOptions["alice"], + }); + + if (!process.env.SKIP_FIXTURE_CREATION) { + await palm.startNode({ + node: { + ...defaultConfig.node, + seedingPolicy: { default: "allow", scope: "all" }, + alias: "palm", + }, + }); + + try { + console.log("Creating markdown fixture"); + await createMarkdownFixture(palm); + console.log("Creating asciidoc fixture"); + await createAsciidocFixture(palm); + console.log("Creating cobs fixture"); + await createCobsFixture(peerManager, palm); + console.log("All fixtures created"); + } catch (error) { + console.log(""); + console.log("Not able to create the required fixtures."); + console.log("Make sure you are not using binaries compiled for release."); + console.log(""); + console.log(error); + console.log(""); + process.exit(1); + } + await palm.stopNode(); + } + await palm.startHttpd(parseInt(process.env.VITE_TEST_HTTP_API_PORT ?? "0")); + + return async () => { + await peerManager.shutdown(); + }; +} diff --git a/desktop/tests/support/heartwood-release b/desktop/tests/support/heartwood-release new file mode 100644 index 000000000..587c5f0c7 --- /dev/null +++ b/desktop/tests/support/heartwood-release @@ -0,0 +1 @@ +1.10.3 diff --git a/desktop/tests/support/logPrefix.ts b/desktop/tests/support/logPrefix.ts new file mode 100644 index 000000000..c540355cc --- /dev/null +++ b/desktop/tests/support/logPrefix.ts @@ -0,0 +1,41 @@ +import type { ColorName } from "chalk"; + +import chalk from "chalk"; + +const PADDING_WIDTH = 12; + +// The order here is important, we want successive prefixes to have +// high contrast. +const availableColors: ColorName[] = [ + "blue", + "yellowBright", + "greenBright", + "gray", + "green", + "blueBright", + "redBright", + "white", + "yellow", + "red", + "magenta", + "cyan", +]; + +let nextColorIndex = 0; + +const assignedColors: Record = {}; + +export function logPrefix(label: string): string { + if (assignedColors[label] === undefined) { + const color = availableColors[nextColorIndex]; + nextColorIndex = (nextColorIndex + 1) % availableColors.length; + assignedColors[label] = color; + } + + // We reset colors at the beginning of each line to avoid styles from previous + // lines messing up prefix colors. This is noticeable in rust stack traces + // where the `in` and `with` keywords have a white background color. + return chalk.reset[assignedColors[label]]( + `${label.padEnd(PADDING_WIDTH)} | `, + ); +} diff --git a/desktop/tests/support/peerManager.ts b/desktop/tests/support/peerManager.ts new file mode 100644 index 000000000..043f810f0 --- /dev/null +++ b/desktop/tests/support/peerManager.ts @@ -0,0 +1,481 @@ +import type * as Execa from "execa"; + +import * as Fs from "node:fs/promises"; +import * as Os from "node:os"; +import * as Path from "node:path"; +import * as readline from "node:readline/promises"; +import * as Stream from "node:stream"; +import * as Util from "node:util"; + +import { type Config, defaultConfig } from "@tests/support/fixtures.js"; +import { logPrefix } from "@tests/support/logPrefix.js"; +import { randomTag, testHttpApiBinary } from "@tests/support/support.js"; +import { execa } from "execa"; +import getPort from "get-port"; +import matches from "lodash/matches.js"; +import waitOn from "wait-on"; + +import { sleep } from "@app/lib/sleep.js"; + +export type RefsUpdate = + | { updated: { name: string; old: string; new: string } } + | { created: { name: string; oid: string } } + | { deleted: { name: string; oid: string } } + | { skipped: { name: string; oid: string } }; + +export type NodeEvent = + | { + type: "refsFetched"; + remote: string; + rid: string; + updated: RefsUpdate[]; + } + | { + type: "refsSynced"; + remote: string; + rid: string; + } + | { + type: "seedDiscovered"; + rid: string; + nid: string; + } + | { + type: "seedDropped"; + nid: string; + rid: string; + } + | { + type: "peerConnected"; + nid: string; + } + | { + type: "peerDisconnected"; + nid: string; + reason: string; + }; + +export interface RoutingEntry { + nid: string; + rid: string; +} + +interface PeerOptions { + // Name for easy identification. Used on file system and in logs. + name: string; + gitOptions?: Record; + // Without one the keys are unencrypted and need no ssh-agent. + passphrase?: string; + sshAuthSock?: string; +} + +interface PeerManagerParams extends PeerOptions { + dataPath: string; + radSeed: string; + outputLog: Stream.Writable; +} + +export interface PeerManager { + createPeer(params: PeerOptions): Promise; + /** + * Kill all processes spawned by any of the peers + */ + shutdown(): Promise; +} + +export async function createPeerManager(createParams: { + dataDir: string; + outputLog?: Stream.Writable; +}): Promise { + let outputLog: Stream.Writable; + let outputLogFile: Fs.FileHandle; + if (createParams.outputLog) { + outputLog = createParams.outputLog; + } else { + outputLogFile = await Fs.open( + Path.join(createParams.dataDir, "peerManager.log"), + "a", + ); + outputLog = outputLogFile.createWriteStream(); + } + + const peers: RadiclePeer[] = []; + return { + async createPeer(params) { + const peer = await RadiclePeer.create({ + dataPath: createParams.dataDir, + ...params, + radSeed: Array(64) + .fill((peers.length + 1).toString()) + .join(""), + outputLog, + }); + peers.push(peer); + + return peer; + }, + async shutdown() { + await Promise.all(peers.map(peer => peer.shutdown())); + }, + }; +} + +// Specialize the return type of `execa()` to guarantee that `stdout` and +// `stderr` are strings. +type SpawnResult = Execa.ResultPromise< + SpawnOptions & { + stdout: (line: unknown) => AsyncGenerator; + stderr: (line: unknown) => AsyncGenerator; + encoding: "utf8"; + } +>; + +// `Execa.Options` is a union of the text and binary encoding variants. Exclude +// the binary variant, which requires `encoding`, so that the omitted result +// stays assignable to `Execa.Options`. +export type SpawnOptions = Omit< + Exclude, + "stdin" | "stdout" | "stderr" | "lines" | "encoding" +>; + +export interface BaseUrl { + hostname: string; + port: number; + scheme: string; +} + +export class RadiclePeer { + public checkoutPath: string; + public nodeId: string; + + #radSeed: string; + #socket: string; + #radHome: string; + #eventRecords: NodeEvent[] = []; + #outputLog: Stream.Writable; + #gitOptions?: Record; + #passphrase: string; + #sshAuthSock?: string; + #listenSocketAddr?: string; + #httpdBaseUrl?: BaseUrl; + #nodeProcess?: SpawnResult; + // Name for easy identification. Used on file system and in logs. + #name: string; + #childProcesses: SpawnResult[] = []; + + private constructor(props: { + checkoutPath: string; + nodeId: string; + radSeed: string; + socket: string; + gitOptions?: Record; + radHome: string; + logFile: Stream.Writable; + name: string; + passphrase: string; + sshAuthSock?: string; + }) { + this.checkoutPath = props.checkoutPath; + this.nodeId = props.nodeId; + this.#gitOptions = props.gitOptions; + this.#radSeed = props.radSeed; + this.#socket = props.socket; + this.#radHome = props.radHome; + this.#outputLog = props.logFile; + this.#name = props.name; + this.#passphrase = props.passphrase; + this.#sshAuthSock = props.sshAuthSock; + } + + public async waitForEvent( + searchEvent: NodeEvent | ((event: NodeEvent) => boolean), + timeoutInMs: number, + ) { + const start = new Date().getTime(); + const predicate = + typeof searchEvent === "function" ? searchEvent : matches(searchEvent); + + while (true) { + if (this.#eventRecords.find(predicate)) { + return; + } + if (new Date().getTime() - start > timeoutInMs) { + throw Error( + `Timeout waiting for event on node ${this.#name} ${Util.inspect( + searchEvent, + { depth: null }, + )}`, + ); + } + await sleep(100); + } + } + + public static async create({ + dataPath, + name, + gitOptions, + radSeed: node, + outputLog: logFile, + passphrase = "", + sshAuthSock, + }: PeerManagerParams): Promise { + const checkoutPath = Path.join(dataPath, name, "copy"); + await Fs.mkdir(checkoutPath, { recursive: true }); + const radHome = Path.join(dataPath, name, "home"); + await Fs.mkdir(radHome, { recursive: true }); + + const socketDir = await Fs.mkdtemp( + Path.join(Os.tmpdir(), `radicle-${randomTag()}`), + ); + const socket = Path.join(socketDir, "control.sock"); + + /* eslint-disable @typescript-eslint/naming-convention */ + const env = { + ...gitOptions, + RAD_HOME: radHome, + RAD_PASSPHRASE: passphrase, + RAD_KEYGEN_SEED: node, + RAD_SOCKET: socket, + SSH_AUTH_SOCK: sshAuthSock, + }; + /* eslint-enable @typescript-eslint/naming-convention */ + + await execa("rad", ["auth", "--alias", name], { env }); + const { stdout: nodeId } = await execa("rad", ["self", "--nid"], { env }); + + return new RadiclePeer({ + checkoutPath, + gitOptions, + radSeed: node, + socket, + nodeId, + radHome, + logFile, + name, + passphrase, + sshAuthSock, + }); + } + + /** + Removes the peers identity file from the `ssh-agent` + **/ + public async logOut(): Promise { + await this.spawn("ssh-add", ["-d", `${this.#radHome}/keys/radicle.pub`]); + } + + public async authenticate(): Promise { + await this.spawn("rad", ["auth"]); + } + + // Without a port the OS picks a free one, which the server reports back. + // Choosing one up front raced between workers: the second server failed to + // bind, and its page talked to the other worker's server instead. + public async startHttpd(port?: number): Promise { + const httpd = this.spawn(testHttpApiBinary, [ + "--listen", + `127.0.0.1:${port ?? 0}`, + ]); + if (port) { + await waitOn({ resources: [`tcp:127.0.0.1:${port}`], timeout: 10_000 }); + } else { + port = await reportedPort(httpd); + } + this.#httpdBaseUrl = { hostname: "127.0.0.1", port, scheme: "http" }; + } + + public async startNode(config: Partial = defaultConfig) { + const listenPort = await getPort(); + this.#listenSocketAddr = `0.0.0.0:${listenPort}`; + + await updateConfig(this.#radHome, config); + + this.#nodeProcess = this.spawn("radicle-node", [ + "--listen", + this.#listenSocketAddr, + ]); + + await waitOn({ + resources: [`socket:${this.#socket}`], + timeout: 2000, + }); + + const { stdout } = this.rad(["node", "events"], { + cwd: this.#radHome, + }); + + if (!stdout) { + throw new Error("Could not get stdout to track events"); + } + + readline + .createInterface({ + input: stdout, + terminal: false, + }) + .on("line", line => { + let event; + try { + event = JSON.parse(line); + } catch { + console.log("Error parsing event", line); + return; + } + + this.#eventRecords.push(event); + for (const line of Util.inspect(event, { depth: null }).split("\n")) { + this.#outputLog.write( + `${logPrefix(`${this.#name} node events`)} ${line}\n`, + ); + } + }); + } + + public async stopNode() { + // Don’t leak unhandled rejections when forcefully killing the process + // eslint-disable-next-line @typescript-eslint/no-empty-function + this.#nodeProcess?.catch(() => {}); + this.#nodeProcess?.kill("SIGTERM"); + + await waitOn({ + resources: [`socket:${this.#socket}`], + reverse: true, + timeout: 2000, + }); + } + + /** + * Kill all child processes created with `spawn()`, including the node and + * httpd processes. + */ + public async shutdown() { + // We don’t care about proper cleanup. We just want to make sure that no + // processes are running anymore. + this.#childProcesses.forEach(p => { + // Don’t leak unhandled rejections when forcefully killing the process + // eslint-disable-next-line @typescript-eslint/no-empty-function + p.catch(() => {}); + p.kill("SIGKILL"); + }); + } + + public get address(): string { + if (!this.#listenSocketAddr) { + throw new Error("Remote node has no listen addr yet"); + } + return `${this.nodeId}@${this.#listenSocketAddr}`; + } + + public uiUrl(): string { + if (!this.#httpdBaseUrl) { + throw new Error("No httpd service running"); + } + + return `/nodes/${this.#httpdBaseUrl.hostname}:${this.#httpdBaseUrl.port}`; + } + + public ridUrl(rid: string): string { + return `/nodes/${this.httpdBaseUrl.hostname}:${this.httpdBaseUrl.port}/${rid}`; + } + + public get httpdBaseUrl(): BaseUrl { + if (!this.#httpdBaseUrl) { + throw new Error("No httpd service running"); + } + + return this.#httpdBaseUrl; + } + + public git(args: string[] = [], opts?: SpawnOptions): SpawnResult { + return this.spawn("git", args, { ...opts }); + } + + public rad(args: string[] = [], opts?: SpawnOptions): SpawnResult { + return this.spawn("rad", args, { ...opts }); + } + + public spawn( + cmd: string, + args: string[] = [], + opts?: SpawnOptions, + ): SpawnResult { + const prefix = logPrefix(`${this.#name} ${cmd}`); + const outputLog = this.#outputLog; + + function* logWithPrefix(line: unknown) { + if (typeof line === "string") { + outputLog.write(`${prefix} ${line}\n`, "utf8"); + } + yield line; + } + + /* eslint-disable @typescript-eslint/naming-convention */ + const childProcess = execa(cmd, args, { + ...opts, + env: { + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + RAD_HOME: this.#radHome, + RAD_PASSPHRASE: this.#passphrase, + SSH_AUTH_SOCK: this.#sshAuthSock, + RAD_LOCAL_TIME: "1671125284", + RAD_KEYGEN_SEED: this.#radSeed, + RAD_SOCKET: this.#socket, + ...opts?.env, + ...this.#gitOptions, + }, + encoding: "utf8", + stdout: logWithPrefix, + stderr: logWithPrefix, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + + this.#childProcesses.push(childProcess); + + return childProcess; + } +} + +async function updateConfig(radHome: string, configParams: Partial) { + const configPath = Path.join(radHome, "config.json"); + const configFile = await Fs.readFile(configPath, "utf-8"); + const config = { + defaultConfig, + ...JSON.parse(configFile), + }; + config.preferredSeeds = []; + config.web = { ...config.web, ...configParams.web }; + config.node = { + ...config.node, + ...configParams.node, + network: "test", + }; + await Fs.writeFile(configPath, JSON.stringify(config), "utf-8"); +} + +async function reportedPort(httpd: SpawnResult): Promise { + if (!httpd.stdout) { + throw new Error("test-http-api has no stdout to read its port from"); + } + const lines = readline.createInterface({ input: httpd.stdout }); + const exited = httpd.then( + () => { + throw new Error("test-http-api exited before it was listening"); + }, + (error: unknown) => { + throw error; + }, + ); + const listening = (async () => { + for await (const line of lines) { + const match = /^listening on .*:(\d+)$/.exec(line); + if (match) { + lines.close(); + return Number(match[1]); + } + } + throw new Error("test-http-api closed its output before it was listening"); + })(); + return Promise.race([listening, exited]); +} diff --git a/desktop/tests/support/repo.ts b/desktop/tests/support/repo.ts new file mode 100644 index 000000000..d5b2227ab --- /dev/null +++ b/desktop/tests/support/repo.ts @@ -0,0 +1,66 @@ +import type { Page } from "@playwright/test"; +import type { RadiclePeer } from "@tests/support/peerManager"; + +import * as Path from "node:path"; + +export async function changeBranch(peer: string, branch: string, page: Page) { + await page.getByTitle("Change branch").click(); + const peerLocator = page.getByLabel("peer-item").filter({ hasText: peer }); + await peerLocator.getByTitle("Expand peer").click(); + await page.getByRole("button", { name: branch }).click(); +} + +// Create a repo using the rad CLI. +export async function createRepo( + peer: RadiclePeer, + { + name, + description = "", + defaultBranch = "main", + visibility = "public", + }: { + name: string; + description?: string; + defaultBranch?: string; + visibility?: "public" | "private"; + }, +): Promise<{ rid: string; repoFolder: string; defaultBranch: string }> { + const repoFolder = Path.join(peer.checkoutPath, name); + + await peer.git(["init", name, "--initial-branch", defaultBranch], { + cwd: peer.checkoutPath, + }); + await peer.git(["commit", "--allow-empty", "--message", "initial commit"], { + cwd: repoFolder, + }); + await peer.rad( + [ + "init", + "--name", + name, + "--default-branch", + defaultBranch, + "--description", + description, + `--${visibility}`, + ], + { + cwd: repoFolder, + }, + ); + + const { stdout: rid } = await peer.rad(["inspect"], { + cwd: repoFolder, + }); + + return { rid, repoFolder, defaultBranch }; +} + +export function extractPatchId(cmdOutput: { stderr: string }) { + const match = cmdOutput.stderr.match(/[0-9a-f]{40}/); + if (match) { + return match[0]; + } else { + throw new Error("Could not get patch id"); + } +} diff --git a/desktop/tests/support/router.ts b/desktop/tests/support/router.ts new file mode 100644 index 000000000..4c1cd7ca4 --- /dev/null +++ b/desktop/tests/support/router.ts @@ -0,0 +1,30 @@ +import type { Page } from "@playwright/test"; + +import { expect, reload } from "@tests/support/fixtures.js"; + +// Reloads the current page and verifies that the URL stays correct +export const expectUrlPersistsReload = async (page: Page) => { + const url = page.url(); + await reload(page); + await expect(page).toHaveURL(url); +}; + +// Navigates back, checks the URL and navigates forward back to the initial page +export const expectBackAndForwardNavigationWorks = async ( + beforeURL: string, + page: Page, +) => { + const currentURL = page.url(); + + await page.goBack(); + await page + .getByRole("progressbar", { name: "Page loading" }) + .waitFor({ state: "hidden" }); + await expect(page).toHaveURL(beforeURL); + await page.goForward(); + + await page + .getByRole("progressbar", { name: "Page loading" }) + .waitFor({ state: "hidden" }); + await expect(page).toHaveURL(currentURL); +}; diff --git a/desktop/tests/support/support.ts b/desktop/tests/support/support.ts new file mode 100644 index 000000000..980b863d0 --- /dev/null +++ b/desktop/tests/support/support.ts @@ -0,0 +1,75 @@ +import type { SpawnOptions } from "@tests/support/peerManager.js"; + +import * as Crypto from "node:crypto"; +import * as Fs from "node:fs/promises"; +import * as Path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { execa } from "execa"; + +// Generate string of 12 random characters with 8 bits of entropy. +export function randomTag(): string { + return Crypto.randomBytes(8).toString("hex"); +} + +export function createOptions(repoFolder: string, days: number): SpawnOptions { + return { + cwd: repoFolder, + // eslint-disable-next-line @typescript-eslint/naming-convention + env: { RAD_LOCAL_TIME: (1671211684 + days * 86400).toString() }, + }; +} + +const filename = fileURLToPath(import.meta.url); +export const supportDir = Path.dirname(filename); +export const tmpDir = Path.resolve(supportDir, "..", "./tmp"); +export const fixturesDir = Path.resolve(supportDir, "..", "./fixtures"); +export const testHttpApiBinary = Path.resolve( + supportDir, + "../..", + process.env.CARGO_TARGET_DIR ?? "target", + "debug", + "test-http-api", +); + +export const radJobBinary = Path.resolve( + supportDir, + "../..", + process.env.CARGO_TARGET_DIR ?? "target", + "debug", + "rad-job", +); +const workspacePaths = [Path.join(tmpDir, "peers"), Path.join(tmpDir, "repos")]; + +export const heartwoodRelease = ( + await Fs.readFile(`${supportDir}/heartwood-release`, "utf8") +).trim(); + +// Assert that binaries are installed and are the correct version. +export async function assertBinariesInstalled( + binary: string, + expectedVersion: string, + expectedPath: string, +): Promise { + const { stdout: which } = await execa("which", [binary]); + if (Path.dirname(which) !== expectedPath) { + throw new Error( + `${binary} path doesn't match used ${binary} binary: ${expectedPath} !== ${which}`, + ); + } + const { stdout: version } = await execa(binary, ["--version"]); + if (!version.includes(expectedVersion)) { + throw new Error( + `${binary} version ${version} does not satisfy ${expectedVersion}`, + ); + } +} + +export async function removeWorkspace(): Promise { + for (const path of workspacePaths) { + await Fs.rm(path, { + recursive: true, + force: true, + }); + } +} diff --git a/desktop/tests/unit/asciidoc.test.ts b/desktop/tests/unit/asciidoc.test.ts new file mode 100644 index 000000000..9a295e047 --- /dev/null +++ b/desktop/tests/unit/asciidoc.test.ts @@ -0,0 +1,21 @@ +import { expect, test, vi } from "vitest"; + +import { renderAsciidoc } from "@app/lib/asciidoc"; + +vi.mock("@app/lib/invoke", () => ({ invoke: vi.fn() })); + +const source = { + rid: "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe", + sha: "a".repeat(40), + path: "docs/index.adoc", +}; + +test.each([ + ["xref:spec.adoc[Spec]", "spec.adoc"], + ["xref:spec.adoc#_details[Spec]", "spec.adoc#_details"], + ["<>", "spec.adoc"], + ["<<_details,Details>>", "#_details"], +])("links %j to %j", async (content, href) => { + const html = await renderAsciidoc(content, source); + expect(html).toContain(`href="${href}"`); +}); diff --git a/desktop/tests/unit/assigneeSuggestions.test.ts b/desktop/tests/unit/assigneeSuggestions.test.ts new file mode 100644 index 000000000..a674aa5ae --- /dev/null +++ b/desktop/tests/unit/assigneeSuggestions.test.ts @@ -0,0 +1,129 @@ +import type { AliasSuggestion } from "@bindings/cob/AliasSuggestion"; + +import { describe, expect, test } from "vitest"; + +import { + matchesAssignee, + rankAssigneeSuggestions, +} from "@app/lib/assigneeSuggestions"; + +const self = "did:key:z6MkSelf"; +const delegate = "did:key:z6MkDelegate"; +const followed = "did:key:z6MkFollowed"; +const known = "did:key:z6MkKnown"; +const plain = "did:key:z6MkPlain"; + +function alias( + did: string, + name: string | undefined, + extra: Partial = {}, +): AliasSuggestion { + return { did, alias: name, followed: false, isSelf: false, ...extra }; +} + +const aliases = [ + alias(plain, undefined), + alias(followed, "fiona", { followed: true }), + alias(known, "kim"), + alias(self, "alice", { isSelf: true }), +]; + +function rank( + overrides: Partial[0]> = {}, +) { + return rankAssigneeSuggestions({ + query: "", + aliases, + delegates: [{ did: delegate, alias: "dave" }], + assignees: [], + limit: 10, + ...overrides, + }); +} + +describe("rankAssigneeSuggestions", () => { + test("orders you, delegates, aliased people, then bare DIDs", () => { + expect(rank().map(({ did }) => did)).toEqual([ + self, + delegate, + followed, + known, + plain, + ]); + }); + + test("badges each group", () => { + expect(rank().map(({ badge }) => badge)).toEqual([ + "you", + "delegate", + "following", + undefined, + undefined, + ]); + }); + + test("leaves out people who are already assigned", () => { + const dids = rank({ assignees: [{ did: self }, { did: delegate }] }).map( + ({ did }) => did, + ); + expect(dids).toEqual([followed, known, plain]); + }); + + test("filters delegates by alias or DID, ignoring case", () => { + const delegates = [{ did: delegate, alias: "dave" }]; + const dids = (query: string) => + rank({ query, aliases: [], delegates }).map(({ did }) => did); + + expect(dids("DAV")).toEqual([delegate]); + expect(dids("z6mkdeleg")).toEqual([delegate]); + expect(dids(" did:key:z6MkDeleg ")).toEqual([delegate]); + expect(dids("zzz")).toEqual([]); + }); + + test("doesn't match delegates on the DID prefix or mid-key", () => { + const delegates = [{ did: delegate, alias: "dave" }]; + const dids = (query: string) => + rank({ query, aliases: [], delegates }).map(({ did }) => did); + + expect(dids("k")).toEqual([]); + expect(dids("did:")).toEqual([]); + expect(dids("Delegate")).toEqual([]); + }); + + test("lists a delegate who is also a search result once", () => { + const result = rank({ + aliases: [alias(delegate, "dave", { followed: true })], + }); + expect(result).toEqual([ + { did: delegate, alias: "dave", badge: "delegate" }, + ]); + }); + + test("marks yourself as you even when you are a delegate", () => { + const result = rank({ + delegates: [{ did: self, alias: "alice" }], + }); + expect(result[0]).toEqual({ did: self, alias: "alice", badge: "you" }); + expect(result.filter(({ did }) => did === self)).toHaveLength(1); + }); + + test("caps the list at the limit", () => { + expect(rank({ limit: 2 })).toHaveLength(2); + }); +}); + +describe("matchesAssignee", () => { + const author = { did: "did:key:z6MkAlice", alias: "alice" }; + + test("matches the alias anywhere and the key from its start", () => { + expect(matchesAssignee("lic", author)).toBe(true); + expect(matchesAssignee("z6mka", author)).toBe(true); + expect(matchesAssignee("did:key:z6MkA", author)).toBe(true); + expect(matchesAssignee("Alice", author)).toBe(true); + }); + + test("rejects other text", () => { + expect(matchesAssignee("bob", author)).toBe(false); + expect(matchesAssignee("key", author)).toBe(false); + }); +}); diff --git a/desktop/tests/unit/cached.test.ts b/desktop/tests/unit/cached.test.ts new file mode 100644 index 000000000..48be42acf --- /dev/null +++ b/desktop/tests/unit/cached.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test, vi } from "vitest"; + +import { cached } from "@app/lib/cached"; + +describe("cached", () => { + test("returns the cached result for the same key", async () => { + const f = vi.fn(async (x: number) => x * 2); + const c = cached(f, x => String(x)); + + expect(await c(1)).toBe(2); + expect(await c(1)).toBe(2); + expect(await c(2)).toBe(4); + expect(f).toHaveBeenCalledTimes(2); + }); + + test("shares a single in-flight request between concurrent callers", async () => { + let resolve: ((value: string) => void) | undefined; + const f = vi.fn( + () => + new Promise(r => { + resolve = r; + }), + ); + const c = cached(f, () => "key"); + + const first = c(); + const second = c(); + resolve?.("done"); + + expect(await first).toBe("done"); + expect(await second).toBe("done"); + expect(f).toHaveBeenCalledTimes(1); + }); + + test("keeps a miss only for missTtl", async () => { + const f = vi.fn(async () => null); + const c = cached(f, () => "key", { max: 10, ttl: 60_000 }, 1); + + await c(); + await c(); + expect(f).toHaveBeenCalledTimes(1); + await new Promise(resolve => setTimeout(resolve, 10)); + await c(); + expect(f).toHaveBeenCalledTimes(2); + }); + + test("keeps a hit for the full ttl", async () => { + const f = vi.fn(async () => "hit"); + const c = cached(f, () => "key", { max: 10, ttl: 60_000 }, 1); + + await c(); + await new Promise(resolve => setTimeout(resolve, 10)); + await c(); + expect(f).toHaveBeenCalledTimes(1); + }); + + test("retries after a rejection", async () => { + const f = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce("ok"); + const c = cached(f, () => "key"); + + await expect(c()).rejects.toThrow("boom"); + expect(await c()).toBe("ok"); + expect(f).toHaveBeenCalledTimes(2); + }); + + test("clear forces a new request", async () => { + const f = vi.fn(async () => "value"); + const c = cached(f, () => "key"); + + await c(); + c.clear(); + await c(); + expect(f).toHaveBeenCalledTimes(2); + }); + + test("evicts the least recently used entry", async () => { + const f = vi.fn(async (x: number) => x); + const c = cached(f, x => String(x), { max: 2 }); + + await c(1); + await c(2); + await c(1); + await c(3); + await c(1); + expect(f).toHaveBeenCalledTimes(3); + await c(2); + expect(f).toHaveBeenCalledTimes(4); + }); + + test("expires entries after the ttl", async () => { + let now = 1000; + const f = vi.fn(async () => "value"); + const c = cached(f, () => "key", { + max: 1, + ttl: 1000, + ttlResolution: 0, + perf: { now: () => now }, + }); + + await c(); + now = 1500; + await c(); + expect(f).toHaveBeenCalledTimes(1); + now = 2500; + await c(); + expect(f).toHaveBeenCalledTimes(2); + }); +}); diff --git a/desktop/tests/unit/ciJobs.test.ts b/desktop/tests/unit/ciJobs.test.ts new file mode 100644 index 000000000..3795eabcb --- /dev/null +++ b/desktop/tests/unit/ciJobs.test.ts @@ -0,0 +1,197 @@ +import type { Job } from "@bindings/repo/Job"; +import type { Run } from "@bindings/repo/Run"; + +import { describe, expect, test } from "vitest"; + +import { + aggregateStatus, + aliasMatchesHost, + groupJobs, + isTerminal, + runLabel, + statusLabel, + totalCounts, +} from "@app/lib/ciJobs"; + +import { author } from "./support/cobs"; + +const ci = author("ci", "ci.example.com"); +const other = author("other", "other"); + +function run(runId: string, props: Partial = {}): Run { + return { + runId, + node: ci, + status: "succeeded", + log: `https://ci.example.com/runs/${runId}`, + ...props, + }; +} + +function job(runs: Run[]): Job { + return { jobId: "job", commit: "c", runs }; +} + +describe("run details", () => { + test("runLabel uses the GitHub Actions run number when there is one", () => { + expect( + runLabel( + run("abcdefghijk"), + new URL("https://github.com/o/r/actions/runs/12345/job/1"), + ), + ).toBe("12345"); + }); + + test("runLabel falls back to a short run id", () => { + expect(runLabel(run("abcdefghijk"), undefined)).toBe("abcdefgh"); + expect( + runLabel(run("abcdefghijk"), new URL("https://github.com/o/r")), + ).toBe("abcdefgh"); + expect( + runLabel( + run("abcdefghijk"), + new URL("https://ci.example.com/actions/runs/12345"), + ), + ).toBe("abcdefgh"); + }); + + test.each([ + ["succeeded", true], + ["failed", true], + ["started", false], + ] as const)("isTerminal(%j) is %j", (status, expected) => { + expect(isTerminal(status)).toBe(expected); + }); +}); + +describe("summaries", () => { + test.each([ + [{ succeeded: 1, failed: 1, started: 1 }, "failed"], + [{ succeeded: 1, failed: 0, started: 1 }, "started"], + [{ succeeded: 2, failed: 0, started: 0 }, "succeeded"], + [{ succeeded: 0, failed: 0, started: 0 }, "succeeded"], + ])("aggregateStatus(%j) is %j", (counts, expected) => { + expect(aggregateStatus(counts)).toBe(expected); + }); + + test("statusLabel names each non-zero count", () => { + expect(statusLabel({ succeeded: 2, failed: 1, started: 3 })).toBe( + "2 passed · 1 failed · 3 running", + ); + expect(statusLabel({ succeeded: 0, failed: 1, started: 0 })).toBe( + "1 failed", + ); + expect(statusLabel({ succeeded: 0, failed: 0, started: 0 })).toBe(""); + }); + + test("totalCounts adds up every group", () => { + const groups = groupJobs([ + job([ + run("a"), + run("b", { status: "failed" }), + run("c", { node: other, status: "started" }), + ]), + ]); + + expect(totalCounts(groups)).toEqual({ + succeeded: 1, + failed: 1, + started: 1, + }); + }); +}); + +describe("aliasMatchesHost", () => { + test.each([ + ["ci.example.com", "ci.example.com", true], + ["example.com", "ci.example.com", true], + ["ci", "ci.example.com", true], + ["CI", "ci.example.com", true], + ["other", "ci.example.com", false], + ["ample.com", "ci.example.com", false], + [undefined, "ci.example.com", false], + ])("alias %j on host %j is %j", (alias, host, expected) => { + expect(aliasMatchesHost(alias, host)).toBe(expected); + }); +}); + +describe("groupJobs", () => { + test("groups runs by node in order of first appearance", () => { + const groups = groupJobs([ + job([run("a"), run("b", { node: other })]), + job([run("c")]), + ]); + + expect(groups.map(g => [g.nodeKey, g.counts.succeeded])).toEqual([ + [ci.did, 2], + [other.did, 1], + ]); + }); + + test("groups a node's runs by log host", () => { + const [group] = groupJobs([ + job([ + run("a"), + run("b", { log: "https://github.com/o/r/actions/runs/7" }), + run("c", { log: "not a url" }), + run("d", { log: "mailto:ci@example.com" }), + ]), + ]); + + expect( + group.hosts.map(h => [h.host, h.runs.map(r => r.run.runId)]), + ).toEqual([ + ["ci.example.com", ["a"]], + ["github.com", ["b"]], + ["(unknown host)", ["c", "d"]], + ]); + expect(group.flatRuns).toBeUndefined(); + }); + + test("lists a single host's runs flat, naming the host unless the alias does", () => { + const [named] = groupJobs([job([run("a")])]); + expect(named.flatRuns?.map(r => r.run.runId)).toEqual(["a"]); + expect(named.inlineHost).toBeUndefined(); + + const [unnamed] = groupJobs([job([run("a", { node: other })])]); + expect(unnamed.inlineHost).toBe("ci.example.com"); + }); + + test("keeps one view per run, letting a finished status win", () => { + const [group] = groupJobs([ + job([run("a", { status: "started" })]), + job([run("a", { status: "failed" })]), + job([run("a", { status: "started" })]), + ]); + + expect(group.hosts[0].runs.map(r => r.run.status)).toEqual(["failed"]); + expect(group.status).toBe("failed"); + }); + + test("keeps the first finished status of a run", () => { + const [group] = groupJobs([ + job([run("a", { status: "failed" })]), + job([run("a", { status: "succeeded" })]), + ]); + + expect(group.hosts[0].runs.map(r => r.run.status)).toEqual(["failed"]); + }); + + test("links only to safe logs, and not to the placeholder host", () => { + const [group] = groupJobs([ + job([ + run("a"), + run("b", { log: "javascript:alert(1)" }), + run("c", { log: "https://no.url.example.com/x" }), + ]), + ]); + + expect( + group.hosts.flatMap(h => h.runs.map(r => [r.run.runId, r.safeLog])), + ).toEqual([ + ["a", "https://ci.example.com/runs/a"], + ["b", undefined], + ["c", undefined], + ]); + }); +}); diff --git a/desktop/tests/unit/cobActivity.test.ts b/desktop/tests/unit/cobActivity.test.ts new file mode 100644 index 000000000..326bf2d94 --- /dev/null +++ b/desktop/tests/unit/cobActivity.test.ts @@ -0,0 +1,163 @@ +import type { Action } from "@bindings/cob/issue/Action"; + +import { describe, expect, test, vi } from "vitest"; + +import { flattenActivity, itemDiff } from "@app/lib/cobActivity"; + +import { author, operation } from "./support/cobs"; + +const alice = author("alice"); + +function op(id: string, timestamp: number, actions: Action[]) { + return { ...operation(alice, timestamp, []), id, actions }; +} + +test("itemDiff lists added and removed items", () => { + expect(itemDiff(["a", "b"], ["b", "c"])).toEqual({ + removed: ["a"], + added: ["c"], + }); + expect(itemDiff([], [])).toEqual({ removed: [], added: [] }); +}); + +test("itemDiff compares objects by key", () => { + // Each action carries its own copies, so the same person is never the same + // object twice. + const previous = [author("alice"), author("bob")]; + const next = [author("bob"), author("carol")]; + + expect(itemDiff(previous, next, a => a.did)).toEqual({ + removed: [author("alice")], + added: [author("carol")], + }); +}); + +describe("flattenActivity", () => { + const none = new Set(); + + test("makes one entry per action with its operation's metadata", () => { + const items = flattenActivity( + [ + op("o1", 10, [ + { type: "lifecycle", state: { status: "closed", reason: "solved" } }, + { type: "assign", assignees: [alice] }, + ]), + ], + { skip: none }, + ); + + expect(items).toEqual([ + { + key: "o1:0", + timestamp: 10, + data: { + type: "lifecycle", + state: { status: "closed", reason: "solved" }, + id: "o1", + author: alice, + timestamp: 10, + previous: undefined, + }, + }, + { + key: "o1:1", + timestamp: 10, + data: { + type: "assign", + assignees: [alice], + id: "o1", + author: alice, + timestamp: 10, + previous: undefined, + }, + }, + ]); + }); + + test("gives each entry the previous action of its type", () => { + const first = { + type: "lifecycle" as const, + state: { status: "open" as const }, + }; + const items = flattenActivity( + [ + op("o1", 1, [first]), + op("o2", 2, [ + { type: "lifecycle", state: { status: "closed", reason: "other" } }, + ]), + ], + { skip: none }, + ); + + expect(items[1].data.previous).toEqual(first); + }); + + test("drops the first edit but diffs later ones against it", () => { + const opening = { type: "edit" as const, title: "Opening title" }; + const items = flattenActivity( + [ + op("o1", 1, [opening]), + op("o2", 2, [{ type: "edit", title: "Renamed" }]), + ], + { skip: none }, + ); + + expect(items.map(i => i.key)).toEqual(["o2:0"]); + expect(items[0].data.previous).toEqual(opening); + }); + + test("drops label actions that change nothing", () => { + const items = flattenActivity( + [ + op("o1", 1, [{ type: "label", labels: ["bug"] }]), + op("o2", 2, [{ type: "label", labels: ["bug"] }]), + op("o3", 3, [{ type: "label", labels: ["bug", "ux"] }]), + op("o4", 4, [{ type: "label", labels: [] }]), + ], + { skip: none }, + ); + + expect(items.map(i => i.key)).toEqual(["o1:0", "o3:0", "o4:0"]); + }); + + test("drops skipped types, which still count as the previous action", () => { + const onSkipped = vi.fn(); + const skipped = { type: "comment" as const, body: "hello" }; + const items = flattenActivity( + [ + op("o1", 1, [skipped]), + op("o2", 2, [{ type: "comment", body: "again" }]), + ], + { skip: new Set(["comment"]), onSkipped }, + ); + + expect(items).toEqual([]); + expect(onSkipped.mock.calls).toEqual([ + [skipped], + [{ type: "comment", body: "again" }], + ]); + }); + + test("passes each entry to onKept as it is made", () => { + const kept: string[] = []; + const items = flattenActivity( + [ + op("o1", 1, [ + { type: "assign", assignees: [] }, + { type: "label", labels: ["x"] }, + ]), + ], + { + skip: none, + onKept: entry => { + kept.push(entry.type); + if (entry.type === "label") entry.labels = ["changed"]; + }, + }, + ); + + expect(kept).toEqual(["assign", "label"]); + // Callers fold later edits into the entry they were handed. + expect(items[1].data).toMatchObject({ labels: ["changed"] }); + }); +}); diff --git a/desktop/tests/unit/cobSearch.test.ts b/desktop/tests/unit/cobSearch.test.ts new file mode 100644 index 000000000..00a501f71 --- /dev/null +++ b/desktop/tests/unit/cobSearch.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "vitest"; + +import { searchCobs } from "@app/lib/cobSearch"; + +import { author } from "./support/cobs"; + +function cob( + id: string, + props: Partial[1][0]> = {}, +) { + return { + id, + title: "Untitled", + labels: [], + assignees: [], + author: author("nobody", null), + ...props, + }; +} + +const cobs = [ + cob("1", { title: "Crash on startup" }), + cob("2", { labels: ["regression", "ui"] }), + cob("3", { assignees: [author("a", null), author("b", "bob")] }), + cob("4", { author: author("c", "carol") }), + cob("deadbeef"), +]; + +test("lists everything for an empty query", () => { + expect(searchCobs("", cobs).map(c => c.id)).toEqual([ + "1", + "2", + "3", + "4", + "deadbeef", + ]); +}); + +test.each([ + ["startup", "1"], + ["regression", "2"], + ["bob", "3"], + ["carol", "4"], + ["deadbeef", "deadbeef"], +])("finds %j in cob %j", (query, id) => { + expect(searchCobs(query, cobs).map(c => c.id)).toEqual([id]); +}); diff --git a/desktop/tests/unit/codeCommentActions.test.ts b/desktop/tests/unit/codeCommentActions.test.ts new file mode 100644 index 000000000..6fca814f2 --- /dev/null +++ b/desktop/tests/unit/codeCommentActions.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import type { CommentOwner } from "@app/lib/codeCommentActions"; +import { commentActions } from "@app/lib/codeCommentActions"; + +import { author } from "./support/cobs"; + +const { invoke, draftReviewStorage } = vi.hoisted(() => ({ + invoke: vi.fn(), + draftReviewStorage: { updateComment: vi.fn(), deleteComment: vi.fn() }, +})); +vi.mock("@app/lib/invoke", () => ({ invoke })); +vi.mock("@app/lib/draftReviewStorage", () => ({ draftReviewStorage })); + +const me = author("me"); +const other = author("other"); +const embeds = [{ name: "a.png", content: "data:image/png;base64," }]; + +const owners: Record = { + d: { kind: "draft", draftId: "draft-1" }, + r: { kind: "review", reviewId: "review-1" }, + v: { kind: "revision", revisionId: "revision-1" }, +}; + +let reload: ReturnType Promise>>; + +function actions(announce = false) { + return commentActions({ + rid: "rad:z1", + patchId: "patch-1", + publicKey: me.did.replace("did:key:", ""), + announce, + ownerOf: id => owners[id], + reload, + }); +} + +function sentAction() { + expect(invoke).toHaveBeenCalledTimes(1); + const [command, args] = invoke.mock.calls[0]; + expect(command).toBe("edit_patch"); + expect(args).toMatchObject({ rid: "rad:z1", cobId: "patch-1" }); + return args.action; +} + +beforeEach(() => { + invoke.mockReset().mockResolvedValue(undefined); + draftReviewStorage.updateComment.mockReset(); + draftReviewStorage.deleteComment.mockReset(); + reload = vi.fn(() => Promise.resolve()); +}); + +test("passes the announce option through", async () => { + await actions(true).deleteComment("r"); + + expect(invoke.mock.calls[0][1].opts).toEqual({ announce: true }); +}); + +describe("editComment", () => { + test("updates a draft comment locally", async () => { + await actions().editComment("d", "new", embeds); + + expect(draftReviewStorage.updateComment).toHaveBeenCalledWith( + "draft-1", + "d", + { body: "new" }, + ); + expect(invoke).not.toHaveBeenCalled(); + expect(reload).toHaveBeenCalledTimes(1); + }); + + test.each([ + [ + "r", + { + type: "review.comment.edit", + review: "review-1", + comment: "r", + body: "new", + embeds, + }, + ], + [ + "v", + { + type: "revision.comment.edit", + revision: "revision-1", + comment: "v", + body: "new", + embeds, + }, + ], + ])("edits comment %s on its owner", async (id, expected) => { + await actions().editComment(id, "new", embeds); + + expect(sentAction()).toEqual(expected); + expect(reload).toHaveBeenCalledTimes(1); + }); +}); + +describe("deleteComment", () => { + test("deletes a draft comment locally", async () => { + await actions().deleteComment("d"); + + expect(draftReviewStorage.deleteComment).toHaveBeenCalledWith( + "draft-1", + "d", + ); + expect(invoke).not.toHaveBeenCalled(); + expect(reload).toHaveBeenCalledTimes(1); + }); + + test.each([ + ["r", { type: "review.comment.redact", review: "review-1", comment: "r" }], + [ + "v", + { type: "revision.comment.redact", revision: "revision-1", comment: "v" }, + ], + ])("redacts comment %s on its owner", async (id, expected) => { + await actions().deleteComment(id); + + expect(sentAction()).toEqual(expected); + }); +}); + +describe("changeCommentStatus", () => { + test.each([ + [true, "review.comment.resolve"], + [false, "review.comment.unresolve"], + ])("resolved=%s sends %s", async (resolved, type) => { + await actions().changeCommentStatus("r", resolved); + + expect(sentAction()).toEqual({ type, review: "review-1", comment: "r" }); + }); + + test.each(["d", "v"])("ignores comment %s outside a review", async id => { + await actions().changeCommentStatus(id, true); + + expect(invoke).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + }); +}); + +describe("reactOnComment", () => { + test.each([ + ["adds", [other], true], + ["removes", [other, me], false], + ])("%s the viewer's reaction", async (_, authors, active) => { + await actions().reactOnComment("r", authors, "🚀"); + + expect(sentAction()).toEqual({ + type: "review.comment.react", + review: "review-1", + comment: "r", + reaction: "🚀", + active, + }); + }); + + test("reacts on a revision comment", async () => { + await actions().reactOnComment("v", [], "🚀"); + + expect(sentAction()).toEqual({ + type: "revision.comment.react", + revision: "revision-1", + comment: "v", + reaction: "🚀", + active: true, + }); + }); + + test("ignores draft comments", async () => { + await actions().reactOnComment("d", [], "🚀"); + + expect(invoke).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + }); +}); + +test.each([ + [ + "editComment", + (a: ReturnType) => a.editComment("x", "", []), + ], + ["deleteComment", (a: ReturnType) => a.deleteComment("x")], + [ + "reactOnComment", + (a: ReturnType) => a.reactOnComment("x", [], "🚀"), + ], +])("%s skips comments without a known owner", async (_, act) => { + await act(actions()); + + expect(invoke).not.toHaveBeenCalled(); + expect(draftReviewStorage.updateComment).not.toHaveBeenCalled(); + expect(draftReviewStorage.deleteComment).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); +}); + +test("reloads and logs when the action fails", async () => { + const error = vi.spyOn(console, "error").mockReturnValue(undefined); + invoke.mockRejectedValue(new Error("boom")); + + await actions().deleteComment("r"); + + expect(error).toHaveBeenCalledWith( + "Deleting comment failed", + expect.any(Error), + ); + expect(reload).toHaveBeenCalledTimes(1); +}); diff --git a/desktop/tests/unit/commentNavigation.test.ts b/desktop/tests/unit/commentNavigation.test.ts new file mode 100644 index 000000000..e4fb89d98 --- /dev/null +++ b/desktop/tests/unit/commentNavigation.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "vitest"; + +import { + orderComments, + orderFileGroups, + stepCommitIndex, + stepIndex, +} from "@app/lib/commentNavigation"; + +import { comment, location, thread } from "./support/cobs"; + +function at( + id: string, + path: string, + side: "old" | "new", + line: number, + timestamp = 0, +) { + return thread( + comment({ id, location: location(path, side, line), timestamp }), + ); +} + +describe("orderComments", () => { + test("orders by file order, line, side, then age", () => { + const ordered = orderComments( + [ + at("b-5", "b.ts", "new", 5), + at("a-9", "a.ts", "new", 9), + at("a-2-new", "a.ts", "new", 2), + at("a-2-old", "a.ts", "old", 2), + at("a-2-new-older", "a.ts", "new", 2, -1), + ], + ["a.ts", "b.ts"], + () => "modified", + ); + + expect(ordered.map(c => c.id)).toEqual([ + "a-2-old", + "a-2-new-older", + "a-2-new", + "a-9", + "b-5", + ]); + expect(ordered[0].anchor).toEqual({ + path: "a.ts", + side: "deletions", + line: 2, + }); + }); + + test("leaves out comments the diff can't show", () => { + const ordered = orderComments( + [ + at("elsewhere", "gone.ts", "new", 1), + at("moved", "moved.ts", "new", 1), + thread(comment({ id: "plain", location: null })), + at("shown", "a.ts", "new", 1), + ], + ["a.ts", "moved.ts"], + path => (path === "moved.ts" ? "moved" : "modified"), + ); + + expect(ordered.map(c => c.id)).toEqual(["shown"]); + }); +}); + +describe("stepIndex", () => { + test.each([ + [-1, 1, 0], + [-1, -1, 2], + [0, 1, 1], + [2, 1, 0], + [0, -1, 2], + ])("from %j by %j lands on %j of three", (current, delta, expected) => { + expect(stepIndex(current, 3, delta)).toBe(expected); + }); + + test("has nowhere to go in an empty list", () => { + expect(stepIndex(-1, 0, 1)).toBeUndefined(); + }); +}); + +describe("stepCommitIndex", () => { + test.each([ + [-1, -1, 2], + [-1, 1, 0], + [1, 1, 2], + [1, -1, 0], + [2, 1, 2], + [0, -1, 0], + ])("from %j by %j selects %j of three", (current, delta, expected) => { + expect(stepCommitIndex(current, 3, delta)).toBe(expected); + }); +}); + +test("orderFileGroups follows the diff's file order and line order", () => { + const groups = orderFileGroups( + [ + { + path: "b.ts", + threads: [at("b-9", "b.ts", "new", 9), at("b-1", "b.ts", "new", 1)], + }, + { path: "gone.ts", threads: [at("g", "gone.ts", "new", 1)] }, + { path: "a.ts", threads: [at("a-3", "a.ts", "old", 3)] }, + ], + ["a.ts", "b.ts"], + ); + + expect(groups.map(g => [g.path, ...g.threads.map(t => t.root.id)])).toEqual([ + ["a.ts", "a-3"], + ["b.ts", "b-1", "b-9"], + ]); +}); + +test("orderFileGroups leaves the caller's groups as they were", () => { + const threads = [at("b-9", "b.ts", "new", 9), at("b-1", "b.ts", "new", 1)]; + orderFileGroups([{ path: "b.ts", threads }], ["b.ts"]); + + expect(threads.map(t => t.root.id)).toEqual(["b-9", "b-1"]); +}); diff --git a/desktop/tests/unit/commentResolutions.test.ts b/desktop/tests/unit/commentResolutions.test.ts new file mode 100644 index 000000000..2be0ff7be --- /dev/null +++ b/desktop/tests/unit/commentResolutions.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "vitest"; + +import { resolutionsByComment } from "@app/lib/commentResolutions"; + +import { author, operation } from "./support/cobs"; + +const alice = author("alice"); +const bob = author("bob"); + +function resolve(comment: string) { + return { type: "review.comment.resolve" as const, review: "r", comment }; +} + +function unresolve(comment: string) { + return { type: "review.comment.unresolve" as const, review: "r", comment }; +} + +describe("resolutionsByComment", () => { + test("records who resolved a comment and when", () => { + const resolutions = resolutionsByComment([ + operation(alice, 10, [resolve("c1")]), + ]); + + expect(resolutions.get("c1")).toEqual({ author: alice, timestamp: 10 }); + }); + + test("the latest resolve wins", () => { + const resolutions = resolutionsByComment([ + operation(alice, 10, [resolve("c1")]), + operation(bob, 20, [resolve("c1")]), + ]); + + expect(resolutions.get("c1")).toEqual({ author: bob, timestamp: 20 }); + }); + + test("replays operations in timestamp order, not array order", () => { + const resolutions = resolutionsByComment([ + operation(bob, 20, [unresolve("c1")]), + operation(alice, 10, [resolve("c1")]), + ]); + + expect(resolutions.has("c1")).toBe(false); + }); + + test("unresolving drops the entry", () => { + const resolutions = resolutionsByComment([ + operation(alice, 10, [resolve("c1"), resolve("c2")]), + operation(bob, 20, [unresolve("c1")]), + ]); + + expect([...resolutions.keys()]).toEqual(["c2"]); + }); + + test("ignores other actions", () => { + const resolutions = resolutionsByComment([ + operation(alice, 10, [{ type: "label", labels: ["bug"] }]), + ]); + + expect(resolutions.size).toBe(0); + }); + + test("does not reorder the caller's array", () => { + const activity = [ + operation(bob, 20, [resolve("c1")]), + operation(alice, 10, [resolve("c2")]), + ]; + resolutionsByComment(activity); + + expect(activity.map(op => op.timestamp)).toEqual([20, 10]); + }); +}); diff --git a/desktop/tests/unit/commentSources.test.ts b/desktop/tests/unit/commentSources.test.ts new file mode 100644 index 000000000..850606f8e --- /dev/null +++ b/desktop/tests/unit/commentSources.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "vitest"; + +import { commentSourcesOf, STANDALONE_COMMENTS } from "@app/lib/commentSources"; + +import { author, comment, location, review, revision } from "./support/cobs"; + +const alice = author("alice"); +const bob = author("bob"); +const carol = author("carol"); +const code = location("src/main.rs", "new", 3); + +describe("commentSourcesOf", () => { + test("lists each review with code comments, counting only roots", () => { + const sources = commentSourcesOf( + revision({ + reviews: [ + review({ + id: "r1", + author: alice, + comments: [ + comment({ id: "a", location: code }), + comment({ id: "b", location: code }), + comment({ id: "reply", location: code, replyTo: "a" }), + ], + }), + ], + }), + ); + + expect(sources).toEqual([ + { id: "r1", name: "alice", count: 2, nids: ["z6Mkalice"] }, + ]); + }); + + test("skips reviews without code comments", () => { + const sources = commentSourcesOf( + revision({ + reviews: [ + review({ id: "r1", comments: [comment({ location: null })] }), + review({ id: "r2", comments: [] }), + ], + }), + ); + + expect(sources).toEqual([]); + }); + + test("names a review author without an alias by a short key", () => { + const [source] = commentSourcesOf( + revision({ + reviews: [ + review({ + author: author("anonymous", null), + comments: [comment({ location: code })], + }), + ], + }), + ); + + expect(source.name).toBe("z6Mkan"); + }); + + test("collects code comments outside reviews into their own bucket", () => { + const sources = commentSourcesOf( + revision({ + discussion: [ + comment({ id: "1", author: bob, location: code }), + comment({ id: "2", author: carol, location: code }), + comment({ id: "3", author: bob, location: code }), + comment({ id: "plain", author: alice, location: null }), + comment({ id: "reply", author: alice, location: code, replyTo: "1" }), + ], + }), + ); + + expect(sources).toEqual([ + { + id: STANDALONE_COMMENTS, + name: "Not part of a review", + count: 3, + nids: ["z6Mkbob", "z6Mkcarol"], + }, + ]); + }); + + test("puts reviews before the standalone bucket", () => { + const sources = commentSourcesOf( + revision({ + reviews: [ + review({ id: "r1", comments: [comment({ location: code })] }), + ], + discussion: [comment({ location: code })], + }), + ); + + expect(sources.map(s => s.id)).toEqual(["r1", STANDALONE_COMMENTS]); + }); +}); diff --git a/desktop/tests/unit/commitGroups.test.ts b/desktop/tests/unit/commitGroups.test.ts new file mode 100644 index 000000000..2dfb47cd2 --- /dev/null +++ b/desktop/tests/unit/commitGroups.test.ts @@ -0,0 +1,109 @@ +import type { Commit } from "@bindings/repo/Commit"; + +import { describe, expect, test } from "vitest"; + +import { + commitRowsOf, + dayKey, + dayLabel, + groupCommitsByDay, +} from "@app/lib/commitGroups"; + +// The unit tests run with TZ=UTC. +const now = Date.UTC(2026, 8, 30, 12); +const hour = 3600 * 1000; + +function commit(id: string, at: number): Commit { + const person = { name: "Alice", email: "a@x", time: at / 1000 }; + return { + id, + author: person, + committer: person, + summary: id, + message: id, + parents: [], + } as unknown as Commit; +} + +test("dayKey is the calendar day", () => { + expect(dayKey(Date.UTC(2026, 0, 5, 23, 59))).toBe("2026-01-05"); + expect(dayKey(Date.UTC(2026, 0, 6, 0, 0))).toBe("2026-01-06"); +}); + +describe("dayLabel", () => { + test.each([ + [now, "Today"], + [Date.UTC(2026, 8, 30, 0, 0), "Today"], + [Date.UTC(2026, 8, 29, 23, 59), "Yesterday"], + [Date.UTC(2026, 8, 29, 0, 0), "Yesterday"], + [Date.UTC(2026, 8, 28, 23, 59), "Monday, September 28, 2026"], + ])("labels %j as %j", (timestamp, expected) => { + expect(dayLabel(timestamp, now)).toBe(expected); + }); + + test("defaults to the current time", () => { + expect(dayLabel(Date.now())).toBe("Today"); + }); + + test("handles yesterday across a month boundary", () => { + expect(dayLabel(Date.UTC(2026, 8, 30, 10), Date.UTC(2026, 9, 1, 10))).toBe( + "Yesterday", + ); + }); +}); + +describe("grouping", () => { + const commits = [ + commit("a", now), + commit("b", now - 2 * hour), + commit("c", now - 24 * hour), + commit("d", now - 1000 * hour), + ]; + + test("groups commits by day in order", () => { + expect( + groupCommitsByDay(commits, now).map(g => [ + g.label, + ...g.commits.map(c => c.id), + ]), + ).toEqual([ + ["Today", "a", "b"], + ["Yesterday", "c"], + ["Wednesday, August 19, 2026", "d"], + ]); + }); + + test("merges a day's commits even when they aren't adjacent", () => { + expect( + groupCommitsByDay( + [ + commit("a", now), + commit("c", now - 24 * hour), + commit("b", now - hour), + ], + now, + ).map(g => g.commits.map(c => c.id)), + ).toEqual([["a", "b"], ["c"]]); + }); + + test("groups by commit date, not author date", () => { + const rebased = { + ...commit("r", now), + author: { name: "Alice", email: "a@x", time: (now - 1000 * hour) / 1000 }, + }; + + expect(groupCommitsByDay([rebased], now)[0].label).toBe("Today"); + }); + + test("flattens groups into header and commit rows", () => { + const rows = commitRowsOf(groupCommitsByDay(commits.slice(0, 3), now)); + + expect( + rows.map(row => + row.type === "header" + ? `# ${row.label}` + : `${row.commit.id}${row.first ? " first" : ""}${row.last ? " last" : ""}`, + ), + ).toEqual(["# Today", "a first", "b last", "# Yesterday", "c first last"]); + }); +}); diff --git a/desktop/tests/unit/debounced.svelte.test.ts b/desktop/tests/unit/debounced.svelte.test.ts new file mode 100644 index 000000000..9462568fe --- /dev/null +++ b/desktop/tests/unit/debounced.svelte.test.ts @@ -0,0 +1,35 @@ +import { flushSync } from "svelte"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { debounced } from "@app/lib/debounced.svelte"; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +test("follows the source once it stops changing", () => { + let source = $state("a"); + let search: { readonly current: string } | undefined; + const cleanup = $effect.root(() => { + search = debounced(() => source, 150); + }); + flushSync(); + expect(search?.current).toBe("a"); + + source = "ab"; + flushSync(); + vi.advanceTimersByTime(100); + source = "abc"; + flushSync(); + vi.advanceTimersByTime(149); + expect(search?.current).toBe("a"); + + vi.advanceTimersByTime(1); + expect(search?.current).toBe("abc"); + + cleanup(); +}); diff --git a/desktop/tests/unit/deepLink.test.ts b/desktop/tests/unit/deepLink.test.ts new file mode 100644 index 000000000..6dc0d0aff --- /dev/null +++ b/desktop/tests/unit/deepLink.test.ts @@ -0,0 +1,133 @@ +import type { Config } from "@bindings/config/Config"; + +import { describe, expect, test, vi } from "vitest"; + +import { + createLinkQueue, + deepLinkTarget, + parseDeepLink, +} from "@app/lib/deepLink"; + +const repo = "z3gqcJUoA1n9HaHKufZs5FCSGazv5"; +const rid = `rad:${repo}`; +const oid = "5e4683023476778201ab25c87e1343fbfbe67c89"; +const nid = "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"; +const config = { + publicExplorer: "https://radicle.network/nodes/$host/$rid$path", + preferredSeeds: [], +} as unknown as Config; +const explorer = `https://radicle.network/nodes/rosa.radicle.network/${rid}`; + +function target(link: string, local = true) { + return deepLinkTarget(parseDeepLink(link)!, local, config); +} + +describe("parseDeepLink", () => { + test.each([ + rid, + `rad:///${repo}`, + ` ${rid}\n`, + `rad:///${rid}`, + `rad://${rid}`, + `rad:${rid}`, + ])("accepts %j", link => { + expect(parseDeepLink(link)).toEqual({ type: "uri", uri: { repo } }); + }); + + test.each([ + `did:key:${nid}`, + "https://radicle.network", + `web+${rid}`, + "rad:bogus", + ])("rejects %j", link => { + expect(parseDeepLink(link)).toBeUndefined(); + }); +}); + +describe("deepLinkTarget", () => { + test.each([ + [rid, { resource: "repo.home", rid }], + [ + `${rid}/cob/xyz.radicle.patch/${oid}`, + { + resource: "repo.patch", + rid, + patch: oid, + status: undefined, + reviewId: undefined, + }, + ], + [ + `${rid}/cob/dev.radicle.artifact/${oid}`, + { resource: "repo.release", rid, release: oid }, + ], + [`${rid}/commit/${oid}`, { resource: "repo.commit", rid, commit: oid }], + [ + `${rid}/commit/main?path=src/lib.rs#L10`, + { resource: "repo.home", rid, revision: "main", path: "src/lib.rs" }, + ], + ])("opens %s in-app when the repo is here", (link, route) => { + expect(target(link)).toEqual({ type: "route", route }); + }); + + test("offers to seed a repo that is not here", () => { + expect(target(`${rid}/cob/xyz.radicle.issue/${oid}`, false)).toEqual({ + type: "missing", + rid, + url: `${explorer}/issues/${oid}`, + }); + }); + + test("offers to seed a repo that is not here when nothing has a page", () => { + expect(target(`${rid}/tree/${oid}`, false)).toEqual({ + type: "missing", + rid, + url: undefined, + }); + }); + + test("goes nowhere when neither has a page", () => { + expect(target(`${rid}/tree/${oid}`)).toBeUndefined(); + }); +}); + +describe("createLinkQueue", () => { + function setup(fail?: string) { + const opened: string[] = []; + let running = 0; + let overlapped = false; + const queue = createLinkQueue(async link => { + running++; + overlapped ||= running > 1; + await new Promise(resolve => setTimeout(resolve, 1)); + running--; + if (link === fail) throw new Error(link); + opened.push(link); + }); + return { queue, opened, overlapped: () => overlapped }; + } + const settle = () => new Promise(resolve => setTimeout(resolve, 20)); + + test("holds links until the app is ready", async () => { + const { queue, opened } = setup(); + queue.receive(["a"]); + await settle(); + expect(opened).toEqual([]); + + queue.ready(); + queue.receive(["b"]); + await settle(); + expect(opened).toEqual(["a", "b"]); + }); + + test("opens links one at a time, past a failing one", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const { queue, opened, overlapped } = setup("b"); + queue.ready(); + queue.receive(["a", "b"]); + queue.receive(["c"]); + await settle(); + expect(opened).toEqual(["a", "c"]); + expect(overlapped()).toBe(false); + }); +}); diff --git a/desktop/tests/unit/diffOptions.svelte.test.ts b/desktop/tests/unit/diffOptions.svelte.test.ts new file mode 100644 index 000000000..a09000427 --- /dev/null +++ b/desktop/tests/unit/diffOptions.svelte.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +const defaults = { + showTree: true, + diffStyle: "unified", + wordWrap: false, + indicators: "bars", + lineDiffType: "word-alt", +}; + +async function load() { + const { diffOptions } = await import("@app/lib/diffOptions.svelte"); + const { flushSync } = await import("svelte"); + return { diffOptions, flushSync }; +} + +beforeEach(() => { + localStorage.clear(); + vi.resetModules(); +}); + +test("starts with the defaults when nothing is stored", async () => { + expect((await load()).diffOptions).toEqual(defaults); +}); + +test("merges stored options over the defaults", async () => { + localStorage.setItem( + "diffOptions", + JSON.stringify({ diffStyle: "split", wordWrap: true }), + ); + + expect((await load()).diffOptions).toEqual({ + ...defaults, + diffStyle: "split", + wordWrap: true, + }); +}); + +test.each([ + "{not json", + "null", + JSON.stringify({ diffStyle: "sideways", wordWrap: true }), +])("falls back to the defaults for %s", async stored => { + localStorage.setItem("diffOptions", stored); + + expect((await load()).diffOptions).toEqual(defaults); +}); + +test("persists changes", async () => { + const { diffOptions, flushSync } = await load(); + diffOptions.indicators = "none"; + flushSync(); + + expect(JSON.parse(localStorage.getItem("diffOptions") ?? "")).toEqual({ + ...defaults, + indicators: "none", + }); +}); diff --git a/desktop/tests/unit/diffText.test.ts b/desktop/tests/unit/diffText.test.ts new file mode 100644 index 000000000..d79cff67b --- /dev/null +++ b/desktop/tests/unit/diffText.test.ts @@ -0,0 +1,152 @@ +import type { DiffContent } from "@bindings/diff/DiffContent"; +import type { FileDiff } from "@bindings/diff/FileDiff"; + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { + checkedFileProgress, + checkedPaths, + fileDiffPath, + fileMetaOf, + fullFileLoader, + gitStatusEntries, +} from "@app/lib/diffText"; + +const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock("@app/lib/invoke", () => ({ invoke })); + +const plain: DiffContent = { + type: "plain", + stats: { additions: 1, deletions: 0 }, +}; + +const added: FileDiff = { status: "added", path: "new.ts", diff: plain }; +const deleted: FileDiff = { status: "deleted", path: "old.ts", diff: plain }; +const modified: FileDiff = { status: "modified", path: "a.ts", diff: plain }; +const moved: FileDiff = { + status: "moved", + oldPath: "from.ts", + newPath: "to.ts", + diff: plain, +}; +const copied: FileDiff = { + status: "copied", + oldPath: "orig.ts", + newPath: "copy.ts", + diff: plain, +}; + +test("fileDiffPath names renames and copies by their new path", () => { + expect([added, deleted, modified, moved, copied].map(fileDiffPath)).toEqual([ + "new.ts", + "old.ts", + "a.ts", + "to.ts", + "copy.ts", + ]); +}); + +test("gitStatusEntries marks a rename as renamed and a copy as added", () => { + expect(gitStatusEntries([added, deleted, modified, moved, copied])).toEqual([ + { path: "new.ts", status: "added" }, + { path: "old.ts", status: "deleted" }, + { path: "a.ts", status: "modified" }, + { path: "to.ts", status: "renamed" }, + { path: "copy.ts", status: "added" }, + ]); +}); + +test("fileMetaOf notes unrenderable files and lockfiles", () => { + const meta = fileMetaOf([ + moved, + { status: "added", path: "logo.png", diff: { type: "binary" } }, + { status: "added", path: "empty.txt", diff: { type: "empty" } }, + { status: "modified", path: "web/package-lock.json", diff: plain }, + ]); + + expect(Object.fromEntries(meta.statuses)).toEqual({ + "to.ts": "moved", + "logo.png": "added", + "empty.txt": "added", + "web/package-lock.json": "modified", + }); + expect(Object.fromEntries(meta.notes)).toEqual({ + "logo.png": "binary", + "empty.txt": "empty", + }); + expect([...meta.ignored]).toEqual(["web/package-lock.json"]); +}); + +test("checkedFileProgress ignores checks on files the diff no longer has", () => { + expect( + checkedFileProgress([modified, moved], ["a.ts", "from.ts", "gone.ts"]), + ).toEqual({ filesChecked: 1, filesTotal: 2 }); +}); + +test("checkedPaths names checked files by their diff path", () => { + expect([...checkedPaths([modified, moved], path => path !== "a.ts")]).toEqual( + ["to.ts"], + ); +}); + +describe("fullFileLoader", () => { + beforeEach(() => { + invoke.mockReset(); + invoke.mockImplementation( + (_cmd, { path, sha }: { path: string; sha: string }) => + Promise.resolve( + path.endsWith(".png") + ? { binary: true, content: "\u0089PNG" } + : { binary: false, content: `${path}@${sha}` }, + ), + ); + }); + + const load = (files: FileDiff[]) => + fullFileLoader("rad:z", "base", "head", () => files); + + test("reads a rename's old side from its old path", async () => { + expect(await load([moved])("to.ts")).toEqual({ + oldContents: "from.ts@base", + newContents: "to.ts@head", + }); + }); + + test("reads only the side a file has", async () => { + expect(await load([added])("new.ts")).toEqual({ + oldContents: "", + newContents: "new.ts@head", + }); + expect(await load([deleted])("old.ts")).toEqual({ + oldContents: "old.ts@base", + newContents: "", + }); + expect(invoke).toHaveBeenCalledTimes(2); + }); + + test("has no old side without a base", async () => { + const rootCommit = fullFileLoader("rad:z", undefined, "head", () => [ + modified, + ]); + expect(await rootCommit("a.ts")).toEqual({ + oldContents: "", + newContents: "a.ts@head", + }); + }); + + test("gives binary blobs and unknown paths no contents", async () => { + const binary: FileDiff = { + status: "modified", + path: "logo.png", + diff: { type: "binary" }, + }; + expect(await load([binary])("logo.png")).toEqual({ + oldContents: "", + newContents: "", + }); + expect(await load([modified])("missing.ts")).toEqual({ + oldContents: "", + newContents: "", + }); + }); +}); diff --git a/desktop/tests/unit/disableDropNavigation.test.ts b/desktop/tests/unit/disableDropNavigation.test.ts new file mode 100644 index 000000000..5b2769a60 --- /dev/null +++ b/desktop/tests/unit/disableDropNavigation.test.ts @@ -0,0 +1,77 @@ +import { beforeAll, describe, expect, test } from "vitest"; + +import { disableDropNavigation } from "@app/lib/disableDropNavigation"; + +// happy-dom's DragEvent ignores `dataTransfer`, so it is attached by hand. +function drag(type: "dragover" | "drop", target: Element, types: string[]) { + const dataTransfer = { types, dropEffect: "copy" }; + const event = new DragEvent(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { value: dataTransfer }); + target.dispatchEvent(event); + return { + prevented: event.defaultPrevented, + dropEffect: dataTransfer.dropEffect, + }; +} + +function element(html: string): Element { + const template = document.createElement("template"); + template.innerHTML = html; + const node = template.content.firstElementChild as Element; + document.body.append(node); + return node; +} + +beforeAll(() => { + disableDropNavigation(); +}); + +describe("disableDropNavigation", () => { + test.each([ + ["a link", ["text/uri-list", "text/plain"]], + ["a file", ["Files"]], + ])("refuses %s dropped on the page", (_, types) => { + const page = element("
page
"); + expect(drag("dragover", page, types)).toEqual({ + prevented: true, + dropEffect: "none", + }); + expect(drag("drop", page, types).prevented).toBe(true); + }); + + test.each([ + ["a textarea", ""], + ["an input", ""], + ["an editable element", '
x
'], + ])("leaves text dropped into %s to the webview", (_, html) => { + const field = element(html); + const types = ["text/plain"]; + expect(drag("dragover", field, types)).toEqual({ + prevented: false, + dropEffect: "copy", + }); + expect(drag("drop", field, types).prevented).toBe(false); + }); + + test("refuses text dropped on an element that isn't editable", () => { + const field = element('
x
'); + expect(drag("drop", field, ["text/plain"]).prevented).toBe(true); + }); + + test("refuses a file over a field that didn't accept it", () => { + const field = element(""); + expect(drag("dragover", field, ["Files"])).toEqual({ + prevented: true, + dropEffect: "none", + }); + }); + + test("keeps the drop effect of a field that accepted the file", () => { + const field = element("
"); + field.addEventListener("dragover", event => event.preventDefault()); + expect(drag("dragover", field, ["Files"])).toEqual({ + prevented: true, + dropEffect: "copy", + }); + }); +}); diff --git a/desktop/tests/unit/documentLinks.test.ts b/desktop/tests/unit/documentLinks.test.ts new file mode 100644 index 000000000..f3bfad878 --- /dev/null +++ b/desktop/tests/unit/documentLinks.test.ts @@ -0,0 +1,251 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import type { DocumentLink } from "@app/lib/documentLinks"; +import { + classifyDocumentLink, + enhanceDocumentLink, + scrollToFragment, + scrollToLocationFragment, +} from "@app/lib/documentLinks"; + +const { show } = vi.hoisted(() => ({ show: vi.fn() })); +vi.mock("@app/lib/modal", () => ({ show })); + +describe("classifyDocumentLink", () => { + test.each<[string, string, DocumentLink]>([ + ["docs/guide.md", "README.md", { type: "file", path: "docs/guide.md" }], + ["./guide.md", "docs/index.md", { type: "file", path: "docs/guide.md" }], + ["../README.md", "docs/index.md", { type: "file", path: "README.md" }], + ["/src/main.rs", "docs/index.md", { type: "file", path: "src/main.rs" }], + ["docs/", "README.md", { type: "file", path: "docs" }], + [ + "docs/guide.md#usage", + "README.md", + { type: "file", path: "docs/guide.md", fragment: "usage" }, + ], + [ + "guide.md#caf%C3%A9", + "README.md", + { type: "file", path: "guide.md", fragment: "café" }, + ], + ["guide.md?plain=1", "README.md", { type: "file", path: "guide.md" }], + ["my%20notes.md", "README.md", { type: "file", path: "my notes.md" }], + ["LICENSE", "", { type: "file", path: "LICENSE" }], + ["/", "docs/index.md", { type: "file", path: undefined }], + ["..", "README.md", { type: "file", path: undefined }], + ["#usage", "README.md", { type: "fragment", id: "usage" }], + ["#caf%C3%A9", "README.md", { type: "fragment", id: "café" }], + ["README.md#usage", "README.md", { type: "fragment", id: "usage" }], + ["README.md#Usage", "README.md", { type: "fragment", id: "Usage" }], + ["#caf%", "README.md", { type: "fragment", id: "caf%" }], + ["#", "README.md", { type: "fragment", id: "" }], + ["", "README.md", { type: "fragment", id: "" }], + ["https://radicle.xyz", "README.md", { type: "external" }], + ["HTTP://radicle.xyz", "README.md", { type: "external" }], + ["mailto:alice@example.com", "README.md", { type: "external" }], + ["tel:+123", "README.md", { type: "external" }], + ["ftp://example.com/file", "README.md", { type: "unsupported" }], + ["javascript:alert(1)", "README.md", { type: "unsupported" }], + ["//radicle.xyz/notes.md", "README.md", { type: "unsupported" }], + ])("classifies %j in %j", (href, from, expected) => { + expect(classifyDocumentLink(href, from)).toEqual(expected); + }); +}); + +describe("enhanceDocumentLink", () => { + let container: HTMLElement; + + function link(href: string | undefined): HTMLAnchorElement { + const anchor = document.createElement("a"); + if (href !== undefined) { + anchor.setAttribute("href", href); + } + anchor.textContent = "link"; + container.appendChild(anchor); + return anchor; + } + + function click(anchor: HTMLAnchorElement): MouseEvent { + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + anchor.dispatchEvent(event); + return event; + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + vi.restoreAllMocks(); + show.mockReset(); + }); + + test("opens a file in the repository instead of following the link", () => { + const openFile = vi.fn(); + const anchor = link("../CONTRIBUTING.md"); + enhanceDocumentLink(anchor, () => ({ path: "docs/index.md", openFile })); + + expect(click(anchor).defaultPrevented).toBe(true); + expect(openFile).toHaveBeenCalledWith("CONTRIBUTING.md", undefined); + expect(show).not.toHaveBeenCalled(); + }); + + test("opens the repository root", () => { + const openFile = vi.fn(); + const anchor = link("/"); + enhanceDocumentLink(anchor, () => ({ path: "docs/index.md", openFile })); + + expect(click(anchor).defaultPrevented).toBe(true); + expect(openFile).toHaveBeenCalledWith(undefined, undefined); + }); + + test("resolves against the document shown when the link is followed", () => { + const openFile = vi.fn(); + let path = "README.md"; + const anchor = link("guide.md"); + enhanceDocumentLink(anchor, () => ({ path, openFile })); + + path = "docs/README.md"; + click(anchor); + expect(openFile).toHaveBeenCalledWith("docs/guide.md", undefined); + }); + + test("opens a file at the section it links to", () => { + const openFile = vi.fn(); + const anchor = link("guide.md#Usage"); + enhanceDocumentLink(anchor, () => ({ path: "README.md", openFile })); + + click(anchor); + expect(openFile).toHaveBeenCalledWith("guide.md", "Usage"); + }); + + test("opens external links outside the app", () => { + const anchor = link("https://radicle.xyz"); + enhanceDocumentLink(anchor, () => ({ path: "README.md" })); + + expect(anchor.target).toBe("_blank"); + expect(anchor.rel).toBe("noopener noreferrer"); + expect(anchor.onclick).toBeNull(); + }); + + test("scrolls to a fragment in the document", () => { + const heading = document.createElement("h2"); + heading.id = "usage"; + container.appendChild(heading); + const scroll = vi + .spyOn(heading, "scrollIntoView") + .mockReturnValue(undefined); + const anchor = link("#usage"); + enhanceDocumentLink(anchor, () => ({ path: "README.md" })); + + expect(click(anchor).defaultPrevented).toBe(true); + expect(scroll).toHaveBeenCalled(); + }); + + test("scrolls to a named anchor", () => { + const target = document.createElement("a"); + target.setAttribute("name", "legacy"); + container.appendChild(target); + const scroll = vi + .spyOn(target, "scrollIntoView") + .mockReturnValue(undefined); + const anchor = link("#legacy"); + enhanceDocumentLink(anchor, () => ({ path: "README.md" })); + + click(anchor); + expect(scroll).toHaveBeenCalled(); + }); + + test("stays put for a missing fragment", () => { + const anchor = link("#missing"); + enhanceDocumentLink(anchor, () => ({ path: "README.md" })); + + expect(click(anchor).defaultPrevented).toBe(true); + expect(show).not.toHaveBeenCalled(); + }); + + test.each([ + ["ftp://example.com/file", true], + ["//radicle.xyz/notes.md", true], + // A document that isn't part of a repository has no files to open. + ["guide.md", false], + ])("explains why %j can't be opened", (href, inRepo) => { + const openFile = vi.fn(); + const anchor = link(href); + enhanceDocumentLink(anchor, () => ({ + path: "README.md", + openFile: inRepo ? openFile : undefined, + })); + + expect(click(anchor).defaultPrevented).toBe(true); + expect(openFile).not.toHaveBeenCalled(); + expect(show).toHaveBeenCalledWith( + expect.objectContaining({ + props: { link: href, reason: "unsupported" }, + }), + ); + }); + + test("leaves anchors without a link alone", () => { + const anchor = link(undefined); + enhanceDocumentLink(anchor, () => ({ path: "README.md" })); + + expect(anchor.onclick).toBeNull(); + expect(anchor.target).toBe(""); + }); +}); + +describe("scrollToFragment", () => { + let container: HTMLElement; + + function target(tag: string, attribute: string, value: string) { + const element = document.createElement(tag); + element.setAttribute(attribute, value); + container.appendChild(element); + return vi.spyOn(element, "scrollIntoView").mockReturnValue(undefined); + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + vi.restoreAllMocks(); + window.history.replaceState(null, "", "/"); + }); + + test("falls back to the lowercase id of a heading", () => { + const heading = target("h2", "id", "usage"); + + scrollToFragment("Usage"); + expect(heading).toHaveBeenCalled(); + }); + + test("prefers an exact match over the lowercase id", () => { + const heading = target("h2", "id", "usage"); + const anchor = target("a", "name", "Usage"); + + scrollToFragment("Usage"); + expect(anchor).toHaveBeenCalled(); + expect(heading).not.toHaveBeenCalled(); + }); + + test("scrolls to the location's fragment once per location", () => { + const heading = target("h2", "id", "café"); + window.history.replaceState(null, "", "/repos#caf%C3%A9"); + + const scrolledAt = scrollToLocationFragment(undefined); + expect(heading).toHaveBeenCalledTimes(1); + + scrollToLocationFragment(scrolledAt); + expect(heading).toHaveBeenCalledTimes(1); + + window.history.replaceState(null, "", "/other#caf%C3%A9"); + scrollToLocationFragment(scrolledAt); + expect(heading).toHaveBeenCalledTimes(2); + }); +}); diff --git a/desktop/tests/unit/draftReviewStorage.test.ts b/desktop/tests/unit/draftReviewStorage.test.ts new file mode 100644 index 000000000..5e8e63389 --- /dev/null +++ b/desktop/tests/unit/draftReviewStorage.test.ts @@ -0,0 +1,294 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { author, location } from "./support/cobs"; + +const invoke = vi.fn(); +vi.mock("@app/lib/invoke", () => ({ invoke })); + +const key = "repo.patches.draftReviews"; +const alice = author("alice"); +const bob = author("bob"); +const aliceNid = "z6Mkalice"; +const bobNid = "z6Mkbob"; + +// The store reads localStorage once, when the module loads. +async function load() { + vi.resetModules(); + return (await import("@app/lib/draftReviewStorage")).draftReviewStorage; +} + +function stored(): Record> { + return JSON.parse(localStorage.getItem(key) ?? "{}"); +} + +beforeEach(() => { + localStorage.clear(); + invoke.mockReset(); + vi.spyOn(console, "error").mockReturnValue(undefined); +}); + +describe("draftReviewStorage", () => { + test("creates an empty accepting draft for its author", async () => { + const drafts = await load(); + const id = drafts.create("rad:repo", "patch", "rev", aliceNid); + + expect(drafts.get(id, alice)).toEqual({ + id, + draft: true, + rid: "rad:repo", + author: alice, + revisionId: "rev", + verdict: "accept", + summary: "", + labels: [], + comments: [], + checkedFiles: [], + }); + expect(stored()[id]).toMatchObject({ nid: aliceNid, patchId: "patch" }); + }); + + test("hides a draft from other identities", async () => { + const drafts = await load(); + const id = drafts.create("rad:repo", "patch", "rev", aliceNid); + + expect(drafts.get(id, bob)).toBeUndefined(); + expect(drafts.getForRevision("rev", bob)).toBeUndefined(); + expect(drafts.hasForRevision("rev", bobNid)).toBe(false); + }); + + test("finds a draft by revision", async () => { + const drafts = await load(); + drafts.create("rad:repo", "patch", "other", aliceNid); + const id = drafts.create("rad:repo", "patch", "rev", aliceNid); + + expect(drafts.getForRevision("rev", alice)?.id).toBe(id); + expect(drafts.hasForRevision("rev", aliceNid)).toBe(true); + expect(drafts.hasForRevision("missing", aliceNid)).toBe(false); + }); + + test("updates summary, verdict and labels", async () => { + const drafts = await load(); + const id = drafts.create("rad:repo", "patch", "rev", aliceNid); + drafts.update(id, { summary: "LGTM", verdict: undefined, labels: ["ok"] }); + + expect(drafts.get(id, alice)).toMatchObject({ + summary: "LGTM", + verdict: undefined, + labels: ["ok"], + }); + }); + + test("toggles checked files", async () => { + const drafts = await load(); + const id = drafts.create("rad:repo", "patch", "rev", aliceNid); + + drafts.toggleCheckedFile(id, "a.ts"); + drafts.toggleCheckedFile(id, "b.ts"); + expect(drafts.isFileChecked(id, "a.ts")).toBe(true); + + drafts.toggleCheckedFile(id, "a.ts"); + expect(drafts.isFileChecked(id, "a.ts")).toBe(false); + expect(drafts.get(id, alice)?.checkedFiles).toEqual(["b.ts"]); + expect(drafts.isFileChecked("missing", "a.ts")).toBe(false); + }); + + describe("comments", () => { + test("adds a comment as the draft author's", async () => { + const drafts = await load(); + const id = drafts.create("rad:repo", "patch", "rev", aliceNid); + const where = location("a.ts", "new", 3); + const commentId = drafts.addComment(id, { body: "nit", location: where }); + + expect(drafts.get(id, alice)?.comments).toEqual([ + { + id: commentId, + author: alice, + edits: [{ author: alice, timestamp: 0, body: "nit" }], + reactions: [], + replyTo: null, + location: where, + resolved: false, + }, + ]); + }); + + test("edits a comment's body", async () => { + const drafts = await load(); + const id = drafts.create("rad:repo", "patch", "rev", aliceNid); + const commentId = drafts.addComment(id, { + body: "nit", + location: location("a.ts", "new", 3), + }); + drafts.updateComment(id, commentId, { body: "fixed" }); + + expect(drafts.get(id, alice)?.comments[0].edits[0].body).toBe("fixed"); + expect(() => drafts.updateComment(id, "missing", { body: "x" })).toThrow( + "Comment missing does not exist", + ); + }); + + test("deletes only the given comment", async () => { + const drafts = await load(); + const id = drafts.create("rad:repo", "patch", "rev", aliceNid); + const where = location("a.ts", "new", 3); + const first = drafts.addComment(id, { body: "one", location: where }); + const second = drafts.addComment(id, { body: "two", location: where }); + + drafts.deleteComment(id, "missing"); + expect(drafts.get(id, alice)?.comments.map(c => c.id)).toEqual([ + first, + second, + ]); + + drafts.deleteComment(id, first); + expect(drafts.get(id, alice)?.comments.map(c => c.id)).toEqual([second]); + }); + + test("editing a draft that doesn't exist throws", async () => { + const drafts = await load(); + + expect(() => + drafts.addComment("missing", { + body: "x", + location: location("a.ts", "new", 1), + }), + ).toThrow("Draft review missing does not exist"); + }); + }); + + test("deletes a draft and returns it", async () => { + const drafts = await load(); + const id = drafts.create("rad:repo", "patch", "rev", aliceNid); + + expect(drafts.delete(id)?.id).toBe(id); + expect(drafts.get(id, alice)).toBeUndefined(); + expect(stored()).toEqual({}); + }); + + test("prunes only this author's drafts on this patch's removed revisions", async () => { + const drafts = await load(); + const gone = drafts.create("rad:repo", "patch", "gone", aliceNid); + const live = drafts.create("rad:repo", "patch", "live", aliceNid); + const otherPatch = drafts.create("rad:repo", "other", "gone", aliceNid); + const otherAuthor = drafts.create("rad:repo", "patch", "gone", bobNid); + + drafts.pruneStale("patch", ["live"], aliceNid); + + expect(Object.keys(stored()).sort()).toEqual( + [live, otherPatch, otherAuthor].sort(), + ); + expect(stored()[gone]).toBeUndefined(); + }); + + describe("publish", () => { + test("publishes the review, then deletes the draft", async () => { + invoke.mockResolvedValue(undefined); + const drafts = await load(); + const id = drafts.create("rad:repo", "patch", "rev", aliceNid); + drafts.update(id, { + summary: "LGTM", + verdict: undefined, + labels: ["ok"], + }); + const where = location("a.ts", "new", 3); + drafts.addComment(id, { body: "nit", location: where }); + + await drafts.publish(id); + + expect(invoke).toHaveBeenCalledWith("create_patch_review", { + args: { + rid: "rad:repo", + revision: "rev", + verdict: null, + summary: "LGTM", + labels: ["ok"], + comments: [{ body: "nit", location: where }], + }, + }); + expect(drafts.get(id, alice)).toBeUndefined(); + }); + + test("publishes a comment without a location as null", async () => { + invoke.mockResolvedValue(undefined); + localStorage.setItem( + key, + JSON.stringify({ + old: { + id: "old", + rid: "rad:repo", + revision: "rev", + labels: [], + comments: [{ id: "c", body: "general remark" }], + }, + }), + ); + const drafts = await load(); + + await drafts.publish("old"); + + expect(invoke.mock.calls[0][1].args.comments).toEqual([ + { body: "general remark", location: null }, + ]); + }); + + test("keeps the draft when publishing fails", async () => { + invoke.mockRejectedValue(new Error("offline")); + const drafts = await load(); + const id = drafts.create("rad:repo", "patch", "rev", aliceNid); + + await expect(drafts.publish(id)).rejects.toThrow("offline"); + expect(drafts.get(id, alice)?.id).toBe(id); + }); + + test("rejects a draft that doesn't exist", async () => { + const drafts = await load(); + + await expect(drafts.publish("missing")).rejects.toThrow( + "Review missing does not exist", + ); + expect(invoke).not.toHaveBeenCalled(); + }); + }); + + describe("persistence", () => { + test("drafts survive a reload", async () => { + const id = (await load()).create("rad:repo", "patch", "rev", aliceNid); + + expect((await load()).get(id, alice)?.revisionId).toBe("rev"); + }); + + test("reads drafts stored before nid, patchId, summary and checkedFiles existed", async () => { + localStorage.setItem( + key, + JSON.stringify({ + old: { + id: "old", + rid: "rad:repo", + revision: "rev", + labels: [], + comments: [{ id: "c", body: "legacy" }], + }, + }), + ); + const drafts = await load(); + + // Without a recorded author, the draft belongs to whoever is signed in. + expect(drafts.get("old", bob)).toMatchObject({ + summary: "", + checkedFiles: [], + comments: [{ id: "c", location: null }], + }); + // Without a patch id it can't be pruned. + drafts.pruneStale("patch", [], bobNid); + expect(drafts.get("old", bob)).toBeDefined(); + }); + + test("starts empty when the stored data is invalid", async () => { + localStorage.setItem(key, "{not json"); + expect((await load()).hasForRevision("rev", aliceNid)).toBe(false); + + localStorage.setItem(key, JSON.stringify({ x: { id: 1 } })); + expect((await load()).get("x", alice)).toBeUndefined(); + }); + }); +}); diff --git a/desktop/tests/unit/embeds.test.ts b/desktop/tests/unit/embeds.test.ts new file mode 100644 index 000000000..8d3e78e96 --- /dev/null +++ b/desktop/tests/unit/embeds.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "vitest"; + +import { + basename, + decodeEmbed, + embedPreviewKind, + encodeEmbedUpload, + retinaWidth, +} from "@app/lib/embeds"; + +describe("basename", () => { + test.each([ + ["/home/alice/cat.png", "cat.png"], + ["C:\\Users\\alice\\cat.png", "cat.png"], + ["cat.png", "cat.png"], + ["/home/alice/build/", "build"], + ])("of %j is %j", (path, expected) => { + expect(basename(path)).toBe(expected); + }); +}); + +test.each([ + ["image/png", "image"], + ["application/pdf", "document"], + ["video/mp4", "video"], + ["audio/ogg", "audio"], + ["text/plain", undefined], + [undefined, undefined], +])("a %j embed previews as %j", (mimeType, expected) => { + expect(embedPreviewKind(mimeType)).toBe(expected); +}); + +function u32(n: number): number[] { + return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]; +} + +function bytesOf(text: string): number[] { + return Array.from(text, c => c.charCodeAt(0)); +} + +function chunk(type: string, data: number[]): number[] { + return [...u32(data.length), ...bytesOf(type), ...data, 0, 0, 0, 0]; +} + +const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +function ihdr(width: number): number[] { + return chunk("IHDR", [...u32(width), ...u32(100), 8, 6, 0, 0, 0]); +} + +function png(width: number, ...chunks: number[][]): Uint8Array { + return new Uint8Array([ + ...signature, + ...ihdr(width), + ...chunks.flat(), + ...chunk("IEND", []), + ]); +} + +function pHYs(x: number, y: number = x, unit: number = 1): number[] { + return chunk("pHYs", [...u32(x), ...u32(y), unit]); +} + +const retina = 5669; + +describe("retinaWidth", () => { + test("halves a 144 DPI PNG", () => { + expect(retinaWidth(png(3680, pHYs(retina)))).toBe(1840); + }); + + test("divides a 216 DPI PNG by three", () => { + expect(retinaWidth(png(3000, pHYs(8504)))).toBe(1000); + }); + + test("finds pHYs after other chunks", () => { + const text = chunk("tEXt", bytesOf("a\0b")); + expect(retinaWidth(png(3680, text, pHYs(retina)))).toBe(1840); + }); + + test("reads bytes at an offset in a larger buffer", () => { + const bytes = png(3680, pHYs(retina)); + const buffer = new Uint8Array(bytes.length + 3); + buffer.set(bytes, 3); + expect(retinaWidth(buffer.subarray(3))).toBe(1840); + }); + + test.each([ + ["72 DPI", png(3680, pHYs(2835))], + ["300 DPI", png(3680, pHYs(11811))], + ["a density of zero", png(3680, pHYs(0))], + ["the maximum density", png(3680, pHYs(0xffffffff))], + ["no pHYs chunk", png(3680)], + ["an unknown unit", png(3680, pHYs(retina, retina, 0))], + ["non-square pixels", png(3680, pHYs(retina, 2835))], + ["pHYs after IDAT", png(3680, chunk("IDAT", [0]), pHYs(retina))], + [ + "a pHYs chunk of the wrong length", + png(3680, chunk("pHYs", [...u32(retina), ...u32(retina), 1, 0])), + ], + ["a truncated pHYs chunk", png(3680, pHYs(retina)).subarray(0, 45)], + [ + "no IHDR chunk first", + new Uint8Array([...signature, ...pHYs(retina), ...ihdr(3680)]), + ], + ["a truncated IHDR chunk", png(3680, pHYs(retina)).subarray(0, 18)], + ])("ignores a PNG with %s", (_, bytes) => { + expect(retinaWidth(bytes)).toBeUndefined(); + }); + + test.each([ + ["empty", new Uint8Array()], + [ + "a JPEG", + new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...new Array(30).fill(0)]), + ], + ])("ignores %s input", (_, bytes) => { + expect(retinaWidth(bytes)).toBeUndefined(); + }); +}); + +describe("decodeEmbed", () => { + test("splits the MIME type from the content at the first NUL", () => { + const bytes = new Uint8Array([...bytesOf("image/png\0"), 1, 0, 2]); + const { mimeType, content } = decodeEmbed(bytes.buffer); + expect(mimeType).toBe("image/png"); + expect(content).toEqual(new Uint8Array([1, 0, 2])); + }); + + test("reads an empty MIME type as unknown", () => { + const bytes = new Uint8Array([0, 1, 2]); + const { mimeType, content } = decodeEmbed(bytes.buffer); + expect(mimeType).toBeUndefined(); + expect(content).toEqual(new Uint8Array([1, 2])); + }); +}); + +test("encodeEmbedUpload puts the UTF-8 name before a NUL and the content", () => { + const name = "Screenshot at 3.07.58\u202fPM.png"; + const encodedName = new TextEncoder().encode(name); + expect(encodeEmbedUpload(name, new Uint8Array([1, 0, 2]))).toEqual( + new Uint8Array([...encodedName, 0, 1, 0, 2]), + ); +}); diff --git a/desktop/tests/unit/fileTreeExpansion.test.ts b/desktop/tests/unit/fileTreeExpansion.test.ts new file mode 100644 index 000000000..93befd1ea --- /dev/null +++ b/desktop/tests/unit/fileTreeExpansion.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from "vitest"; + +import type { FolderExpansion, Location } from "@app/lib/fileTreeExpansion"; +import { + isFolderExpanded, + openAncestors, + toggleFolder, +} from "@app/lib/fileTreeExpansion"; + +function create(): FolderExpansion { + return { opened: new Set(), collapsedOnVisit: new Map() }; +} + +let visits = 0; +function navigate(state: FolderExpansion, path: string): Location { + openAncestors(state, path); + return { path, visit: ++visits }; +} + +test("folders containing the open file are expanded", () => { + const state = create(); + const location = { path: "docs/guides/setup.md", visit: 0 }; + + expect(isFolderExpanded(state, "docs/", location)).toBe(true); + expect(isFolderExpanded(state, "docs/guides/", location)).toBe(true); + expect(isFolderExpanded(state, "src/", location)).toBe(false); +}); + +test("only the open file's own ancestors are expanded", () => { + const state = create(); + const location = navigate(state, "src/doc/readme.md"); + + expect(isFolderExpanded(state, "doc/", location)).toBe(false); + expect(isFolderExpanded(state, "src/do/", location)).toBe(false); +}); + +test("a file at the root opens no folders", () => { + const state = create(); + navigate(state, "README.md"); + + expect(state.opened.size).toBe(0); +}); + +test("folders stay open after navigating to a file elsewhere", () => { + const state = create(); + navigate(state, "docs/guides/setup.md"); + const location = navigate(state, "src/main.ts"); + + expect(isFolderExpanded(state, "docs/", location)).toBe(true); + expect(isFolderExpanded(state, "docs/guides/", location)).toBe(true); +}); + +test("a folder opened by hand stays open after navigating", () => { + const state = create(); + toggleFolder(state, "src/", navigate(state, "README.md")); + const location = navigate(state, "docs/setup.md"); + + expect(isFolderExpanded(state, "src/", location)).toBe(true); +}); + +test("a folder collapsed by hand stays closed after navigating elsewhere", () => { + const state = create(); + toggleFolder(state, "docs/", navigate(state, "docs/setup.md")); + const location = navigate(state, "src/main.ts"); + + expect(isFolderExpanded(state, "docs/", location)).toBe(false); +}); + +test("the open file's folder can be collapsed and expanded again", () => { + const state = create(); + const location = navigate(state, "docs/setup.md"); + + toggleFolder(state, "docs/", location); + expect(isFolderExpanded(state, "docs/", location)).toBe(false); + + toggleFolder(state, "docs/", location); + expect(isFolderExpanded(state, "docs/", location)).toBe(true); +}); + +test("navigating into a collapsed folder expands it", () => { + const state = create(); + toggleFolder(state, "docs/", navigate(state, "docs/setup.md")); + const location = navigate(state, "docs/usage.md"); + + expect(isFolderExpanded(state, "docs/", location)).toBe(true); +}); + +test("navigating back to the file a folder was collapsed on expands it", () => { + const state = create(); + toggleFolder(state, "docs/", navigate(state, "docs/setup.md")); + navigate(state, "src/main.ts"); + const location = navigate(state, "docs/setup.md"); + + expect(isFolderExpanded(state, "docs/", location)).toBe(true); +}); diff --git a/desktop/tests/unit/frontmatter.test.ts b/desktop/tests/unit/frontmatter.test.ts new file mode 100644 index 000000000..3c0cce095 --- /dev/null +++ b/desktop/tests/unit/frontmatter.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "vitest"; + +import { parseFrontmatter } from "@app/lib/frontmatter"; + +test.each([ + ["LF", "---\ntitle: Hello\nn: 1\n---\n# Body\n"], + ["CRLF", "---\r\ntitle: Hello\r\nn: 1\r\n---\r\n# Body\n"], +])("splits %s frontmatter from the content", (_, input) => { + expect(parseFrontmatter(input)).toEqual({ + data: { title: "Hello", n: 1 }, + content: "# Body\n", + }); +}); + +test("accepts a closing fence at the end of the input", () => { + expect(parseFrontmatter("---\na: 1\n---")).toEqual({ + data: { a: 1 }, + content: "", + }); +}); + +test("stops at the first closing fence", () => { + expect(parseFrontmatter("---\na: 1\n---\ntext\n---\nmore")).toEqual({ + data: { a: 1 }, + content: "text\n---\nmore", + }); +}); + +test.each([ + "# Title\n---\na: 1\n---\n", + " ---\na: 1\n---\n", + "---\na: 1\n", + "---\n---\n", +])("leaves %j without frontmatter as is", input => { + expect(parseFrontmatter(input)).toEqual({ data: {}, content: input }); +}); + +test.each([ + ["a scalar", "just text"], + ["a list", "- a\n- b"], + ["null", "~"], + ["nothing", ""], + ["a comment", "# draft"], + ["invalid YAML", "Note: see: below"], +])("drops a block holding %s but strips it", (_, yaml) => { + expect(parseFrontmatter(`---\n${yaml}\n---\nbody`)).toEqual({ + data: {}, + content: "body", + }); +}); diff --git a/desktop/tests/unit/hints.test.ts b/desktop/tests/unit/hints.test.ts new file mode 100644 index 000000000..3cf37c98e --- /dev/null +++ b/desktop/tests/unit/hints.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +async function load() { + vi.resetModules(); + return (await import("@app/lib/hints")).hints; +} + +beforeEach(() => { + localStorage.clear(); + vi.spyOn(console, "error").mockReturnValue(undefined); +}); + +test("starts with nothing dismissed", async () => { + const hints = await load(); + + expect(hints.isDismissed("markdown")).toBe(false); + expect(hints.dismissedCount).toBe(0); +}); + +test("remembers dismissed hints across reloads", async () => { + const hints = await load(); + hints.dismiss("markdown"); + hints.dismiss("markdown"); + + const reloaded = await load(); + expect(reloaded.isDismissed("markdown")).toBe(true); + expect(reloaded.isDismissed("guide")).toBe(false); + expect(reloaded.dismissedCount).toBe(1); +}); + +test("resetAll brings every hint back", async () => { + const hints = await load(); + hints.dismiss("markdown"); + hints.dismiss("guide"); + expect(hints.dismissedCount).toBe(2); + + hints.resetAll(); + + expect(hints.isDismissed("markdown")).toBe(false); + expect(hints.dismissedCount).toBe(0); + expect((await load()).dismissedCount).toBe(0); +}); + +test("ignores an invalid stored value", async () => { + localStorage.setItem("dismissedHints", '"markdown"'); + + const hints = await load(); + + expect(hints.isDismissed("markdown")).toBe(false); + expect(hints.dismissedCount).toBe(0); +}); diff --git a/desktop/tests/unit/identityState.test.ts b/desktop/tests/unit/identityState.test.ts new file mode 100644 index 000000000..302b91621 --- /dev/null +++ b/desktop/tests/unit/identityState.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; + +import { stateCaption } from "@app/lib/identityState"; + +test("stateCaption names the sibling that was accepted", () => { + expect( + stateCaption({ + status: "rejected", + reason: { + type: "sibling", + revision: "4b2d0a1e9f5c3b7a6d8e0f1a2b3c4d5e6f7a8b9c", + }, + }), + ).toBe("Rejected because sibling 4b2d0a1 was accepted"); + expect(stateCaption({ status: "accepted" })).toBeUndefined(); +}); diff --git a/desktop/tests/unit/ignoredFiles.test.ts b/desktop/tests/unit/ignoredFiles.test.ts new file mode 100644 index 000000000..c5f3c311d --- /dev/null +++ b/desktop/tests/unit/ignoredFiles.test.ts @@ -0,0 +1,31 @@ +import type { FileDiff } from "@bindings/diff/FileDiff"; + +import { expect, test } from "vitest"; + +import { isIgnoredFile, isIgnoredPath } from "@app/lib/ignoredFiles"; + +const diff = { type: "empty" } as const; + +test.each([ + ["Cargo.lock", true], + ["web/package-lock.json", true], + ["a/b/go.sum", true], + ["Cargo.lock.bak", false], + ["cargo.lock", false], + ["Cargo.lock/main.rs", false], + ["src/main.rs", false], +])("isIgnoredPath(%j) is %s", (path, expected) => { + expect(isIgnoredPath(path)).toBe(expected); +}); + +test("isIgnoredFile judges a renamed file by its new path", () => { + const moved = (oldPath: string, newPath: string): FileDiff => ({ + status: "moved", + oldPath, + newPath, + diff, + }); + + expect(isIgnoredFile(moved("yarn.lock", "notes.txt"))).toBe(false); + expect(isIgnoredFile(moved("notes.txt", "yarn.lock"))).toBe(true); +}); diff --git a/desktop/tests/unit/inboxFilter.test.ts b/desktop/tests/unit/inboxFilter.test.ts new file mode 100644 index 000000000..691b0cc6f --- /dev/null +++ b/desktop/tests/unit/inboxFilter.test.ts @@ -0,0 +1,106 @@ +import type { Author } from "@bindings/cob/Author"; +import type { NotificationItem } from "@bindings/cob/inbox/NotificationItem"; +import type { NotificationsByRepo } from "@bindings/cob/inbox/NotificationsByRepo"; + +import { describe, expect, test } from "vitest"; + +import { filterNotifications, rowIdsOf } from "@app/lib/inboxFilter"; + +import { author } from "./support/cobs"; + +function issue( + id: string, + rowId: string, + props: { title?: string; authors?: Author[]; closed?: boolean } = {}, +): NotificationItem { + const { title = "Untitled", authors = [author("alice")], closed } = props; + return { + type: "issue", + rowId, + id, + update: { type: "created", name: "refs/cobs", oid: "o" }, + title, + timestamp: 0, + status: closed + ? { status: "closed", reason: "solved" } + : { status: "open" }, + actions: authors.map(by => ({ + type: "edit", + title, + oid: "o", + timestamp: 0, + author: by, + })), + repoId: "rad:z1", + }; +} + +function repo( + rid: string, + notifications: NotificationItem[][], +): NotificationsByRepo { + return { rid, name: rid, notifications, count: 99 }; +} + +const crash = [issue("aaa111", "1", { title: "Crash on startup" })]; +const renamed = [ + issue("bbb222", "2", { title: "Old wording" }), + issue("bbb222", "3", { + title: "Sidebar overflow", + authors: [author("bob"), author("carol", null)], + closed: true, + }), +]; +const repos = [repo("rad:z1", [crash, renamed]), repo("rad:z2", [])]; + +function ids(query: string, excluded?: string[]) { + return filterNotifications(repos, query, excluded).map(r => ({ + rid: r.repo.rid, + ids: r.groups.map(g => g[0].id), + })); +} + +describe("filterNotifications", () => { + test.each(["", " "])("returns every repo unchanged for %j", query => { + expect(filterNotifications(repos, query)).toEqual([ + { repo: repos[0], groups: [crash, renamed], count: 99 }, + { repo: repos[1], groups: [], count: 99 }, + ]); + }); + + test.each([ + ["the latest title", "sidebar"], + ["the latest state", "closed"], + ["an alias from any item", "bob"], + ["a DID", "z6Mkcarol"], + ["the id", "bbb222"], + ])("matches %s", (_, query) => { + expect(ids(query)).toEqual([{ rid: "rad:z1", ids: ["bbb222"] }]); + }); + + test("matches the type", () => { + expect(ids("issue")[0].ids).toEqual( + expect.arrayContaining(["aaa111", "bbb222"]), + ); + }); + + test.each([ + ["an earlier title", "wording"], + ["a missing alias", "undefined"], + ["a weak fuzzy match", "cso"], + ])("doesn't match %s", (_, query) => { + expect(ids(query)).toEqual([]); + }); + + test("counts the items of the matching groups", () => { + expect(filterNotifications(repos, "sidebar")[0].count).toBe(2); + }); + + test("drops excluded groups and repos left empty", () => { + expect(ids("crash", ["aaa111"])).toEqual([]); + }); +}); + +test("rowIdsOf lists the rows of every group", () => { + expect(rowIdsOf([crash, renamed])).toEqual(["1", "2", "3"]); +}); diff --git a/desktop/tests/unit/inputValidation.test.ts b/desktop/tests/unit/inputValidation.test.ts new file mode 100644 index 000000000..635bc60e5 --- /dev/null +++ b/desktop/tests/unit/inputValidation.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "vitest"; + +import { + aliasErrors, + labelError, + parseAssignee, +} from "@app/lib/inputValidation"; + +const nid = "z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK"; + +test.each([ + ["alice", []], + ["", ["AliasError.EmptyAlias"]], + ["a".repeat(32), []], + ["a".repeat(33), ["AliasError.TooLongAlias"]], + ["a b", ["AliasError.InvalidAlias"]], + [ + `${"a ".repeat(17)}`, + ["AliasError.TooLongAlias", "AliasError.InvalidAlias"], + ], +])("aliasErrors(%j) is %j", (alias, expected) => { + expect(aliasErrors(alias)).toEqual(expected); +}); + +test("labelError flags a label that is already assigned", () => { + expect(labelError(" bug ", ["bug"])).toBe("This label is already assigned"); + expect(labelError("feature", ["bug"])).toBeUndefined(); + expect(labelError(" ", ["bug"])).toBeUndefined(); +}); + +describe("parseAssignee", () => { + test("accepts a NID or a DID", () => { + expect(parseAssignee(nid, [])).toEqual({ did: `did:key:${nid}` }); + expect(parseAssignee(`did:key:${nid}`, [])).toEqual({ + did: `did:key:${nid}`, + }); + }); + + test("ignores surrounding whitespace", () => { + expect(parseAssignee(` ${nid} `, [])).toEqual({ did: `did:key:${nid}` }); + expect(parseAssignee(` did:key:${nid}`, [])).toEqual({ + did: `did:key:${nid}`, + }); + }); + + test("rejects an assignee that is already added", () => { + expect(parseAssignee(nid, [{ did: `did:key:${nid}` }])).toEqual({ + error: "This assignee is already added", + }); + }); + + test("rejects a malformed DID", () => { + expect(parseAssignee("did:key:alice", [])).toEqual({ + error: "This is not a valid DID", + }); + }); + + test("treats other input as a search, not an error", () => { + expect(parseAssignee("alice", [])).toEqual({}); + expect(parseAssignee("z6Mkq", [])).toEqual({}); + expect(parseAssignee("", [])).toEqual({}); + }); +}); diff --git a/desktop/tests/unit/interval.test.ts b/desktop/tests/unit/interval.test.ts new file mode 100644 index 000000000..e76e2d9cf --- /dev/null +++ b/desktop/tests/unit/interval.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { dynamicInterval, poll, resetDynamicInterval } from "@app/lib/interval"; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + resetDynamicInterval("a"); + resetDynamicInterval("b"); + vi.useRealTimers(); +}); + +test("runs the callback once per period", () => { + const callback = vi.fn(); + dynamicInterval("a", callback, 100); + + vi.advanceTimersByTime(99); + expect(callback).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(callback).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(200); + expect(callback).toHaveBeenCalledTimes(3); +}); + +test("rescheduling a key replaces its callback and period", () => { + const first = vi.fn(); + const second = vi.fn(); + dynamicInterval("a", first, 100); + vi.advanceTimersByTime(50); + dynamicInterval("a", second, 300); + + vi.advanceTimersByTime(299); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(second).toHaveBeenCalledTimes(1); +}); + +test("an async callback can reschedule its own key", async () => { + const callback = vi.fn(async () => { + await Promise.resolve(); + dynamicInterval("a", callback, 500); + }); + dynamicInterval("a", callback, 100); + + await vi.advanceTimersByTimeAsync(100); + expect(callback).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(499); + expect(callback).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(callback).toHaveBeenCalledTimes(2); +}); + +test("resetting a key stops its callback", () => { + const callback = vi.fn(); + dynamicInterval("a", callback, 100); + resetDynamicInterval("a"); + + vi.advanceTimersByTime(1000); + expect(callback).not.toHaveBeenCalled(); +}); + +test("keys run independently", () => { + const a = vi.fn(); + const b = vi.fn(); + dynamicInterval("a", a, 100); + dynamicInterval("b", b, 100); + + vi.advanceTimersByTime(100); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); +}); + +test("poll runs at once and again a period after each run settles", async () => { + let release: (() => void) | undefined; + const task = vi.fn(() => new Promise(resolve => (release = resolve))); + const stop = poll(task, 100); + expect(task).toHaveBeenCalledTimes(1); + + // A slow run never overlaps the next one. + await vi.advanceTimersByTimeAsync(500); + expect(task).toHaveBeenCalledTimes(1); + + release?.(); + await vi.advanceTimersByTimeAsync(99); + expect(task).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(task).toHaveBeenCalledTimes(2); + stop(); +}); + +test("stopping a poll ends it and marks a pending run inactive", async () => { + let active: (() => boolean) | undefined; + let release: (() => void) | undefined; + const task = vi.fn( + (isActive: () => boolean) => + new Promise(resolve => { + active = isActive; + release = resolve; + }), + ); + const stop = poll(task, 100); + expect(active?.()).toBe(true); + + stop(); + expect(active?.()).toBe(false); + release?.(); + await vi.advanceTimersByTimeAsync(1000); + expect(task).toHaveBeenCalledTimes(1); +}); diff --git a/desktop/tests/unit/issueCounts.test.ts b/desktop/tests/unit/issueCounts.test.ts new file mode 100644 index 000000000..0fc908b86 --- /dev/null +++ b/desktop/tests/unit/issueCounts.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, expect, test } from "vitest"; + +import { + issueCountMismatch, + issueCounts, + resetIssueCounts, + updateIssueCounts, +} from "@app/lib/issueCounts.svelte"; + +const sidebar = { open: 3, closed: 2, all: 5 }; + +beforeEach(() => { + resetIssueCounts(); +}); + +test.each([ + ["all", 5], + ["open", 3], + ["closed", 2], +] as const)( + "matches when the %s list has the sidebar's count", + (status, count) => { + updateIssueCounts(count, sidebar, status); + + expect(issueCounts[status]).toEqual({ sidebar: count, total: count }); + expect(issueCountMismatch(status)).toBe(false); + }, +); + +test("flags a list whose length differs from the sidebar", () => { + updateIssueCounts(4, sidebar, "open"); + + expect(issueCountMismatch("open")).toBe(true); + expect(issueCountMismatch("closed")).toBe(false); +}); + +test("reset clears every status", () => { + updateIssueCounts(4, sidebar, "open"); + updateIssueCounts(1, sidebar, "closed"); + resetIssueCounts(); + + expect(issueCounts).toEqual({ + all: { sidebar: null, total: null }, + open: { sidebar: null, total: null }, + closed: { sidebar: null, total: null }, + }); + expect(issueCountMismatch("open")).toBe(false); +}); diff --git a/desktop/tests/unit/issueTimeline.test.ts b/desktop/tests/unit/issueTimeline.test.ts new file mode 100644 index 000000000..283f64e01 --- /dev/null +++ b/desktop/tests/unit/issueTimeline.test.ts @@ -0,0 +1,65 @@ +import type { Issue } from "@bindings/cob/issue/Issue"; + +import { expect, test } from "vitest"; + +import { issueTimeline } from "@app/lib/issueTimeline"; + +import { author, operation } from "./support/cobs"; + +const alice = author("alice"); +const bob = author("bob"); + +function issue(props: Partial = {}): Issue { + return { + id: "issue", + author: alice, + title: "Title", + state: { status: "open" }, + assignees: [], + commentCount: 0, + labels: [], + timestamp: 50, + ...props, + }; +} + +test("starts with the opening, timed by the first edit of the body", () => { + const [opened] = issueTimeline( + issue({ + body: { + id: "body", + author: alice, + edits: [{ author: alice, timestamp: 7, body: "Description" }], + reactions: [], + replyTo: null, + location: null, + resolved: false, + }, + }), + [], + ); + + expect(opened).toEqual({ + key: "issue:opened", + timestamp: 7, + data: { type: "opened", id: "issue", author: alice, timestamp: 7 }, + }); +}); + +test("falls back to the issue's timestamp without a body", () => { + expect(issueTimeline(issue(), [])[0].timestamp).toBe(50); +}); + +test("leaves comments out of the timeline", () => { + const items = issueTimeline(issue(), [ + operation(bob, 60, [ + { type: "comment", body: "Hi" }, + { type: "comment.edit", id: "c", body: "Hello" }, + { type: "comment.react", id: "c", reaction: "👍", active: true }, + { type: "comment.redact", id: "c" }, + { type: "label", labels: ["bug"] }, + ]), + ]); + + expect(items.map(i => i.data.type)).toEqual(["opened", "label"]); +}); diff --git a/desktop/tests/unit/listState.test.ts b/desktop/tests/unit/listState.test.ts new file mode 100644 index 000000000..af63f27f5 --- /dev/null +++ b/desktop/tests/unit/listState.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "vitest"; + +import { readListState, saveListState } from "@app/lib/listState"; + +function state(scrollOffset: number) { + return { items: [scrollOffset], more: false, scrollOffset, cache: undefined }; +} + +function saveMany(prefix: string, count: number) { + for (let i = 0; i < count; i++) { + saveListState(`${prefix}${i}`, state(i)); + } +} + +test("reads back the latest saved state", () => { + expect(readListState("list")).toBeUndefined(); + + saveListState("list", state(1)); + saveListState("list", state(2)); + + expect(readListState("list")).toEqual(state(2)); +}); + +test("keeps only the 20 most recent lists", () => { + saveMany("a", 21); + + expect(readListState("a0")).toBeUndefined(); + expect(readListState("a1")).toEqual(state(1)); + expect(readListState("a20")).toEqual(state(20)); +}); + +test("saving a list again makes it the most recent", () => { + saveMany("b", 20); + saveListState("b0", state(0)); + saveListState("b20", state(20)); + + expect(readListState("b0")).toEqual(state(0)); + expect(readListState("b1")).toBeUndefined(); +}); diff --git a/desktop/tests/unit/markdownFormat.test.ts b/desktop/tests/unit/markdownFormat.test.ts new file mode 100644 index 000000000..4b6b92d3d --- /dev/null +++ b/desktop/tests/unit/markdownFormat.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "vitest"; + +import type { MarkdownFormat } from "@app/lib/markdownFormat"; +import { + applyMarkdownFormat, + applyTextEdit, + pasteLinkEdit, +} from "@app/lib/markdownFormat"; + +// Pipes mark the selection in both the input and the output. +function format(input: string, kind: MarkdownFormat): string { + const start = input.indexOf("|"); + const end = input.indexOf("|", start + 1) - 1; + const value = input.replaceAll("|", ""); + const edit = applyMarkdownFormat(kind, value, start, end); + const result = applyTextEdit(value, edit); + + return result + .substring(0, edit.selectionStart) + .concat( + "|", + result.substring(edit.selectionStart, edit.selectionEnd), + "|", + result.substring(edit.selectionEnd), + ); +} + +describe("bold", () => { + test.each([ + ["a |word| here", "a **|word|** here"], + ["a ||word", "a **||**word"], + ["a **|word|** here", "a |word| here"], + ["a |**word**| here", "a |word| here"], + ["a **||** here", "a || here"], + ])("%j => %j", (input, expected) => { + expect(format(input, "bold")).toBe(expected); + }); +}); + +describe("italic", () => { + test.each([ + ["a |word| here", "a _|word|_ here"], + ["a _|word|_ here", "a |word| here"], + ["a |_word_| here", "a |word| here"], + ["**|word|**", "**_|word|_**"], + ])("%j => %j", (input, expected) => { + expect(format(input, "italic")).toBe(expected); + }); +}); + +describe("code", () => { + test.each([ + ["a |word| here", "a `|word|` here"], + ["a `|word|` here", "a |word| here"], + ["|one\ntwo|", "```\n|one\ntwo|\n```"], + ["```\n|one\ntwo|\n```", "|one\ntwo|"], + ["a |one\ntwo| b", "a \n```\n|one\ntwo|\n```\n b"], + ["a\n|one\ntwo|\nb", "a\n```\n|one\ntwo|\n```\nb"], + ])("%j => %j", (input, expected) => { + expect(format(input, "code")).toBe(expected); + }); +}); + +describe("link", () => { + test.each([ + ["see |docs| now", "see [docs](||) now"], + ["see || now", "see [](||) now"], + ["see |https://radicle.xyz| now", "see [||](https://radicle.xyz) now"], + ])("%j => %j", (input, expected) => { + expect(format(input, "link")).toBe(expected); + }); +}); + +describe("pasteLinkEdit", () => { + test("wraps the selection and leaves the caret after the link", () => { + const value = "Read the docs here"; + const edit = pasteLinkEdit(value, 9, 13, "https://radicle.xyz"); + + expect(edit && applyTextEdit(value, edit)).toBe( + "Read the [docs](https://radicle.xyz) here", + ); + expect(edit?.selectionStart).toBe(36); + expect(edit?.selectionEnd).toBe(36); + }); + + test("falls through without a selection", () => { + expect(pasteLinkEdit("docs", 2, 2, "https://radicle.xyz")).toBeUndefined(); + }); + + test.each([ + ["https://example.com", "https://example.com"], + ["http://example.com/a?b=1#c", "http://example.com/a?b=1#c"], + [" https://example.com/path ", "https://example.com/path"], + [ + "https://app.radicle.xyz/nodes/iris.radicle.xyz/rad:z4D5U/issues/7bdaa5a", + "https://app.radicle.xyz/nodes/iris.radicle.xyz/rad:z4D5U/issues/7bdaa5a", + ], + ])("links %j", (pasted, url) => { + expect(pasteLinkEdit("docs", 0, 4, pasted)?.text).toBe(`[docs](${url})`); + }); + + test.each([ + "javascript:alert(1)", + "data:text/html,", + "file:///etc/passwd", + "rad:z4D5UCArafTzTQpDZNQRuqswh3ury", + "https://example.com and more", + "https://example.com\nhttps://example.org", + "just some words", + " ", + "", + ])("falls through for %j", pasted => { + expect(pasteLinkEdit("docs", 0, 4, pasted)).toBeUndefined(); + }); +}); diff --git a/desktop/tests/unit/markdownPlain.test.ts b/desktop/tests/unit/markdownPlain.test.ts new file mode 100644 index 000000000..b36165ee2 --- /dev/null +++ b/desktop/tests/unit/markdownPlain.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "vitest"; + +import { plainText } from "@app/lib/markdownPlain"; + +test.each([ + ["[the docs](https://example.com)", "the docs"], + ["![diagram](a.png) here", "diagram here"], + ["run `npm test` now", "run npm test now"], + ["~~old~~ new", "old new"], + ["**bold** and _italic_", "bold and italic"], + ["__strong__ and *em*", "strong and em"], + ["# Heading", "Heading"], + ["### Deep heading", "Deep heading"], + ["> quoted\n> text", "quoted text"], + ["- one\n* two\n+ three\n1. four", "one two three four"], + ["line one\n\n line two", "line one line two"], + [" padded ", "padded"], +])("%j reads as %j", (markdown, expected) => { + expect(plainText(markdown)).toBe(expected); +}); + +test.each([ + "snake_case_name", + "MAX_SIZE", + "_private", + "see _foo_bar", + "a*b*c", + "2 * 3 * 4", +])("leaves %j alone", text => { + expect(plainText(text)).toBe(text); +}); diff --git a/desktop/tests/unit/markdownTasks.test.ts b/desktop/tests/unit/markdownTasks.test.ts new file mode 100644 index 000000000..84c93b2c1 --- /dev/null +++ b/desktop/tests/unit/markdownTasks.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "vitest"; + +import { toggleTask } from "@app/lib/markdown"; + +describe("toggleTask", () => { + test.each([ + ["ticks an open box", "- [ ] a\n- [ ] b", 0, "- [x] a\n- [ ] b"], + ["clears a ticked box", "- [ ] a\n- [x] b", 1, "- [ ] a\n- [ ] b"], + ["clears an upper-case box", "- [X] a", 0, "- [ ] a"], + ["works on ordered lists", "1. [ ] a\n2) [ ] b", 1, "1. [ ] a\n2) [x] b"], + [ + "works on nested items", + "- a\n - [ ] n\n- [x] b", + 0, + "- a\n - [x] n\n- [x] b", + ], + ["works inside a blockquote", "> - [ ] q", 0, "> - [x] q"], + [ + "keeps CRLF line endings", + "- [ ] a\r\n- [x] b\r\n", + 0, + "- [x] a\r\n- [x] b\r\n", + ], + [ + "keeps frontmatter", + "---\nx: 1\n---\n- [ ] a", + 0, + "---\nx: 1\n---\n- [x] a", + ], + ])("%s", (_, input, index, expected) => { + expect(toggleTask(input, index)).toBe(expected); + }); + + test.each([ + ["an HTML comment", "\n"], + ["an HTML block", "
\n- [ ] hidden\n
\n\n"], + ["an indented code block", "para\n\n - [ ] code\n\n"], + ["a fence holding a shorter fence", "````\n```\n- [ ] c\n```\n````\n"], + ["a fence inside a blockquote", "> ```\n> - [ ] c\n> ```\n\n"], + ["a math block", "$$\n- [ ] x\n$$\n\n"], + ["an ordered list that can't interrupt a paragraph", "para\n2. [ ] x\n\n"], + ["a checkbox written as HTML", ' hi\n\n'], + [ + "a checkbox with a look-alike class", + '- foo \n', + ], + ])("skips %s", (_, prefix) => { + expect(toggleTask(`${prefix}- [ ] real`, 0)).toBe(`${prefix}- [x] real`); + }); + + test("counts boxes in the order they are rendered", () => { + // Footnotes render at the end, after the list that follows them. + const doc = "t[^1]\n\n[^1]: - [ ] note\n\n- [ ] real"; + expect(toggleTask(doc, 0)).toBe("t[^1]\n\n[^1]: - [ ] note\n\n- [x] real"); + expect(toggleTask(doc, 1)).toBe("t[^1]\n\n[^1]: - [x] note\n\n- [ ] real"); + }); + + test("ignores a box inside an unclosed fence", () => { + expect(toggleTask("- [ ] real\n```\n- [ ] c", 0)).toBe( + "- [x] real\n```\n- [ ] c", + ); + }); + + test.each([ + ["an index past the last box", "- [ ] a", 1], + ["an empty document", "", 0], + ["a document without tasks", "- a\n- b", 0], + ["a box with no text after it", "- [ ]", 0], + ])("returns undefined for %s", (_, input, index) => { + expect(toggleTask(input, index)).toBeUndefined(); + }); +}); diff --git a/desktop/tests/unit/mentionTrigger.test.ts b/desktop/tests/unit/mentionTrigger.test.ts new file mode 100644 index 000000000..56936095e --- /dev/null +++ b/desktop/tests/unit/mentionTrigger.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from "vitest"; + +import { findMentionTrigger } from "@app/lib/mentionTrigger"; + +const repo = "z3gqcJUoA1n9HaHKufZs5FCSGazv5"; +const rid = `rad:${repo}`; +const nid = "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"; +const oid = "5e4683023476778201ab25c87e1343fbfbe67c89"; + +function trigger(text: string, caret = text.length) { + return findMentionTrigger(text, caret); +} + +describe("queries", () => { + test.each([ + ["@", { scope: "all", start: 0, query: "" }], + ["#", { scope: "cobs", start: 0, query: "" }], + ["hi @rud", { scope: "all", start: 3, query: "rud" }], + ["(#fix", { scope: "cobs", start: 1, query: "fix" }], + ["line\n@a.b-c", { scope: "all", start: 5, query: "a.b-c" }], + ])("%j", (text, expected) => { + expect(trigger(text)).toEqual({ + kind: "query", + end: text.length, + ...expected, + }); + }); + + test.each([ + ["an email address", "me@example"], + ["a URL fragment", "page#section"], + ["a heading", "# Heading"], + ["a query past the limit", `@${"a".repeat(65)}`], + ])("ignores %s", (_, text) => { + expect(trigger(text)).toBeUndefined(); + }); + + test("only considers text up to the caret", () => { + expect(trigger("@rud and more", 4)).toMatchObject({ query: "rud" }); + expect(trigger("@rud and more")).toBeUndefined(); + }); +}); + +describe("identifiers", () => { + test.each([ + [`see did:key:${nid}`, 4, { type: "node", nid }], + [`(${rid}`, 1, { type: "repo", rid }], + [ + `https://app.radicle.xyz/nodes/seed/${rid}/issues/${oid}`, + 0, + { type: "cob", kind: "issue", rid, oid }, + ], + [`https://app.radicle.xyz/nodes/seed/${rid}`, 0, { type: "repo", rid }], + [nid, 0, { type: "node", nid }], + [`x ${repo}`, 2, { type: "repo", rid }], + ])("%s", (text, start, target) => { + expect(trigger(text)).toEqual({ + kind: "identifier", + start, + end: text.length, + target, + }); + }); + + test("does not reopen on an inserted link", () => { + expect(trigger(`[heartwood](${rid})`)).toBeUndefined(); + }); + + test("offers a full oid, lowercased", () => { + expect(trigger(`see ${oid.toUpperCase()}`)).toEqual({ + kind: "oid", + start: 4, + end: 44, + oid, + }); + }); + + test("ignores an abbreviated oid", () => { + expect(trigger(oid.slice(0, 39))).toBeUndefined(); + }); +}); + +describe("explorer file links", () => { + test("defers the branch and path split to the repo", () => { + const url = `https://radicle.network/nodes/seed/${rid}/tree/0004-general-uri-scheme/general-uri-scheme.adoc`; + expect(trigger(`see ${url}`)).toEqual({ + kind: "tree", + start: 4, + end: 4 + url.length, + rid, + namespace: undefined, + path: "0004-general-uri-scheme/general-uri-scheme.adoc", + }); + }); +}); + +describe("rad: file links", () => { + test("offers a file URI as a file", () => { + const uri = `${rid}/commit/master?path=.github/README.md`; + expect(trigger(`see ${uri}`)).toMatchObject({ + kind: "link", + start: 4, + end: 4 + uri.length, + }); + }); + + test.each([ + ["a tag", `${rid}/tag/v1.0`], + ["a branch", `${rid}/commit/main`], + ["a remote", `${rid}/${nid}`], + [ + "a remote's explorer page", + `https://radicle.network/nodes/seed/${rid}/remotes/${nid}`, + ], + ])("offers %s as a link", (_, text) => { + expect(trigger(text)).toMatchObject({ kind: "link", start: 0 }); + }); + + test.each([ + ["a tree by oid", `${rid}/tree/${oid}`], + ["a blob by oid", `${rid}/blob/${oid}`], + ["an unknown cob type", `${rid}/cob/org.example/${oid}`], + ])("offers nothing for %s, which has no page", (_, text) => { + expect(trigger(text)).toBeUndefined(); + }); + + test("does not take the tail of a path as a bare id", () => { + expect(trigger(`/some/path/${nid}`)).toBeUndefined(); + expect(trigger(`see:${oid}`)).toBeUndefined(); + }); +}); + +describe("explorer links without a chip", () => { + const node = "https://radicle.network/nodes/seed"; + + test.each([ + ["an issue list", `${node}/${rid}/issues`], + ["a path at an oid", `${node}/${rid}/tree/${oid}/src`], + ["a tree at an oid", `${node}/${rid}/tree/${oid}`], + ])("offers %s as a link", (_, url) => { + expect(trigger(url)).toMatchObject({ kind: "link", start: 0 }); + }); + + test("leaves a lone branch for the repo to resolve", () => { + expect(trigger(`${node}/${rid}/tree/main`)).toMatchObject({ + kind: "tree", + path: "main", + }); + }); +}); + +describe("releases and lines", () => { + const node = "https://radicle.network/nodes/seed"; + + test.each([ + `${node}/${rid}/releases/${oid}`, + `${rid}/cob/dev.radicle.artifact/${oid}`, + ])("offers %s as a release", link => { + expect(trigger(link)).toMatchObject({ + kind: "identifier", + target: { type: "cob", kind: "release", rid, oid }, + }); + }); + + test("offers a release list as a link", () => { + expect(trigger(`${node}/${rid}/releases`)).toMatchObject({ + kind: "link", + reference: { + uri: { resource: { type: "cob", typeName: "dev.radicle.artifact" } }, + }, + }); + }); + + test("keeps the line of a file on a branch", () => { + expect(trigger(`${node}/${rid}/tree/main/src/lib.rs#L10`)).toMatchObject({ + kind: "tree", + path: "main/src/lib.rs", + fragment: "L10", + }); + }); + + test("offers a rad: file URI with a line as a link", () => { + expect(trigger(`${rid}/commit/main?path=src/lib.rs#L10`)).toMatchObject({ + kind: "link", + reference: { uri: { fragment: "L10" } }, + }); + }); +}); diff --git a/desktop/tests/unit/mentions.test.ts b/desktop/tests/unit/mentions.test.ts new file mode 100644 index 000000000..f457a7642 --- /dev/null +++ b/desktop/tests/unit/mentions.test.ts @@ -0,0 +1,336 @@ +import type { Config } from "@bindings/config/Config"; + +import { describe, expect, test } from "vitest"; + +import { markdownWithExtensions } from "@app/lib/markdown"; +import { + bareReferenceStart, + describeLink, + entityMarkdown, + entityUri, + entityUrl, + matchBareReference, + parseBareIdentifier, + parseEntityHref, + referenceMarkdown, + referenceRoute, +} from "@app/lib/mentions"; +import { parseReference } from "@app/lib/radUri"; + +const repo = "z3gqcJUoA1n9HaHKufZs5FCSGazv5"; +const rid = `rad:${repo}`; +const nid = "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"; +const oid = "5e4683023476778201ab25c87e1343fbfbe67c89"; + +describe("parseEntityHref", () => { + test.each([ + [`did:key:${nid}`, { type: "node", nid }], + [rid, { type: "repo", rid }], + [`${rid}/commit/${oid}`, { type: "commit", rid, oid }], + [ + `${rid}/cob/xyz.radicle.issue/${oid}`, + { type: "cob", kind: "issue", rid, oid }, + ], + [ + `${rid}/cob/xyz.radicle.patch/${oid}`, + { type: "cob", kind: "patch", rid, oid }, + ], + [ + `${rid}/cob/dev.radicle.artifact/${oid}`, + { type: "cob", kind: "release", rid, oid }, + ], + [ + `https://app.radicle.xyz/nodes/seed/${rid}/releases/${oid}`, + { type: "cob", kind: "release", rid, oid }, + ], + [ + `https://app.radicle.xyz/nodes/seed/${rid}/patches/${oid}`, + { type: "cob", kind: "patch", rid, oid }, + ], + [` ${rid} `, { type: "repo", rid }], + ])("%s", (href, expected) => { + expect(parseEntityHref(href)).toEqual(expected); + }); + + test.each([ + ["a tag", `${rid}/tag/v1.0`], + ["a tree", `${rid}/tree/${oid}`], + ["a commit by branch", `${rid}/commit/master`], + ["an unknown cob type", `${rid}/cob/org.example/${oid}`], + ["a cob list", `${rid}/cob/xyz.radicle.issue`], + ["a namespace", `${rid}/${nid}`], + ["a query", `${rid}/commit/${oid}?path=src`], + ["a fragment", `${rid}/commit/${oid}#L10`], + ["an unrelated link", "https://example.com"], + ])("has no chip for %s", (_, href) => { + expect(parseEntityHref(href)).toBeUndefined(); + }); +}); + +describe("parseBareIdentifier", () => { + test.each([ + [nid, { type: "node", nid }], + [repo, { type: "repo", rid }], + ["zlib", undefined], + [oid, undefined], + ])("%s", (token, expected) => { + expect(parseBareIdentifier(token)).toEqual(expected); + }); +}); + +describe("bareReferenceStart", () => { + test.each([ + [`${rid} at the start`, 0], + [`see ${rid}`, 4], + [`(did:key:${nid})`, 1], + [`Conrad:${repo}`, undefined], + [`Conrad:${repo} and ${rid}`, 41], + ])("%s", (src, expected) => { + expect(bareReferenceStart(src)).toBe(expected); + }); +}); + +describe("matchBareReference", () => { + test.each([ + [`${rid}.`, rid], + [`${rid}).`, rid], + [`${rid}'s`, rid], + [`${rid}, then`, rid], + [`did:key:${nid}!`, `did:key:${nid}`], + [`${rid}/commit/master?path=src.`, `${rid}/commit/master?path=src`], + ])("%s", (src, raw) => { + expect(matchBareReference(src)?.raw).toBe(raw); + }); + + test("gives up on a run too long to be a reference", () => { + const start = performance.now(); + expect(matchBareReference(`rad:z${"'a".repeat(50_000)}`)).toBeUndefined(); + expect(performance.now() - start).toBeLessThan(50); + }); + + test("leaves an invalid reference as prose", () => { + expect(matchBareReference(`${rid}/bogus here`)).toBeUndefined(); + }); +}); + +describe("markdown", () => { + const render = (src: string) => markdownWithExtensions.parse(src) as string; + + test("links a bare reference", () => { + expect(render(`See ${rid}.`)).toBe( + `

See ${rid}.

\n`, + ); + }); + + test("leaves the tail of a word alone", () => { + expect(render(`Conrad:${repo}`)).not.toContain(" { + expect(render(`[see ${rid}](https://example.com)`)).toBe( + `

see ${rid}

\n`, + ); + }); + + test("leaves code alone", () => { + expect(render(`\`${rid}\``)).toBe(`

${rid}

\n`); + }); +}); + +describe("serialising", () => { + const issue = { type: "cob", kind: "issue", rid, oid } as const; + + test("entityUri writes the RIP 4 form", () => { + expect(entityUri(issue)).toBe(`${rid}/cob/xyz.radicle.issue/${oid}`); + expect(entityUri({ type: "node", nid })).toBe(`did:key:${nid}`); + expect(entityUri({ ...issue, kind: "patch" })).toBe( + `${rid}/cob/xyz.radicle.patch/${oid}`, + ); + expect(entityUri({ type: "commit", rid, oid })).toBe( + `${rid}/commit/${oid}`, + ); + }); + + test("referenceMarkdown links any reference", () => { + expect( + referenceMarkdown( + { + type: "uri", + uri: { repo, resource: { type: "commit", ref: "main" } }, + }, + "heartwood: README", + ), + ).toBe(`[heartwood: README](${rid}/commit/main)`); + }); + + test("entityMarkdown escapes the label", () => { + expect(entityMarkdown(issue, "Fix [x] \\ y")).toBe( + `[Fix \\[x\\] \\\\ y](${rid}/cob/xyz.radicle.issue/${oid})`, + ); + }); + + test("entityUrl uses the configured explorer and preferred seed", () => { + const config = { + publicExplorer: "https://explorer.example/nodes/$host/$rid$path", + preferredSeeds: [`${nid}@seed.example.com:8776`], + } as Config; + + expect(entityUrl(issue, config)).toBe( + `https://explorer.example/nodes/seed.example.com/${rid}/issues/${oid}`, + ); + expect(entityUrl({ type: "node", nid }, config)).toBe( + `https://explorer.example/nodes/seed.example.com/users/did:key:${nid}`, + ); + }); + + test("entityUrl keeps a path prefix in the explorer URL", () => { + const config = { + publicExplorer: "https://example.com/explorer/nodes/$host/$rid$path", + preferredSeeds: [], + } as unknown as Config; + + expect(entityUrl({ type: "repo", rid }, config)).toBe( + `https://example.com/explorer/nodes/rosa.radicle.network/${rid}`, + ); + }); +}); + +describe("referenceRoute", () => { + test.each([ + [ + `${rid}/commit/master?path=.github/README.md`, + { + resource: "repo.home", + rid, + revision: "master", + path: ".github/README.md", + }, + ], + [ + `${rid}/commit/main?path=My%20File.md`, + { resource: "repo.home", rid, revision: "main", path: "My File.md" }, + ], + [ + `${rid}/commit/refs/heads/main?path=`, + { resource: "repo.home", rid, revision: "main" }, + ], + [ + `${rid}/${nid}/commit/main?path=src`, + { resource: "repo.home", rid, peer: nid, revision: "main", path: "src" }, + ], + [`${rid}/tag/v1.0`, { resource: "repo.home", rid, revision: "v1.0" }], + [`${rid}/commit/${oid}`, { resource: "repo.commit", rid, commit: oid }], + [ + `${rid}/cob/xyz.radicle.issue/${oid}`, + { resource: "repo.issue", rid, issue: oid, status: "all" }, + ], + [ + `${rid}/cob/xyz.radicle.patch/${oid}`, + { + resource: "repo.patch", + rid, + patch: oid, + status: undefined, + reviewId: undefined, + }, + ], + [`${rid}/${nid}`, { resource: "repo.home", rid, peer: nid }], + [ + `${rid}/cob/xyz.radicle.issue`, + { resource: "repo.issues", rid, status: "all" }, + ], + [ + `${rid}/cob/xyz.radicle.patch`, + { resource: "repo.patches", rid, status: undefined }, + ], + [ + `${rid}/cob/dev.radicle.artifact/${oid}`, + { resource: "repo.release", rid, release: oid }, + ], + [`${rid}/cob/dev.radicle.artifact`, { resource: "repo.releases", rid }], + ])("%s", (href, expected) => { + expect(referenceRoute(parseReference(href)!)).toEqual(expected); + }); + + test.each([`${rid}/tree/${oid}`, `did:key:${nid}`])( + "has no page for %s", + href => { + expect(referenceRoute(parseReference(href)!)).toBeUndefined(); + }, + ); +}); + +describe("describeLink", () => { + test.each([ + [`${rid}/commit/main?path=src/lib.rs`, "heartwood: src/lib.rs", "document"], + [ + `${rid}/commit/main?path=src/lib.rs#L10`, + "heartwood: src/lib.rs#L10", + "document", + ], + [`${rid}/commit/main?path=`, "heartwood: main", "folder"], + [`${rid}/commit/${oid}?path=`, "heartwood: 5e46830", "folder"], + [`${rid}/commit/refs/heads/main`, "heartwood: main", "branch"], + [`${rid}/tag/v1.0`, "heartwood: v1.0", "label"], + [`${rid}/cob/xyz.radicle.issue`, "heartwood: issues", "issue"], + [`${rid}/cob/xyz.radicle.patch`, "heartwood: patches", "patch"], + [`${rid}/cob/dev.radicle.artifact`, "heartwood: releases", "parcel"], + [ + `${rid}/cob/dev.radicle.artifact/${oid}`, + "heartwood: release 5e46830", + "parcel", + ], + [`${rid}/${nid}`, "heartwood: z6MknS…tBU8Vi", "repository"], + ])("%s", (href, label, icon) => { + expect(describeLink(parseReference(href)!, "heartwood")).toMatchObject({ + label, + icon, + }); + }); + + test("names a remote by its alias", () => { + expect( + describeLink(parseReference(`${rid}/${nid}`)!, "heartwood", "cloudhead"), + ).toMatchObject({ label: "heartwood: cloudhead" }); + }); + + test.each([ + rid, + `${rid}/commit/${oid}`, + `${rid}/cob/xyz.radicle.issue/${oid}`, + `${rid}/tree/${oid}`, + `${rid}/${nid}/cob/xyz.radicle.issue/${oid}`, + `did:key:${nid}`, + ])("is not a link for %s", href => { + expect(describeLink(parseReference(href)!, "heartwood")).toBeUndefined(); + }); +}); + +describe("hostile input", () => { + test("renders a run of references in linear time", () => { + const start = performance.now(); + void markdownWithExtensions.parse("rad:".repeat(25_000)); + expect(performance.now() - start).toBeLessThan(500); + }); + + test("links at most a fixed number of references per document", () => { + const start = performance.now(); + const html = markdownWithExtensions.parse( + `${rid} `.repeat(5_000), + ) as string; + expect(performance.now() - start).toBeLessThan(1_000); + expect(html.match(/ { + const reference = parseReference(href)!; + expect(referenceRoute(reference)).toBeUndefined(); + expect(describeLink(reference, "heartwood")).toBeUndefined(); + }); +}); diff --git a/desktop/tests/unit/menuNavigation.test.ts b/desktop/tests/unit/menuNavigation.test.ts new file mode 100644 index 000000000..106450d2d --- /dev/null +++ b/desktop/tests/unit/menuNavigation.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "vitest"; + +import { menuFocusIndex } from "@app/lib/menuNavigation"; + +test.each([ + ["ArrowDown", -1, 0], + ["ArrowDown", 0, 1], + ["ArrowDown", 2, 0], + ["ArrowUp", -1, 2], + ["ArrowUp", 0, 2], + ["ArrowUp", 2, 1], + ["Home", 1, 0], + ["End", 0, 2], + ["Enter", 1, undefined], +])("%j from %j of three focuses %j", (key, current, expected) => { + expect(menuFocusIndex(key, current, 3)).toBe(expected); +}); diff --git a/desktop/tests/unit/mermaid.test.ts b/desktop/tests/unit/mermaid.test.ts new file mode 100644 index 000000000..48067f200 --- /dev/null +++ b/desktop/tests/unit/mermaid.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test, vi } from "vitest"; + +import { renderMermaidBlocks } from "@app/lib/mermaid"; + +function documentWith(html: string): HTMLElement { + const container = document.createElement("div"); + container.innerHTML = html; + return container; +} + +const diagram = + '
graph TD; A-->B
'; + +function fakeRender() { + return vi.fn( + async (source: string, theme: string) => + `${source}`, + ); +} + +describe("renderMermaidBlocks", () => { + test("replaces a mermaid block with its diagram", async () => { + const render = fakeRender(); + const container = documentWith( + `${diagram}
fn main() {}
`, + ); + + await renderMermaidBlocks(container, "dark", { render }); + + expect(render).toHaveBeenCalledExactlyOnceWith("graph TD; A-->B", "dark"); + expect(container.querySelector(".mermaid-diagram svg")).not.toBeNull(); + expect(container.querySelector("code.language-mermaid")).toBeNull(); + expect(container.querySelector("code.language-rust")).not.toBeNull(); + }); + + test("replaces the copy button wrapper along with the code", async () => { + const container = documentWith( + `
${diagram}
`, + ); + + await renderMermaidBlocks(container, "dark", { render: fakeRender() }); + + expect(container.querySelector(".pre-wrapper")).toBeNull(); + expect(container.querySelector(".mermaid-diagram")).not.toBeNull(); + }); + + test("redraws diagrams only when the theme changes", async () => { + const render = fakeRender(); + const container = documentWith(diagram); + + await renderMermaidBlocks(container, "dark", { render }); + await renderMermaidBlocks(container, "dark", { render }); + await renderMermaidBlocks(container, "light", { render }); + + expect(render.mock.calls.map(([, theme]) => theme)).toEqual([ + "dark", + "light", + ]); + expect(render.mock.calls[1][0]).toBe("graph TD; A-->B"); + expect(container.querySelectorAll(".mermaid-diagram")).toHaveLength(1); + expect(container.querySelector("svg")?.getAttribute("data-theme")).toBe( + "light", + ); + }); + + test("keeps a diagram that fails to draw as code under one warning", async () => { + const render = vi.fn().mockRejectedValue(new Error("Parse error")); + const decorateWarning = vi.fn(); + const container = documentWith(diagram); + vi.spyOn(console, "warn").mockReturnValue(undefined); + + await renderMermaidBlocks(container, "dark", { render, decorateWarning }); + await renderMermaidBlocks(container, "light", { render, decorateWarning }); + + expect(container.querySelector("code.language-mermaid")).not.toBeNull(); + expect(container.querySelectorAll(".mermaid-error")).toHaveLength(1); + expect(decorateWarning).toHaveBeenCalledOnce(); + }); + + test("keeps the last diagram when redrawing it fails", async () => { + const render = vi + .fn() + .mockResolvedValueOnce("") + .mockRejectedValue(new Error("Not able to load mermaid")); + const container = documentWith(diagram); + vi.spyOn(console, "warn").mockReturnValue(undefined); + + await renderMermaidBlocks(container, "dark", { render }); + await renderMermaidBlocks(container, "light", { render }); + + expect(container.querySelector(".mermaid-diagram svg")).not.toBeNull(); + expect(container.querySelector(".mermaid-error")).toBeNull(); + }); + + test("a theme asked for later wins over one still drawing", async () => { + let finishDark!: (svg: string) => void; + const render = vi + .fn() + .mockReturnValueOnce(new Promise(resolve => (finishDark = resolve))) + .mockResolvedValueOnce(''); + const container = documentWith(diagram); + + const dark = renderMermaidBlocks(container, "dark", { render }); + await renderMermaidBlocks(container, "light", { render }); + finishDark(''); + await dark; + + expect(container.querySelector("svg")?.getAttribute("data-theme")).toBe( + "light", + ); + }); +}); + +describe("renderDiagram", () => { + test("tries loading mermaid again after a failed load", async () => { + vi.resetModules(); + vi.doMock("mermaid", () => { + throw new Error("Failed to fetch dynamically imported module"); + }); + const { renderDiagram } = await import("@app/lib/mermaid"); + + await expect(renderDiagram("graph TD; A-->B", "dark")).rejects.toThrow(); + + vi.doMock("mermaid", () => ({ + default: { + initialize: vi.fn(), + render: vi.fn(async () => ({ svg: "" })), + }, + })); + + await expect(renderDiagram("graph TD; A-->B", "dark")).resolves.toBe( + "", + ); + vi.doUnmock("mermaid"); + }); +}); diff --git a/desktop/tests/unit/modal.test.ts b/desktop/tests/unit/modal.test.ts new file mode 100644 index 000000000..c89f5fcf6 --- /dev/null +++ b/desktop/tests/unit/modal.test.ts @@ -0,0 +1,111 @@ +import type { Component } from "svelte"; + +import { get } from "svelte/store"; +import { afterEach, expect, test, vi } from "vitest"; + +import * as modal from "@app/lib/modal"; + +const First = vi.fn() as unknown as Component; +const Second = vi.fn() as unknown as Component; + +afterEach(() => { + modal.forceHide(); + document.body.replaceChildren(); +}); + +test("show opens the modal with the scrim close enabled by default", () => { + modal.show({ component: First, props: { a: 1 } }); + + expect(get(modal.modalStore)).toEqual({ + component: First, + props: { a: 1 }, + disableScrimClose: false, + }); + + modal.show({ component: Second, props: {}, disableScrimClose: true }); + + expect(get(modal.modalStore)).toMatchObject({ + component: Second, + disableScrimClose: true, + }); +}); + +test("show blurs the focused element", () => { + const input = document.createElement("input"); + document.body.append(input); + input.focus(); + expect(document.activeElement).toBe(input); + + modal.show({ component: First, props: {} }); + + expect(document.activeElement).not.toBe(input); +}); + +test("hide closes the modal and runs its hide callback once", () => { + const hideCallback = vi.fn(); + modal.show({ component: First, props: {}, hideCallback }); + + modal.hide(); + modal.hide(); + + expect(get(modal.modalStore)).toBeUndefined(); + expect(hideCallback).toHaveBeenCalledTimes(1); +}); + +test("disableHide keeps the modal open until enableHide", () => { + const hideCallback = vi.fn(); + modal.show({ component: First, props: {}, hideCallback }); + modal.disableHide(); + + modal.hide(); + expect(get(modal.modalStore)).toBeDefined(); + expect(hideCallback).not.toHaveBeenCalled(); + + modal.enableHide(); + modal.hide(); + expect(get(modal.modalStore)).toBeUndefined(); + expect(hideCallback).toHaveBeenCalledTimes(1); +}); + +test("forceHide closes the modal even when hiding is disabled", () => { + const hideCallback = vi.fn(); + modal.show({ component: First, props: {}, hideCallback }); + modal.disableHide(); + + modal.forceHide(); + modal.forceHide(); + + expect(get(modal.modalStore)).toBeUndefined(); + expect(hideCallback).toHaveBeenCalledTimes(1); +}); + +test.each([ + ["enableHide", modal.enableHide], + ["disableHide", modal.disableHide], +])("%s without a modal leaves it closed", (_, fn) => { + fn(); + + expect(get(modal.modalStore)).toBeUndefined(); +}); + +test("toggle opens, switches and closes the modal", () => { + modal.toggle({ component: First, props: {} }); + expect(get(modal.modalStore)?.component).toBe(First); + + modal.toggle({ component: Second, props: {} }); + expect(get(modal.modalStore)?.component).toBe(Second); + + modal.toggle({ component: Second, props: {} }); + expect(get(modal.modalStore)).toBeUndefined(); +}); + +test("toggle does nothing while hiding is disabled", () => { + modal.show({ component: First, props: {} }); + modal.disableHide(); + + modal.toggle({ component: Second, props: {} }); + expect(get(modal.modalStore)?.component).toBe(First); + + modal.toggle({ component: First, props: {} }); + expect(get(modal.modalStore)?.component).toBe(First); +}); diff --git a/desktop/tests/unit/mutexExecutor.test.ts b/desktop/tests/unit/mutexExecutor.test.ts new file mode 100644 index 000000000..b5f159e28 --- /dev/null +++ b/desktop/tests/unit/mutexExecutor.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test, vi } from "vitest"; + +import * as mutexExecutor from "@app/lib/mutexExecutor"; +import { sleep } from "@app/lib/sleep"; + +describe("executor", () => { + test("cancels running task", async () => { + const e = mutexExecutor.create(); + + const first = e.run(async () => { + await sleep(10); + return "first"; + }); + const second = e.run(async () => { + return "second"; + }); + + expect(await first).toBe(undefined); + expect(await second).toBe("second"); + + const third = e.run(async () => { + await sleep(10); + return "third"; + }); + const fourth = e.run(async () => { + return "fourth"; + }); + + expect(await third).toBe(undefined); + expect(await fourth).toBe("fourth"); + }); + + test("cancels multiple tasks", async () => { + const e = mutexExecutor.create(); + + const canceled1 = e.run(async () => { + await sleep(10); + return true; + }); + const canceled2 = e.run(async () => { + await sleep(10); + return true; + }); + const canceled3 = e.run(async () => { + await sleep(10); + return true; + }); + const last = e.run(async () => { + return true; + }); + + expect(await canceled1).toBe(undefined); + expect(await canceled2).toBe(undefined); + expect(await canceled3).toBe(undefined); + expect(await last).toBe(true); + }); + + test("triggers abort signal event", async () => { + const e = mutexExecutor.create(); + const abortListener = vi.fn(); + + void e.run(async abort => { + abort.addEventListener("abort", abortListener); + await sleep(10); + return "first"; + }); + expect(abortListener).not.toHaveBeenCalled(); + // eslint-disable-next-line @typescript-eslint/no-empty-function + void e.run(async () => {}); + expect(abortListener).toHaveBeenCalled(); + }); + + test("don’t throw error on aborted task", async () => { + const e = mutexExecutor.create(); + + const first = e.run(async () => { + await sleep(10); + throw new Error(); + }); + const second = e.run(async () => { + return "second"; + }); + + expect(await first).toBe(undefined); + expect(await second).toBe("second"); + }); +}); + +describe("worker", () => { + test("sequential work", async () => { + const w = mutexExecutor.createWorker(async (value: number) => { + await sleep(10); + return value; + }); + + const outputs: number[] = []; + w.output.onValue(value => outputs.push(value)); + + await w.submit(1); + await w.submit(2); + await w.submit(3); + + expect(outputs).toEqual([1, 2, 3]); + }); + + test("overlapping work cancels", async () => { + const w = mutexExecutor.createWorker(async (value: number) => { + await sleep(10); + return value; + }); + + const nextOutput = w.output.firstToPromise(); + + void w.submit(1); + void w.submit(2); + void w.submit(3); + + expect(await nextOutput).toEqual(3); + }); +}); diff --git a/desktop/tests/unit/notifications.test.ts b/desktop/tests/unit/notifications.test.ts new file mode 100644 index 000000000..03e08ab91 --- /dev/null +++ b/desktop/tests/unit/notifications.test.ts @@ -0,0 +1,305 @@ +import type { NotificationItem } from "@bindings/cob/inbox/NotificationItem"; +import type { Action as IssueAction } from "@bindings/cob/issue/Action"; +import type { Action as PatchAction } from "@bindings/cob/patch/Action"; + +import { describe, expect, test } from "vitest"; + +import type { Action as NotificationAction } from "@app/lib/notification"; +import { + compressActions, + createSummary, + notificationActions, + notificationIcon, + notificationRoute, +} from "@app/lib/notification"; +import { formatOid } from "@app/lib/utils"; + +const oid = "e8f95f5082a8e99c290ab3908a926e1de5c97d6c"; +const revision = "eb3d92ddbd4394bbd6896a99152afb1f8647d6ca"; +const actionOid = "22d3ec4b78314f83a43add9b72382c6fbc44c8b6"; +const timestamp = 1737622257; +const author = { + did: "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx", + alias: "rudolfs", +}; + +const createAction = ( + action: IssueAction | PatchAction, + oid = actionOid, +): NotificationAction => ({ + oid, + timestamp, + author, + ...action, +}); + +describe("Action summaries", () => { + test.each([ + { + summary: "Review without verdict", + input: [createAction({ type: "review", revision })], + output: `left a review with a comment on revision ${formatOid(revision)}`, + }, + { + summary: "Review with accepted verdict", + input: [createAction({ type: "review", verdict: "accept", revision })], + output: `accepted revision ${formatOid(revision)} with a review`, + }, + { + summary: "Review with rejected verdict", + input: [createAction({ type: "review", verdict: "reject", revision })], + output: `rejected revision ${formatOid(revision)} with a review`, + }, + { + summary: "Add multiple labels", + input: [ + createAction({ + type: "label", + labels: ["bug", "ux"], + }), + createAction({ + type: "label", + labels: ["design"], + }), + ], + output: "added labels", + }, + { + summary: "Leave multiple review comments", + input: [ + createAction({ + type: "review.comment", + body: "A review comment", + review: oid, + }), + createAction({ + type: "review.comment", + body: "Next review comment", + review: oid, + }), + ], + output: "left 2 review comments", + }, + ])( + "$summary => $output", + ({ input, output }: { input: NotificationAction[]; output: string }) => { + expect(compressActions(input, "patch", oid)[0].summary).toEqual(output); + }, + ); + + test.each([ + { + summary: "Create new revision", + input: [ + createAction({ + type: "revision", + oid: revision, + description: "", + base: oid, + }), + ], + output: `created revision ${formatOid(revision)}`, + }, + { + summary: "Merge revision", + input: [ + createAction({ + type: "merge", + revision, + commit: actionOid, + }), + ], + output: `merged revision ${formatOid(revision)}`, + }, + ])( + "$summary => $output", + ({ input, output }: { input: NotificationAction[]; output: string }) => { + expect(compressActions(input, "patch", oid)[0].summary).toEqual(output); + }, + ); + + test.each([ + { + summary: "Close patch", + input: [ + createAction({ + type: "lifecycle", + state: { status: "closed", reason: "other" }, + }), + ], + output: "closed patch", + }, + { + summary: "Close patch as solved", + input: [ + createAction({ + type: "lifecycle", + state: { status: "closed", reason: "solved" }, + }), + ], + output: "closed patch as solved", + }, + { + summary: "Archive patch", + input: [ + createAction({ type: "lifecycle", state: { status: "archived" } }), + ], + output: "archived patch", + }, + { + summary: "Reopen patch", + input: [createAction({ type: "lifecycle", state: { status: "open" } })], + output: "reopened patch", + }, + { + summary: "Change patch to draft", + input: [createAction({ type: "lifecycle", state: { status: "draft" } })], + output: "changed to draft", + }, + { + summary: "More than one lifecycle change", + input: [ + createAction({ type: "lifecycle", state: { status: "draft" } }), + createAction({ type: "lifecycle", state: { status: "open" } }), + ], + output: "changed to open and 2 more changes", + }, + ])( + "$summary => $output", + ({ input, output }: { input: NotificationAction[]; output: string }) => { + expect(createSummary(input, "patch", oid, input.length)).toEqual(output); + }, + ); + + test.each([ + { + summary: "Open patch with an edit and a comment action", + type: "patch" as const, + input: [ + createAction({ type: "edit", title: "Lorem ipsum" }, oid), + createAction({ type: "comment", body: "A patch title" }, oid), + ], + output: `opened patch ${formatOid(oid)}`, + }, + { + summary: "Open issue where the action has the same oid than the cob", + type: "issue" as const, + input: [createAction({ type: "edit", title: "Lorem ipsum" }, oid)], + output: `opened issue ${formatOid(oid)}`, + }, + { + summary: "Leave two comments in one operation", + type: "issue" as const, + input: [ + createAction({ type: "comment", body: "Lorem ipsum" }), + createAction({ type: "comment", body: "A patch title" }), + ], + output: `left 2 comments`, + }, + ])( + "$summary => $output", + ({ + type, + input, + output, + }: { + type: "patch" | "issue"; + input: NotificationAction[]; + output: string; + }) => { + expect(createSummary(input, type, oid, input.length)).toEqual(output); + }, + ); +}); + +describe("notificationActions", () => { + const comment = (body: string, at = timestamp) => ({ + ...createAction({ type: "review.comment", body, review: oid }), + timestamp: at, + }); + + test("keeps an action once when two notifications include it", () => { + const shared = comment("A review comment"); + + expect( + notificationActions([ + { actions: [shared] }, + { actions: [{ ...shared }] }, + ]), + ).toEqual([shared]); + }); + + test("keeps same-type actions from one operation", () => { + const actions = notificationActions([ + { actions: [comment("First"), comment("Second"), comment("Third")] }, + ]); + + expect(actions).toHaveLength(3); + expect(compressActions(actions, "patch", oid)[0].summary).toBe( + "left 3 review comments", + ); + }); + + test("orders actions newest first", () => { + expect( + notificationActions([ + { actions: [comment("old", 1)] }, + { actions: [comment("new", 3), comment("middle", 2)] }, + ]).map(a => a.timestamp), + ).toEqual([3, 2, 1]); + }); +}); + +function item( + type: "issue" | "patch", + status: NotificationItem["status"], +): NotificationItem { + return { + type, + rowId: "1", + id: oid, + update: { type: "created", name: "refs/cobs/x", oid }, + title: "Title", + timestamp, + status, + actions: [], + repoId: "rad:z", + } as NotificationItem; +} + +describe("notification teaser", () => { + test.each([ + [item("issue", { status: "open" }), "issue"], + [item("issue", { status: "closed", reason: "solved" }), "issue-closed"], + [item("patch", { status: "open" }), "patch"], + [item("patch", { status: "draft" }), "patch-draft"], + [ + item("patch", { status: "merged", revision, commit: oid }), + "patch-merged", + ], + [undefined, "patch"], + ])("icon of %# is %j", (notification, expected) => { + expect(notificationIcon(notification)).toBe(expected); + }); + + test("routes to the patch or to the issue in every status", () => { + expect( + notificationRoute("rad:z", item("patch", { status: "open" })), + ).toEqual({ + resource: "repo.patch", + rid: "rad:z", + patch: oid, + status: undefined, + reviewId: undefined, + }); + expect( + notificationRoute("rad:z", item("issue", { status: "open" })), + ).toEqual({ + resource: "repo.issue", + rid: "rad:z", + issue: oid, + status: "all", + }); + expect(notificationRoute("rad:z", undefined)).toBeUndefined(); + }); +}); diff --git a/desktop/tests/unit/paginatedList.svelte.test.ts b/desktop/tests/unit/paginatedList.svelte.test.ts new file mode 100644 index 000000000..27d3360fc --- /dev/null +++ b/desktop/tests/unit/paginatedList.svelte.test.ts @@ -0,0 +1,329 @@ +import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery"; + +import { flushSync } from "svelte"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { readListState, saveListState } from "@app/lib/listState"; +import { createPaginatedList } from "@app/lib/paginatedList.svelte"; + +const { isHistoryNavigation } = vi.hoisted(() => ({ + isHistoryNavigation: vi.fn(() => false), +})); +vi.mock("@app/lib/router", () => ({ isHistoryNavigation })); + +function page(content: string[], more = true): PaginatedQuery { + return { cursor: 0, more, content }; +} + +function deferred() { + let resolve!: (value: PaginatedQuery) => void; + const promise = new Promise>(res => { + resolve = res; + }); + return { promise, resolve }; +} + +const fetchPage = + vi.fn< + ( + skip: number, + take: number | undefined, + ) => Promise> + >(); +let keyCount = 0; +let cleanup: (() => void) | undefined; + +function nextKey() { + return `list-${keyCount + 1}`; +} + +function setup(first = page(["a", "b"]), skipPersist?: () => boolean) { + let key = $state(`list-${++keyCount}`); + let current = $state(first); + let list!: ReturnType>; + cleanup = $effect.root(() => { + list = createPaginatedList({ + key: () => key, + page: () => current, + fetchPage, + pageSize: 2, + id: item => item, + skipPersist, + }); + }); + flushSync(); + return { + list, + key: () => key, + change(newKey: string, next: PaginatedQuery) { + key = newKey; + current = next; + flushSync(); + }, + }; +} + +beforeEach(() => { + fetchPage.mockReset(); + vi.spyOn(console, "error").mockReturnValue(undefined); +}); + +afterEach(() => { + cleanup?.(); + isHistoryNavigation.mockReturnValue(false); +}); + +test("starts from the loader's page", () => { + const { list } = setup(page(["a", "b"], false)); + + expect(list.items).toEqual(["a", "b"]); + expect(list.more).toBe(false); + expect(list.loadingMore).toBe(false); + expect(list.initialScrollOffset).toBeUndefined(); + expect(fetchPage).not.toHaveBeenCalled(); +}); + +test("loadMore appends the next page, skipping rows already shown", async () => { + const { list } = setup(); + fetchPage.mockResolvedValueOnce(page(["b", "c"])); + fetchPage.mockResolvedValueOnce(page(["d"], false)); + + await list.loadMore(); + expect(fetchPage).toHaveBeenLastCalledWith(2, 2); + expect(list.items).toEqual(["a", "b", "c"]); + expect(list.more).toBe(true); + + await list.loadMore(); + expect(fetchPage).toHaveBeenLastCalledWith(4, 2); + expect(list.items).toEqual(["a", "b", "c", "d"]); + expect(list.more).toBe(false); + + await list.loadMore(); + expect(fetchPage).toHaveBeenCalledTimes(2); +}); + +test("an empty page ends the list", async () => { + const { list } = setup(); + fetchPage.mockResolvedValueOnce(page([], true)); + + await list.loadMore(); + + expect(list.more).toBe(false); +}); + +test("loadMore(true) replaces the list with every row", async () => { + const { list } = setup(); + fetchPage.mockResolvedValueOnce(page(["x", "y", "z"], false)); + fetchPage.mockResolvedValueOnce(page(["w"], false)); + + await list.loadMore(true); + + expect(fetchPage).toHaveBeenCalledWith(0, undefined); + expect(list.items).toEqual(["x", "y", "z"]); + expect(list.more).toBe(false); +}); + +test("loadMore(true) moves the cursor to the rows it fetched", async () => { + const { list } = setup(); + fetchPage.mockResolvedValueOnce(page(["x", "y", "z"])); + fetchPage.mockResolvedValueOnce(page(["w"])); + + await list.loadMore(true); + await list.loadMore(); + + expect(fetchPage).toHaveBeenLastCalledWith(3, 2); +}); + +test("loadingMore is set while a page loads", async () => { + const { list } = setup(); + const pending = deferred(); + fetchPage.mockReturnValueOnce(pending.promise); + + const loading = list.loadMore(); + expect(list.loadingMore).toBe(true); + + pending.resolve(page(["c"])); + await loading; + expect(list.loadingMore).toBe(false); +}); + +test("a failed load keeps the list and allows another attempt", async () => { + const { list } = setup(); + fetchPage.mockRejectedValueOnce(new Error("offline")); + + await list.loadMore(); + + expect(list.items).toEqual(["a", "b"]); + expect(list.more).toBe(true); + expect(list.loadingMore).toBe(false); + expect(console.error).toHaveBeenCalled(); +}); + +test("a superseded load leaves the list to the newer one", async () => { + const { list } = setup(); + const older = deferred(); + const newer = deferred(); + fetchPage.mockReturnValueOnce(older.promise); + fetchPage.mockReturnValueOnce(newer.promise); + + const first = list.loadMore(); + const second = list.loadMore(true); + older.resolve(page(["c"])); + await first; + expect(list.items).toEqual(["a", "b"]); + expect(list.loadingMore).toBe(true); + + newer.resolve(page(["x"], false)); + await second; + expect(list.items).toEqual(["x"]); + expect(list.loadingMore).toBe(false); +}); + +test("a key change resets the list and drops an in-flight load", async () => { + const { list, change } = setup(); + const pending = deferred(); + fetchPage.mockReturnValueOnce(pending.promise); + fetchPage.mockResolvedValueOnce(page(["r"])); + + const loading = list.loadMore(); + change("other", page(["p", "q", "r"], true)); + expect(list.items).toEqual(["p", "q", "r"]); + expect(list.loadingMore).toBe(false); + + pending.resolve(page(["c"])); + await loading; + expect(list.items).toEqual(["p", "q", "r"]); + + await list.loadMore(); + expect(fetchPage).toHaveBeenLastCalledWith(3, 2); +}); + +test("a new loader page under the same key is ignored", () => { + const { list, change, key } = setup(); + + change(key(), page(["z"], false)); + expect(list.items).toEqual(["a", "b"]); + expect(list.more).toBe(true); + + change("other", page(["p"], false)); + change("other", page(["z"])); + expect(list.items).toEqual(["p"]); + expect(list.more).toBe(false); +}); + +test("persistScroll saves the list for the current key", () => { + const { list, key } = setup(page(["a", "b"], false)); + + list.persistScroll({ scrollOffset: 40, cache: undefined as never }); + + expect(readListState(key())).toEqual({ + items: ["a", "b"], + more: false, + scrollOffset: 40, + cache: undefined, + }); +}); + +test("persistScroll saves nothing while skipped", () => { + const { list, key } = setup(undefined, () => true); + + list.persistScroll({ scrollOffset: 40, cache: undefined as never }); + + expect(readListState(key())).toBeUndefined(); +}); + +test("revalidate refetches a page-sized window by default", async () => { + const { list } = setup(); + fetchPage.mockResolvedValueOnce(page(["n", "a"], false)); + + await list.revalidate(); + + expect(fetchPage).toHaveBeenCalledWith(0, 2); + expect(list.items).toEqual(["n", "a"]); + expect(list.more).toBe(false); +}); + +test("a failed revalidate keeps the list", async () => { + const { list } = setup(); + fetchPage.mockRejectedValueOnce(new Error("offline")); + + await list.revalidate(); + + expect(list.items).toEqual(["a", "b"]); + expect(list.loadingMore).toBe(false); + expect(console.error).toHaveBeenCalled(); +}); + +test("a superseded revalidate leaves loadingMore to the newer load", async () => { + const { list } = setup(); + const older = deferred(); + const newer = deferred(); + fetchPage.mockReturnValueOnce(older.promise); + fetchPage.mockReturnValueOnce(newer.promise); + + const first = list.revalidate(); + void list.loadMore(true); + older.resolve(page(["c"])); + await first; + + expect(list.items).toEqual(["a", "b"]); + expect(list.loadingMore).toBe(true); +}); + +function snapshot(key: string, items: string[]) { + saveListState(key, { + items, + more: true, + scrollOffset: 120, + cache: "cache" as never, + }); +} + +test("history navigation restores a deep list and refetches it", async () => { + isHistoryNavigation.mockReturnValue(true); + snapshot(nextKey(), ["s1", "s2", "s3"]); + const pending = deferred(); + fetchPage.mockReturnValueOnce(pending.promise); + + const { list } = setup(page(["a", "b"], false)); + expect(fetchPage).toHaveBeenCalledWith(0, 3); + expect(list.items).toEqual(["s1", "s2", "s3"]); + expect(list.more).toBe(true); + expect(list.loadingMore).toBe(true); + expect(list.initialScrollOffset).toBe(120); + expect(list.initialCache).toBe("cache"); + + pending.resolve(page(["t1", "t2", "t3"])); + await vi.waitFor(() => expect(list.loadingMore).toBe(false)); + expect(list.items).toEqual(["t1", "t2", "t3"]); + + fetchPage.mockResolvedValueOnce(page(["t4"])); + await list.loadMore(); + expect(fetchPage).toHaveBeenLastCalledWith(3, 2); + + list.consumeRestoredScroll(); + expect(list.initialScrollOffset).toBeUndefined(); + expect(list.initialCache).toBeUndefined(); +}); + +test("history navigation replaces a shallow list with the loader's page", () => { + isHistoryNavigation.mockReturnValue(true); + snapshot(nextKey(), ["s1", "s2"]); + + const { list } = setup(page(["a", "b"], false)); + + expect(list.items).toEqual(["a", "b"]); + expect(list.more).toBe(false); + expect(list.initialScrollOffset).toBe(120); + expect(fetchPage).not.toHaveBeenCalled(); +}); + +test("a fresh navigation ignores the snapshot", () => { + snapshot(nextKey(), ["s1", "s2", "s3"]); + + const { list } = setup(); + + expect(list.items).toEqual(["a", "b"]); + expect(list.initialScrollOffset).toBeUndefined(); + expect(fetchPage).not.toHaveBeenCalled(); +}); diff --git a/desktop/tests/unit/patchContributions.test.ts b/desktop/tests/unit/patchContributions.test.ts new file mode 100644 index 000000000..55f2968ae --- /dev/null +++ b/desktop/tests/unit/patchContributions.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "vitest"; + +import { patchContributions } from "@app/lib/patchContributions"; + +import { author, comment, review, revision } from "./support/cobs"; + +const alice = author("alice"); +const bob = author("bob"); + +const revisions = [ + revision({ + id: "r1", + author: alice, + discussion: [ + comment({ author: alice }), + comment({ author: bob }), + comment({ author: bob }), + ], + reviews: [ + review({ + author: bob, + comments: [comment({ author: bob }), comment({ author: alice })], + }), + ], + }), + revision({ + id: "r2", + author: bob, + reviews: [review({ author: alice }), review({ author: bob })], + }), +]; + +test("counts one person's revisions, comments and reviews", () => { + expect(patchContributions(revisions, bob.did)).toEqual({ + revisionCount: 1, + commentCount: 3, + reviewCount: 2, + }); +}); + +test("counts comments in revision discussions and in reviews", () => { + expect(patchContributions(revisions, alice.did)).toEqual({ + revisionCount: 1, + commentCount: 2, + reviewCount: 1, + }); +}); + +test("counts nothing for someone who took no part", () => { + expect(patchContributions(revisions, author("carol").did)).toEqual({ + revisionCount: 0, + commentCount: 0, + reviewCount: 0, + }); +}); diff --git a/desktop/tests/unit/patchCounts.test.ts b/desktop/tests/unit/patchCounts.test.ts new file mode 100644 index 000000000..77c961308 --- /dev/null +++ b/desktop/tests/unit/patchCounts.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, expect, test } from "vitest"; + +import { + patchCountMismatch, + patchCounts, + resetPatchCounts, + updatePatchCounts, +} from "@app/lib/patchCounts.svelte"; + +const sidebar = { open: 3, draft: 2, archived: 1, merged: 4 }; + +beforeEach(() => { + resetPatchCounts(); +}); + +test.each([ + ["open", 3], + ["draft", 2], + ["archived", 1], + ["merged", 4], +] as const)( + "matches when the %s list has the sidebar's count", + (status, count) => { + updatePatchCounts(count, sidebar, status); + + expect(patchCounts[status]).toEqual({ sidebar: count, total: count }); + expect(patchCountMismatch(status)).toBe(false); + }, +); + +test("compares the unfiltered list with the sum of all statuses", () => { + updatePatchCounts(10, sidebar); + + expect(patchCounts.all).toEqual({ sidebar: 10, total: 10 }); + expect(patchCountMismatch()).toBe(false); + + updatePatchCounts(9, sidebar); + + expect(patchCountMismatch()).toBe(true); +}); + +test("flags a list whose length differs from the sidebar", () => { + updatePatchCounts(4, sidebar, "open"); + + expect(patchCountMismatch("open")).toBe(true); + expect(patchCountMismatch("draft")).toBe(false); + expect(patchCountMismatch()).toBe(false); +}); + +test("reset clears every status", () => { + updatePatchCounts(4, sidebar, "open"); + updatePatchCounts(9, sidebar); + resetPatchCounts(); + + expect(Object.values(patchCounts)).toEqual( + Array(5).fill({ sidebar: null, total: null }), + ); + expect(patchCountMismatch("open")).toBe(false); + expect(patchCountMismatch()).toBe(false); +}); diff --git a/desktop/tests/unit/patchSlice.test.ts b/desktop/tests/unit/patchSlice.test.ts new file mode 100644 index 000000000..513ce6d68 --- /dev/null +++ b/desktop/tests/unit/patchSlice.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "vitest"; + +import { sliceForRange } from "@app/lib/patchSlice"; + +const preamble = [ + "diff --git a/f.txt b/f.txt", + "index 1111111..2222222 100644", + "--- a/f.txt", + "+++ b/f.txt", +]; + +// Old side: l1..l10 on lines 1-10. New side: l1-l4, L5, L5b, l6-l10 on lines +// 1-11. +const hunk = [ + "@@ -1,10 +1,11 @@ fn main", + " l1", + " l2", + " l3", + " l4", + "-l5", + "+L5", + "+L5b", + " l6", + " l7", + " l8", + " l9", + " l10", +]; + +function patch(...lines: string[]): string { + return [...preamble, ...lines, ""].join("\n"); +} + +describe("sliceForRange", () => { + test("cuts a new-side range down to its context and renumbers the hunk", () => { + expect(sliceForRange(patch(...hunk), "new", 5, 6, 1)).toBe( + patch("@@ -5,1 +5,2 @@", "-l5", "+L5", "+L5b"), + ); + }); + + test("keeps three lines of context by default", () => { + expect(sliceForRange(patch(...hunk), "new", 5, 6)).toBe( + patch( + "@@ -3,5 +3,6 @@", + " l3", + " l4", + "-l5", + "+L5", + "+L5b", + " l6", + " l7", + ), + ); + }); + + test("cuts an old-side range", () => { + expect(sliceForRange(patch(...hunk), "old", 5, 6, 1)).toBe( + patch("@@ -4,2 +4,2 @@", " l4", "-l5", "+L5"), + ); + }); + + test("covers a multi-line range with context on both ends", () => { + expect(sliceForRange(patch(...hunk), "new", 7, 9, 1)).toBe( + patch("@@ -6,3 +6,4 @@", "+L5b", " l6", " l7", " l8"), + ); + }); + + test("keeps the hunk verbatim when the window covers all of it", () => { + expect(sliceForRange(patch(...hunk), "new", 5, 6, 20)).toBe(patch(...hunk)); + }); + + test("writes 0 for a side the slice has no lines on", () => { + expect(sliceForRange(patch(...hunk), "new", 5, 7, 0)).toBe( + patch("@@ -0,0 +5,2 @@", "+L5", "+L5b"), + ); + }); + + test("returns undefined when no hunk covers the range", () => { + expect(sliceForRange(patch(...hunk), "new", 50, 51)).toBeUndefined(); + expect(sliceForRange(patch(...hunk), "old", 11, 12)).toBeUndefined(); + }); + + test("uses the hunk that covers the range and keeps the preamble", () => { + const twoHunks = patch( + "@@ -1,2 +1,2 @@", + "-a", + "+A", + " b", + "@@ -20,3 +20,3 @@ fn second", + " x", + "-y", + "+Y", + " z", + ); + + expect(sliceForRange(twoHunks, "new", 21, 22, 0)).toBe( + patch("@@ -0,0 +21,1 @@", "+Y"), + ); + expect(sliceForRange(twoHunks, "new", 21, 22, 5)).toBe( + patch("@@ -20,3 +20,3 @@ fn second", " x", "-y", "+Y", " z"), + ); + }); + + test("reads a hunk header without counts", () => { + expect(sliceForRange(patch("@@ -3 +3 @@", "-c", "+C"), "new", 3, 4)).toBe( + patch("@@ -3 +3 @@", "-c", "+C"), + ); + }); + + test("a no-newline marker doesn't shift the line numbers", () => { + const noNewline = patch( + "@@ -1,2 +1,2 @@", + "-a", + "\\ No newline at end of file", + "+A", + " b", + ); + + expect(sliceForRange(noNewline, "new", 2, 3, 0)).toBe( + patch("@@ -2,1 +2,1 @@", " b"), + ); + }); +}); diff --git a/desktop/tests/unit/patchTimeline.test.ts b/desktop/tests/unit/patchTimeline.test.ts new file mode 100644 index 000000000..321c47722 --- /dev/null +++ b/desktop/tests/unit/patchTimeline.test.ts @@ -0,0 +1,374 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Operation } from "@bindings/cob/Operation"; +import type { Action } from "@bindings/cob/patch/Action"; +import type { Commit } from "@bindings/repo/Commit"; + +import { describe, expect, test } from "vitest"; + +import type { ActivityItem } from "@app/lib/cobActivity"; +import type { PatchActivityData } from "@app/lib/patchTimeline"; +import { openingDraftOpId, patchTimeline } from "@app/lib/patchTimeline"; + +import { author, comment, location, revision, thread } from "./support/cobs"; + +const alice = author("alice"); +const bob = author("bob"); + +function op( + id: string, + timestamp: number, + actions: Action[], + by: Author = alice, +): Operation { + return { id, author: by, timestamp, actions }; +} + +function revisionOp(id: string, timestamp: number, by: Author = alice) { + return op( + id, + timestamp, + [{ type: "revision", description: `Revision ${id}`, base: "b", oid: "o" }], + by, + ); +} + +function reviewOp(id: string, timestamp: number, of: string, by = bob) { + return op(id, timestamp, [{ type: "review", revision: of }], by); +} + +function timeline( + activity: Operation[], + props: { + revisionIds?: string[]; + expanded?: Record; + commits?: Record; + } = {}, +) { + const revisionIds = + props.revisionIds ?? + activity + .filter(o => o.actions.some(a => a.type === "revision")) + .map(o => o.id); + return patchTimeline({ + activity, + revisions: revisionIds.map(id => revision({ id })), + commitsByRevision: props.commits ?? {}, + threadsByReview: new Map(), + discussionThreadsByReview: new Map(), + expandedRevisionRuns: props.expanded ?? {}, + }); +} + +// A compact view of the timeline: `opened`, `older(n)` for a fold, and the +// operation type and id for everything else. +function shape(items: ActivityItem[]): string[] { + return items.map(item => { + const data = item.data; + if (data.kind === "opened") { + return data.openedAsDraft ? "opened-draft" : "opened"; + } + if (data.kind === "olderRevisions") return `older(${data.count})`; + return `${data.op.type}:${data.op.id}`; + }); +} + +describe("openingDraftOpId", () => { + const draft = { + type: "lifecycle" as const, + state: { status: "draft" as const }, + }; + + test("finds a draft set in the first or second operation", () => { + expect(openingDraftOpId([op("d", 1, [draft])])).toBe("d"); + expect(openingDraftOpId([revisionOp("r", 1), op("d", 2, [draft])])).toBe( + "d", + ); + }); + + test("goes by timestamp, not array order", () => { + expect( + openingDraftOpId([ + op("d", 3, [draft]), + revisionOp("r", 1), + op("x", 2, [{ type: "label", labels: ["a"] }]), + ]), + ).toBeUndefined(); + }); + + test("ignores a draft set later in the patch's life", () => { + expect( + openingDraftOpId([ + revisionOp("r", 1), + op("x", 2, [{ type: "label", labels: ["a"] }]), + op("d", 3, [draft]), + ]), + ).toBeUndefined(); + }); + + test("ignores an opening lifecycle change that isn't to draft", () => { + expect( + openingDraftOpId([ + op("o", 1, [{ type: "lifecycle", state: { status: "open" } }]), + ]), + ).toBeUndefined(); + expect( + openingDraftOpId([ + op("a", 1, [{ type: "lifecycle", state: { status: "archived" } }]), + ]), + ).toBeUndefined(); + }); +}); + +describe("patchTimeline", () => { + test("starts with the opening, then the first revision", () => { + expect(shape(timeline([revisionOp("r1", 1)]))).toEqual([ + "opened", + "revision:r1", + ]); + }); + + test("folds a draft at creation into the opening", () => { + expect( + shape( + timeline([ + revisionOp("r1", 1), + op("d", 2, [{ type: "lifecycle", state: { status: "draft" } }]), + ]), + ), + ).toEqual(["opened-draft", "revision:r1"]); + }); + + test("keeps a later switch to draft as its own entry", () => { + expect( + shape( + timeline([ + revisionOp("r1", 1), + op("l", 2, [{ type: "label", labels: ["x"] }]), + op("d", 3, [{ type: "lifecycle", state: { status: "draft" } }]), + ]), + ), + ).toEqual(["opened", "revision:r1", "label:l", "lifecycle:d"]); + }); + + test("places each review right after its revision", () => { + expect( + shape( + timeline([ + revisionOp("r1", 1), + revisionOp("r2", 2), + reviewOp("v1", 3, "r1"), + ]), + ), + ).toEqual(["opened", "revision:r1", "review:v1", "revision:r2"]); + }); + + test("orders entries by timestamp", () => { + expect( + shape( + timeline([ + op("l", 5, [{ type: "label", labels: ["x"] }]), + revisionOp("r1", 1), + ]), + ), + ).toEqual(["opened", "revision:r1", "label:l"]); + }); + + test("folds review and revision edits into the entries they change", () => { + const items = timeline([ + revisionOp("r1", 1), + reviewOp("v1", 2, "r1"), + op("e1", 3, [ + { + type: "review.edit", + review: "v1", + verdict: "accept", + summary: "LGTM", + }, + { type: "revision.edit", revision: "r1", description: "Edited" }, + ]), + ]); + + expect(shape(items)).toEqual(["opened", "revision:r1", "review:v1"]); + expect(items[1].data).toMatchObject({ + op: { description: "Edited" }, + }); + expect(items[2].data).toMatchObject({ + op: { verdict: "accept", summary: "LGTM" }, + }); + }); + + test("drops redacted revisions and reviews", () => { + expect( + shape( + timeline( + [ + revisionOp("r1", 1), + revisionOp("r2", 2), + reviewOp("v1", 3, "r1"), + op("x", 4, [ + { type: "revision.redact", revision: "r2" }, + { type: "review.redact", review: "v1" }, + ]), + ], + { revisionIds: ["r1"] }, + ), + ), + ).toEqual(["opened", "revision:r1"]); + }); + + test("keeps a review of a redacted revision, at the end", () => { + expect( + shape( + timeline( + [ + revisionOp("r1", 1), + revisionOp("r2", 2), + reviewOp("v2", 3, "r2"), + op("l", 4, [{ type: "label", labels: ["x"] }]), + op("x", 5, [{ type: "revision.redact", revision: "r2" }]), + ], + { revisionIds: ["r1"] }, + ), + ), + ).toEqual(["opened", "revision:r1", "label:l", "review:v2"]); + }); + + test("attaches commits and marks merges and reviews as standalone", () => { + const commits = [{ id: "c1" } as Commit]; + const items = timeline( + [ + revisionOp("r1", 1), + reviewOp("v1", 2, "r1"), + op("m", 3, [{ type: "merge", revision: "r1", commit: "c1" }]), + ], + { commits: { r1: commits } }, + ); + + expect(items[1].data).toMatchObject({ kind: "op", commits }); + expect(items.map(i => i.standalone)).toEqual([ + undefined, + false, + true, + true, + ]); + }); + + test("attaches a review's code and discussion threads", () => { + const code = [thread(comment({ location: location("a.ts", "new", 1) }))]; + const discussion = [thread(comment())]; + const [, , review] = patchTimeline({ + activity: [revisionOp("r1", 1), reviewOp("v1", 2, "r1")], + revisions: [revision({ id: "r1" })], + commitsByRevision: {}, + threadsByReview: new Map([["v1", code]]), + discussionThreadsByReview: new Map([["v1", discussion]]), + expandedRevisionRuns: {}, + }); + + expect(review.data).toMatchObject({ + reviewThreads: code, + reviewComments: discussion, + }); + }); + + describe("older revisions", () => { + const activity = [ + revisionOp("r1", 1), + reviewOp("v1", 2, "r1", alice), + revisionOp("r2", 3), + revisionOp("r3", 4), + ]; + + test("folds a run of older revisions, with their reviews, by one author", () => { + const items = timeline(activity); + + expect(shape(items)).toEqual(["opened", "older(2)", "revision:r3"]); + expect(items[1].data).toEqual({ + kind: "olderRevisions", + groupKey: "older:r1", + revisionIds: ["r1", "r2"], + count: 2, + author: alice, + expanded: false, + }); + }); + + test("shows the folded entries when the run is expanded", () => { + expect( + shape(timeline(activity, { expanded: { "older:r1": true } })), + ).toEqual([ + "opened", + "older(2)", + "revision:r1", + "review:v1", + "revision:r2", + "revision:r3", + ]); + }); + + // Current behaviour, which may not be intended: a review by someone else + // breaks the run, so reviewed revisions rarely fold. + test("a review by another author breaks the run", () => { + expect( + shape( + timeline([ + revisionOp("r1", 1), + reviewOp("v1", 2, "r1", bob), + revisionOp("r2", 3), + revisionOp("r3", 4), + ]), + ), + ).toEqual([ + "opened", + "revision:r1", + "review:v1", + "revision:r2", + "revision:r3", + ]); + }); + + test("doesn't fold a lone older revision", () => { + expect( + shape(timeline([revisionOp("r1", 1), revisionOp("r2", 2)])), + ).toEqual(["opened", "revision:r1", "revision:r2"]); + }); + + test("a different author breaks the run", () => { + expect( + shape( + timeline([ + revisionOp("r1", 1), + revisionOp("r2", 2, bob), + revisionOp("r3", 3), + revisionOp("r4", 4), + ]), + ), + ).toEqual([ + "opened", + "revision:r1", + "revision:r2", + "revision:r3", + "revision:r4", + ]); + }); + + test("another entry between revisions breaks the run", () => { + expect( + shape( + timeline([ + revisionOp("r1", 1), + op("l", 2, [{ type: "label", labels: ["x"] }]), + revisionOp("r2", 3), + revisionOp("r3", 4), + ]), + ), + ).toEqual([ + "opened", + "revision:r1", + "label:l", + "revision:r2", + "revision:r3", + ]); + }); + }); +}); diff --git a/desktop/tests/unit/pierreComments.test.ts b/desktop/tests/unit/pierreComments.test.ts new file mode 100644 index 000000000..cfb3afc50 --- /dev/null +++ b/desktop/tests/unit/pierreComments.test.ts @@ -0,0 +1,356 @@ +import type { CodeLocation } from "@bindings/cob/thread/CodeLocation"; +import type { CodeRange } from "@bindings/cob/thread/CodeRange"; + +import { describe, expect, test } from "vitest"; + +import type { ComposerTarget } from "@app/lib/pierreComments"; +import { + anchorOf, + annotationSignature, + commentCountsByPath, + commentedLines, + composerLines, + entryStatus, + fileAnnotations, + formatAnchor, + formatAnchorLines, + isCommentableStatus, + itemIds, + lineRangeOf, + locationOf, +} from "@app/lib/pierreComments"; + +import { comment, location, thread } from "./support/cobs"; + +function lines(start: number, end: number): CodeRange { + return { type: "lines", range: { start, end } }; +} + +function at( + path: string, + props: { old?: CodeRange; new?: CodeRange }, +): CodeLocation { + return { + commit: "c".repeat(40), + path, + old: props.old ?? null, + new: props.new ?? null, + }; +} + +describe("anchorOf", () => { + test("anchors a new-side range under its last line", () => { + expect(anchorOf(at("a.ts", { new: lines(3, 8) }))).toEqual({ + path: "a.ts", + side: "additions", + line: 7, + }); + }); + + test("anchors an old-side range on the deletions side", () => { + expect(anchorOf(at("a.ts", { old: lines(5, 6) }))).toEqual({ + path: "a.ts", + side: "deletions", + line: 5, + }); + }); + + test("prefers the new side when a location has both", () => { + expect( + anchorOf(at("a.ts", { old: lines(1, 2), new: lines(9, 10) }))?.side, + ).toBe("additions"); + }); + + test("places a chars range on its line", () => { + expect( + anchorOf( + at("a.ts", { + new: { type: "chars", line: 4, range: { start: 0, end: 2 } }, + }), + )?.line, + ).toBe(4); + }); + + test.each([null, undefined, at("a.ts", {})])( + "has no anchor for %j", + value => { + expect(anchorOf(value)).toBeUndefined(); + }, + ); +}); + +describe("locationOf", () => { + const target: ComposerTarget = { + path: "a.ts", + side: "additions", + firstLine: 4, + lastLine: 6, + }; + + test("writes the range on the composer's side", () => { + expect(locationOf("abc", target)).toEqual({ + commit: "abc", + path: "a.ts", + old: null, + new: lines(4, 7), + }); + }); + + test("writes a deletions range on the old side", () => { + expect(locationOf("abc", { ...target, side: "deletions" })).toEqual({ + commit: "abc", + path: "a.ts", + old: lines(4, 7), + new: null, + }); + }); + + test("normalises a range dragged upwards", () => { + expect( + locationOf("abc", { ...target, firstLine: 6, lastLine: 4 }).new, + ).toEqual(lines(4, 7)); + }); + + test("round-trips through anchorOf to the composer's last line", () => { + expect(anchorOf(locationOf("abc", target))).toEqual({ + path: "a.ts", + side: "additions", + line: 6, + }); + }); +}); + +describe("formatAnchorLines", () => { + test.each<[CodeLocation, string | undefined]>([ + [at("a.ts", { new: lines(12, 13) }), "R12"], + [at("a.ts", { old: lines(3, 10) }), "L3-L9"], + [at("a.ts", { new: lines(3, 10) }), "R3-R9"], + [ + at("a.ts", { + old: { type: "chars", line: 7, range: { start: 1, end: 4 } }, + }), + "L7", + ], + [at("a.ts", {}), undefined], + ])("formats %j as %j", (value, expected) => { + expect(formatAnchorLines(value)).toBe(expected); + }); + + test("formatAnchor prefixes the path", () => { + expect(formatAnchor(at("src/a.ts", { old: lines(3, 10) }))).toBe( + "src/a.ts:L3-L9", + ); + expect(formatAnchor(at("src/a.ts", {}))).toBe("src/a.ts"); + }); +}); + +test.each([ + ["added", true], + ["deleted", true], + ["modified", true], + [undefined, true], + ["moved", false], + ["copied", false], +] as const)("isCommentableStatus(%j) is %j", (status, expected) => { + expect(isCommentableStatus(status)).toBe(expected); +}); + +describe("fileAnnotations", () => { + const onLine5 = location("a.ts", "new", 5); + + test("keeps only this file's threads, one annotation per side and line", () => { + const annotations = fileAnnotations( + "a.ts", + [ + thread(comment({ id: "1", location: onLine5 })), + thread(comment({ id: "2", location: location("a.ts", "old", 5) })), + thread(comment({ id: "3", location: location("b.ts", "new", 5) })), + thread(comment({ id: "4", location: null })), + ], + undefined, + ); + + expect( + annotations.map(a => [ + a.side, + a.lineNumber, + a.metadata.threads.map(t => t.root.id), + ]), + ).toEqual([ + ["additions", 5, ["1"]], + ["deletions", 5, ["2"]], + ]); + }); + + test("orders threads on the same line oldest first", () => { + const [annotation] = fileAnnotations( + "a.ts", + [ + thread(comment({ id: "new", location: onLine5, timestamp: 20 })), + thread(comment({ id: "old", location: onLine5, timestamp: 10 })), + ], + undefined, + ); + + expect(annotation.metadata.threads.map(t => t.root.id)).toEqual([ + "old", + "new", + ]); + }); + + test("puts the composer on its last line, sharing a slot with threads", () => { + const composer: ComposerTarget = { + path: "a.ts", + side: "additions", + firstLine: 2, + lastLine: 5, + }; + const annotations = fileAnnotations( + "a.ts", + [thread(comment({ id: "1", location: onLine5 }))], + composer, + ); + + expect(annotations).toHaveLength(1); + expect(annotations[0].metadata.composer).toBe(composer); + expect(annotations[0].metadata.threads).toHaveLength(1); + }); + + test("ignores a composer in another file", () => { + const annotations = fileAnnotations("a.ts", [], { + path: "b.ts", + side: "additions", + firstLine: 1, + lastLine: 1, + }); + + expect(annotations).toEqual([]); + }); +}); + +describe("commentCountsByPath", () => { + test("counts resolved and unresolved threads per file", () => { + const counts = commentCountsByPath( + [ + thread(comment({ id: "1", location: location("a.ts", "new", 1) })), + thread( + comment({ + id: "2", + location: location("a.ts", "new", 2), + resolved: true, + }), + ), + thread(comment({ id: "3", location: location("b.ts", "old", 1) })), + thread(comment({ id: "4", location: null })), + ], + () => true, + ); + + expect(Object.fromEntries(counts)).toEqual({ + "a.ts": { resolved: 1, unresolved: 1 }, + "b.ts": { resolved: 0, unresolved: 1 }, + }); + }); + + test("skips threads that can't be resolved", () => { + const counts = commentCountsByPath( + [ + thread(comment({ id: "draft", location: location("a.ts", "new", 1) })), + thread(comment({ id: "real", location: location("a.ts", "new", 2) })), + ], + id => id !== "draft", + ); + + expect(counts.get("a.ts")).toEqual({ resolved: 0, unresolved: 1 }); + }); +}); + +describe("line ranges", () => { + test("lineRangeOf prefers the new side and ends after the last line", () => { + expect( + lineRangeOf(at("a.ts", { old: lines(1, 2), new: lines(3, 5) })), + ).toEqual({ side: "new", start: 3, end: 5 }); + expect(lineRangeOf(at("a.ts", { old: lines(1, 2) }))).toEqual({ + side: "old", + start: 1, + end: 2, + }); + expect( + lineRangeOf( + at("a.ts", { + new: { type: "chars", line: 7, range: { start: 0, end: 3 } }, + }), + ), + ).toEqual({ side: "new", start: 7, end: 8 }); + expect(lineRangeOf(at("a.ts", {}))).toBeUndefined(); + }); + + test("commentedLines lists each line of the range", () => { + expect(commentedLines(at("a.ts", { new: lines(3, 6) }))).toEqual([3, 4, 5]); + expect(commentedLines(at("a.ts", {}))).toEqual([]); + }); +}); + +test("annotationSignature only depends on which slots are taken", () => { + const a = { side: "additions" as const, lineNumber: 1, metadata: {} }; + const b = { side: "deletions" as const, lineNumber: 2, metadata: {} }; + + expect(annotationSignature([b, a] as never)).toBe( + annotationSignature([a, b] as never), + ); + expect(annotationSignature([a] as never)).not.toBe( + annotationSignature([b] as never), + ); + expect(annotationSignature(undefined)).toBe(""); +}); + +describe("composerLines", () => { + test("orders a range dragged upwards", () => { + expect(composerLines({ start: 9, end: 4, side: "deletions" })).toEqual({ + side: "deletions", + firstLine: 4, + lastLine: 9, + }); + }); + + test("keeps the starting line of a drag that ends on the other side", () => { + expect( + composerLines({ + start: 3, + end: 8, + side: "additions", + endSide: "deletions", + }), + ).toEqual({ side: "additions", firstLine: 3, lastLine: 3 }); + }); + + test("defaults to the new side", () => { + expect(composerLines({ start: 2, end: 5, endSide: "additions" })).toEqual({ + side: "additions", + firstLine: 2, + lastLine: 5, + }); + }); +}); + +test.each([ + ["added", "deleted", "deleted"], + ["deleted", "new", "added"], + ["added", "new", "added"], + ["modified", "change", "modified"], + [undefined, "new", undefined], +] as const)( + "entryStatus of a %j path's %j entry is %j", + (status, type, expected) => { + expect(entryStatus(status, type)).toBe(expected); + }, +); + +test("itemIds numbers repeated paths, keeping the first bare", () => { + expect(itemIds(["a", "b", "a", "a"])).toEqual([ + "a", + "b", + "a\u00002", + "a\u00003", + ]); +}); diff --git a/desktop/tests/unit/pierreParse.test.ts b/desktop/tests/unit/pierreParse.test.ts new file mode 100644 index 000000000..87394c503 --- /dev/null +++ b/desktop/tests/unit/pierreParse.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +type Request = { id: number; patch: string; cacheKeyPrefix?: string }; + +// Stands in for the parse worker. Tests decide how each instance behaves. +class FakeWorker { + static instances: FakeWorker[] = []; + static failToStart = false; + + onmessage: ((event: { data: unknown }) => void) | null = null; + onerror: ((event: { message: string }) => void) | null = null; + requests: Request[] = []; + + constructor() { + if (FakeWorker.failToStart) throw new Error("no workers here"); + FakeWorker.instances.push(this); + } + + postMessage(request: Request) { + this.requests.push(request); + } + + reply(data: unknown) { + this.onmessage?.({ data }); + } +} + +const processPatch = vi.fn((patch: string) => ({ + files: [{ name: `main-thread:${patch}` }], +})); + +vi.mock("@app/lib/pierreParse.worker?worker", () => ({ default: FakeWorker })); +vi.mock("@pierre/diffs", () => ({ processPatch })); + +async function load() { + vi.resetModules(); + return (await import("@app/lib/pierreParse")).parsePatch; +} + +// Lets the worker receive the request before the test replies. +async function tick() { + await new Promise(resolve => setTimeout(resolve, 0)); +} + +beforeEach(() => { + FakeWorker.instances = []; + FakeWorker.failToStart = false; + processPatch.mockClear(); + vi.spyOn(console, "error").mockReturnValue(undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("parsePatch", () => { + test("parses in the worker", async () => { + const parsePatch = await load(); + const result = parsePatch("patch", "rev1"); + await tick(); + const [worker] = FakeWorker.instances; + const [request] = worker.requests; + expect(request).toMatchObject({ patch: "patch", cacheKeyPrefix: "rev1" }); + + worker.reply({ id: request.id, files: [{ name: "from-worker" }] }); + + expect(await result).toEqual([{ name: "from-worker" }]); + expect(processPatch).not.toHaveBeenCalled(); + }); + + test("routes out-of-order replies to the right request", async () => { + const parsePatch = await load(); + const first = parsePatch("one"); + const second = parsePatch("two"); + await tick(); + const [worker] = FakeWorker.instances; + const [a, b] = worker.requests; + + worker.reply({ id: b.id, files: [{ name: "two" }] }); + worker.reply({ id: a.id, files: [{ name: "one" }] }); + + expect(await first).toEqual([{ name: "one" }]); + expect(await second).toEqual([{ name: "two" }]); + expect(FakeWorker.instances).toHaveLength(1); + }); + + test("parses on the main thread when the worker can't start", async () => { + FakeWorker.failToStart = true; + const parsePatch = await load(); + + expect(await parsePatch("patch", "rev1")).toEqual([ + { name: "main-thread:patch" }, + ]); + expect(processPatch).toHaveBeenCalledWith("patch", "rev1"); + }); + + test("falls back to the main thread when the worker reports an error", async () => { + const parsePatch = await load(); + const result = parsePatch("patch"); + await tick(); + const [worker] = FakeWorker.instances; + + worker.reply({ id: worker.requests[0].id, error: "bad patch" }); + + expect(await result).toEqual([{ name: "main-thread:patch" }]); + }); + + test("a crashed worker fails over its pending requests and is not reused", async () => { + const parsePatch = await load(); + const pending = parsePatch("one"); + await tick(); + const [worker] = FakeWorker.instances; + + worker.onerror?.({ message: "crashed" }); + + expect(await pending).toEqual([{ name: "main-thread:one" }]); + expect(await parsePatch("two")).toEqual([{ name: "main-thread:two" }]); + expect(FakeWorker.instances).toHaveLength(1); + }); + + test("ignores replies for unknown requests", async () => { + const parsePatch = await load(); + const result = parsePatch("patch"); + await tick(); + const [worker] = FakeWorker.instances; + + worker.reply({ id: 999, files: [{ name: "stray" }] }); + worker.reply({ id: worker.requests[0].id, files: [{ name: "real" }] }); + + expect(await result).toEqual([{ name: "real" }]); + }); +}); diff --git a/desktop/tests/unit/radUri.test.ts b/desktop/tests/unit/radUri.test.ts new file mode 100644 index 000000000..8305e34df --- /dev/null +++ b/desktop/tests/unit/radUri.test.ts @@ -0,0 +1,709 @@ +import { describe, expect, test } from "vitest"; + +import type { RadUri } from "@app/lib/radUri"; +import { + cobListType, + explorerUrl, + filePath, + fileReference, + formatRadUri, + isFileReference, + isSafeRef, + issueType, + parseExplorerTreeUrl, + parseExplorerUrl, + parseRadUri, + parseReference, + patchType, + releaseType, +} from "@app/lib/radUri"; + +// The RIP 4 fixture `zk1arLZ…` decodes to 21 bytes, so like the +// `radicle-uri` crate these cases use `z21arLZ…`, one character apart. +const repo00 = "z3gqcJUbbbbbbbbbbbbbbbCSGazv5"; +const repo01 = "z21arLZbbbbbbbbbbbbbbb26us45E"; +const node01 = "z6MksFbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbS9wzpT"; +const node02 = "z6Mku8bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbo9XVAx"; +const seed = "seed.example.com:8776"; +const oid = "e9cf5263ffffffffffffffffffffffffffffffff"; + +describe("RIP 4 examples", () => { + const cases: [string, string, RadUri][] = [ + ["tc00a", `rad:${repo00}`, { repo: repo00 }], + ["tc00b", `rad://${repo00}`, { repo: repo00 }], + ["tc00c", `rad:///${repo00}`, { repo: repo00 }], + ["tc01a", `rad:${repo01}/${node01}`, { repo: repo01, namespace: node01 }], + ["tc01b", `rad://${repo01}/${node01}`, { repo: repo01, namespace: node01 }], + [ + "tc01c", + `rad:///${repo01}/${node01}`, + { repo: repo01, namespace: node01 }, + ], + [ + "tc02", + `rad://${node01}/${repo01}`, + { authority: { node: node01 }, repo: repo01 }, + ], + [ + "tc03", + `rad://${node01}/${repo01}/${node01}`, + { authority: { node: node01 }, repo: repo01, namespace: node01 }, + ], + [ + "tc04", + `rad://${node01}@${seed}/${repo01}`, + { authority: { node: node01, address: seed }, repo: repo01 }, + ], + [ + "tc05", + `rad://${node01}@${seed}/${repo01}/${node02}`, + { + authority: { node: node01, address: seed }, + repo: repo01, + namespace: node02, + }, + ], + [ + "tc10a", + `rad:${repo00}/commit/72db6dffffffffffffffffffffffffffffffffff`, + { + repo: repo00, + resource: { + type: "commit", + ref: "72db6dffffffffffffffffffffffffffffffffff", + }, + }, + ], + [ + "tc10b", + `rad:///${repo00}/commit/72db6dffffffffffffffffffffffffffffffffff`, + { + repo: repo00, + resource: { + type: "commit", + ref: "72db6dffffffffffffffffffffffffffffffffff", + }, + }, + ], + [ + "tc12", + `rad://${node01}/${repo01}/commit/${oid}`, + { + authority: { node: node01 }, + repo: repo01, + resource: { type: "commit", ref: oid }, + }, + ], + [ + "tc13", + `rad://${node01}@${seed}/${repo01}/${node02}/commit/72db6dffffffffffffffffffffffffffffffffff`, + { + authority: { node: node01, address: seed }, + repo: repo01, + namespace: node02, + resource: { + type: "commit", + ref: "72db6dffffffffffffffffffffffffffffffffff", + }, + }, + ], + [ + "tc30", + `rad:${repo01}/cob/org.example/${oid}`, + { + repo: repo01, + resource: { type: "cob", typeName: "org.example", oid }, + }, + ], + [ + "tc31", + `rad://${node01}/${repo01}/cob/org.example/${oid}`, + { + authority: { node: node01 }, + repo: repo01, + resource: { type: "cob", typeName: "org.example", oid }, + }, + ], + [ + "tc32", + `rad:${repo01}/cob/org.example`, + { repo: repo01, resource: { type: "cob", typeName: "org.example" } }, + ], + [ + "tc40a", + `rad:${repo01}/tree/3eb47e9fffffffffffffffffffffffffffffffff?path=src`, + { + repo: repo01, + resource: { + type: "tree", + oid: "3eb47e9fffffffffffffffffffffffffffffffff", + }, + query: [{ param: "path", value: "src" }], + }, + ], + [ + "tc41a", + `rad:${repo01}/commit/master?blob=README.md`, + { + repo: repo01, + resource: { type: "commit", ref: "master" }, + query: [{ param: "blob", value: "README.md" }], + }, + ], + [ + "tc42a", + `rad:${repo01}/commit/baz?path=foo/doc`, + { + repo: repo01, + resource: { type: "commit", ref: "baz" }, + query: [{ param: "path", value: "foo/doc" }], + }, + ], + [ + "tc43a", + `rad:${repo01}/commit/baz/foo?path=doc&path=src`, + { + repo: repo01, + resource: { type: "commit", ref: "baz/foo" }, + query: [ + { param: "path", value: "doc" }, + { param: "path", value: "src" }, + ], + }, + ], + [ + "tc44", + `rad:///${repo01}/commit/refs/notes/commit?blob=${oid}`, + { + repo: repo01, + resource: { type: "commit", ref: "refs/notes/commit" }, + query: [{ param: "blob", value: oid }], + }, + ], + ]; + + test.each(cases)("%s parses", (_, input, expected) => { + expect(parseRadUri(input)).toEqual(expected); + }); +}); + +describe("parseRadUri", () => { + test("is case-insensitive in the scheme and resource type", () => { + expect(parseRadUri(`RAD:${repo00}/COMMIT/master`)).toEqual({ + repo: repo00, + resource: { type: "commit", ref: "master" }, + }); + }); + + test.each([ + [ + `cob/${issueType}/${oid.toUpperCase()}`, + { type: "cob", typeName: issueType, oid }, + ], + [`tree/${oid.toUpperCase()}`, { type: "tree", oid }], + [`commit/${oid.toUpperCase()}`, { type: "commit", ref: oid }], + ])("lowercases the oid in %s", (resource, expected) => { + expect(parseRadUri(`rad:${repo00}/${resource}`)?.resource).toEqual( + expected, + ); + }); + + test("parses an IPv6 authority", () => { + expect(parseRadUri(`rad://${node01}@[::1]:8776/${repo00}`)).toEqual({ + authority: { node: node01, address: "[::1]:8776" }, + repo: repo00, + }); + }); + + test("parses a namespace followed by a resource", () => { + expect(parseRadUri(`rad:${repo00}/${node01}/commit/master`)).toEqual({ + repo: repo00, + namespace: node01, + resource: { type: "commit", ref: "master" }, + }); + }); + + test("reads a question mark after the fragment as part of it", () => { + expect(parseRadUri(`rad:${repo00}#a?b`)).toEqual({ + repo: repo00, + fragment: "a?b", + }); + }); +}); + +describe("rejects", () => { + test.each([ + ["no scheme", repo00], + ["another scheme", `https:${repo00}`], + ["no repo", "rad:"], + ["empty authority without a repo", "rad:///"], + ["an absolute path without authority", `rad:/${repo00}`], + ["a non-base58 repo id", "rad:z3gqcJUbbbbbbbbbbbbbbbCSGazv0"], + ["a repo id of the wrong length", "rad:zk1arLZbbbbbbbbbbbbbbb26us45E"], + ["an abbreviated tree oid", `rad:${repo00}/tree/abc123`], + ["a blob that is not an oid", `rad:${repo00}/blob/not-a-sha1`], + ["an abbreviated cob oid", `rad:${repo00}/cob/${issueType}/e9cf5263`], + ["an unknown resource type", `rad:${repo00}/issues/${oid}`], + [ + "an invalid authority node", + `rad://z6Mk11111111111111111111111111111111111111111111/${repo00}`, + ], + [ + "an invalid namespace", + `rad:${repo00}/z6Mk11111111111111111111111111111111111111111111`, + ], + ["an address without a port", `rad://${node01}@seed.example.com/${repo00}`], + ["trailing input", `rad:${repo00}/commit/master trailing`], + ["a trailing slash", `rad:${repo00}/`], + ["a resource on the legacy form", `rad://${repo00}/commit/master`], + ["a query on the legacy form", `rad://${repo00}?x`], + ["an invalid host", `rad://${node01}@seed example.com:8776/${repo00}`], + ["an authority without a repo", `rad://${node01}`], + ["invalid query characters", `rad:${repo00}?a b`], + ["invalid fragment characters", `rad:${repo00}#a b`], + ["a tree with extra segments", `rad:${repo00}/tree/${oid}/src`], + ["a cob with extra segments", `rad:${repo00}/cob/${issueType}/${oid}/x`], + ["a cob type without a dot", `rad:${repo00}/cob/issue/${oid}`], + ])("%s", (_, input) => { + expect(parseRadUri(input)).toBeUndefined(); + }); +}); + +describe("formatRadUri", () => { + test.each([ + `rad:${repo00}`, + `rad:${repo00}/${node01}`, + `rad://${node01}/${repo00}`, + `rad://${node01}@${seed}/${repo00}/${node02}/commit/master?path=src#L10`, + `rad:${repo00}/tag/v1.0`, + `rad:${repo00}/blob/${oid}`, + `rad:${repo00}/cob/${patchType}/${oid}`, + `rad:${repo00}?raw&path=src`, + `rad:${repo00}/commit/master#section-1`, + `rad:${repo00}?`, + ])("round-trips %s", input => { + const uri = parseRadUri(input); + expect(uri).toBeDefined(); + expect(formatRadUri(uri!)).toBe(input); + }); + + test("drops an empty authority", () => { + expect(formatRadUri(parseRadUri(`rad:///${repo00}/commit/master`)!)).toBe( + `rad:${repo00}/commit/master`, + ); + }); + + test("writes an empty authority when forced", () => { + expect(formatRadUri({ repo: repo00 }, { forceAuthority: true })).toBe( + `rad:///${repo00}`, + ); + }); +}); + +describe("parseReference", () => { + test("parses a DID", () => { + expect(parseReference(`did:key:${node01}`)).toEqual({ + type: "did", + node: node01, + }); + }); + + test("rejects a DID of another method", () => { + expect(parseReference(`did:web:${node01}`)).toBeUndefined(); + }); + + test("rejects an invalid DID", () => { + expect( + parseReference( + "did:key:z6Mk11111111111111111111111111111111111111111111", + ), + ).toBeUndefined(); + }); +}); + +describe("explorer URLs", () => { + const template = "https://app.radicle.xyz/"; + const host = "seed.radicle.xyz"; + const base = `https://app.radicle.xyz/nodes/${host}`; + + test.each([ + [`rad:${repo00}`, `${base}/rad:${repo00}`], + [ + `rad:${repo00}/cob/${issueType}/${oid}`, + `${base}/rad:${repo00}/issues/${oid}`, + ], + [ + `rad:${repo00}/cob/${patchType}/${oid}`, + `${base}/rad:${repo00}/patches/${oid}`, + ], + [ + `rad:${repo00}/cob/${releaseType}/${oid}`, + `${base}/rad:${repo00}/releases/${oid}`, + ], + [`rad:${repo00}/commit/${oid}`, `${base}/rad:${repo00}/commits/${oid}`], + [`rad:${repo00}/${node01}`, `${base}/rad:${repo00}/remotes/${node01}`], + [`did:key:${node01}`, `${base}/users/did:key:${node01}`], + ])("maps %s both ways", (reference, url) => { + const parsed = parseReference(reference)!; + expect(explorerUrl(parsed, template, host)).toBe(url); + expect(parseExplorerUrl(url)).toEqual(parsed); + }); + + test("maps a branch to its tree", () => { + expect( + explorerUrl( + parseReference(`rad:${repo00}/commit/master`)!, + template, + host, + ), + ).toBe(`${base}/rad:${repo00}/tree/master`); + }); + + test.each([ + [`commit/master?path=src/lib`, `/tree/master/src/lib`], + [`commit/${oid}?blob=README.md`, `/tree/${oid}/README.md`], + [`tag/v1.0?tree=docs`, `/tree/v1.0/docs`], + [`${node01}/commit/master`, `/remotes/${node01}/tree/master`], + [`${node01}/commit/${oid}`, `/commits/${oid}`], + ])("maps %s", (resource, path) => { + expect( + explorerUrl(parseReference(`rad:${repo00}/${resource}`)!, template, host), + ).toBe(`${base}/rad:${repo00}${path}`); + }); + + test("has no page for an unknown cob type", () => { + expect( + explorerUrl( + parseReference(`rad:${repo00}/cob/org.example/${oid}`)!, + template, + host, + ), + ).toBeUndefined(); + }); + + test("decodes percent-encoded ids", () => { + expect(parseExplorerUrl(`${base}/users/did%3Akey%3A${node01}`)).toEqual({ + type: "did", + node: node01, + }); + }); + + test("rejects an abbreviated oid", () => { + expect( + parseExplorerUrl(`${base}/rad:${repo00}/issues/e9cf5263`), + ).toBeUndefined(); + }); +}); + +describe("explorer URL edge cases", () => { + const base = "https://app.radicle.xyz"; + const node = `${base}/nodes/seed.radicle.xyz`; + + test.each([ + [`tree/${oid}`, undefined], + [`blob/${oid}`, undefined], + ["commit/master?path=", `${node}/rad:${repo00}/tree/master`], + [`cob/${issueType}`, `${node}/rad:${repo00}/issues`], + ["commit/refs/heads/main", `${node}/rad:${repo00}/tree/main`], + ["tag/refs/tags/v1.0", `${node}/rad:${repo00}/tree/v1.0`], + ["commit/refs/notes/commit", undefined], + ])("maps %s", (resource, url) => { + expect( + explorerUrl( + parseReference(`rad:${repo00}/${resource}`)!, + base, + "seed.radicle.xyz", + ), + ).toBe(url); + }); + + test.each([ + ["a non-web scheme", `ftp://app.radicle.xyz/nodes/h/rad:${repo00}`], + ["an invalid repo id", `${node}/rad:z3gqcJUbbbbbbbbbbbbbbbCSGazv0`], + ["extra segments", `${node}/rad:${repo00}/issues/${oid}/x`], + ["an unknown section", `${node}/rad:${repo00}/wiki/${oid}`], + ["users before the end", `${node}/users/did:key:${node01}/x`], + ["a branch followed by a path", `${node}/rad:${repo00}/tree/feature/x`], + ["a history page", `${node}/rad:${repo00}/history/main`], + [ + "an invalid remote", + `${node}/rad:${repo00}/remotes/z6Mk11111111111111111111111111111111111111111111`, + ], + ])("parseExplorerUrl rejects %s", (_, url) => { + expect(parseExplorerUrl(url)).toBeUndefined(); + }); + + test("parseExplorerUrl lowercases the oid", () => { + expect( + parseExplorerUrl(`${node}/rad:${repo00}/commits/${oid.toUpperCase()}`), + ).toEqual({ + type: "uri", + uri: { repo: repo00, resource: { type: "commit", ref: oid } }, + }); + }); + + test.each([ + [ + "a patch revision", + `${node}/rad:${repo00}/patches/${oid}/${oid}?tab=changes`, + { repo: repo00, resource: { type: "cob", typeName: patchType, oid } }, + ], + [ + "an issue under a remote", + `${node}/rad:${repo00}/remotes/${node01}/issues/${oid}`, + { repo: repo00, resource: { type: "cob", typeName: issueType, oid } }, + ], + [ + "a remote's tree", + `${node}/rad:${repo00}/remotes/${node01}/tree`, + { repo: repo00, namespace: node01 }, + ], + [ + "a tree at an oid", + `${node}/rad:${repo00}/tree/${oid}`, + { + repo: repo00, + resource: { type: "commit", ref: oid }, + query: [{ param: "path", value: "" }], + }, + ], + [ + "an encoded path at an oid", + `${node}/rad:${repo00}/tree/${oid}/My%20File.md`, + { + repo: repo00, + resource: { type: "commit", ref: oid }, + query: [{ param: "path", value: "My%20File.md" }], + }, + ], + [ + "an issue list", + `${node}/rad:${repo00}/issues?status=open`, + { repo: repo00, resource: { type: "cob", typeName: issueType } }, + ], + [ + "a patch list", + `${node}/rad:${repo00}/patches`, + { repo: repo00, resource: { type: "cob", typeName: patchType } }, + ], + [ + "a release list", + `${node}/rad:${repo00}/releases`, + { repo: repo00, resource: { type: "cob", typeName: releaseType } }, + ], + [ + "a bare repo id", + `${node}/${repo00}/issues/${oid}`, + { repo: repo00, resource: { type: "cob", typeName: issueType, oid } }, + ], + [ + "a path at a commit", + `${node}/rad:${repo00}/tree/${oid}/src/lib`, + { + repo: repo00, + resource: { type: "commit", ref: oid }, + query: [{ param: "path", value: "src/lib" }], + }, + ], + ])("parseExplorerUrl reads %s", (_, url, uri) => { + expect(parseExplorerUrl(url)).toEqual({ type: "uri", uri }); + }); + + test("maps a path at a commit both ways", () => { + const url = `${node}/rad:${repo00}/tree/${oid}/src/lib`; + expect(explorerUrl(parseExplorerUrl(url)!, base, "seed.radicle.xyz")).toBe( + url, + ); + }); +}); + +describe("file links", () => { + const base = "https://radicle.network"; + const node = `${base}/nodes/seed.example.com`; + + test.each([ + [ + `${node}/rad:${repo00}/tree/0004-general-uri-scheme/general-uri-scheme.adoc`, + { repo: repo00, path: "0004-general-uri-scheme/general-uri-scheme.adoc" }, + ], + [ + `${node}/rad:${repo00}/remotes/${node01}/tree/feature/x/src`, + { repo: repo00, namespace: node01, path: "feature/x/src" }, + ], + [ + `${node}/rad:${repo00}/tree/main/My%20File.md`, + { repo: repo00, path: "main/My File.md" }, + ], + ])("parseExplorerTreeUrl reads %s", (url, expected) => { + expect(parseExplorerTreeUrl(url)).toEqual(expected); + }); + + test.each([ + [ + "a branch", + `${node}/rad:${repo00}/tree/main`, + { repo: repo00, path: "main" }, + ], + [ + "a remote's branch", + `${node}/rad:${repo00}/remotes/${node01}/tree/main`, + { repo: repo00, namespace: node01, path: "main" }, + ], + ])("parseExplorerTreeUrl reads %s on its own", (_, url, expected) => { + expect(parseExplorerTreeUrl(url)).toEqual(expected); + }); + + test.each([ + ["a tree at an oid", `${node}/rad:${repo00}/tree/${oid}/src`], + ["another page", `${node}/rad:${repo00}/history/main/src`], + ])("parseExplorerTreeUrl leaves %s to parseExplorerUrl", (_, url) => { + expect(parseExplorerTreeUrl(url)).toBeUndefined(); + }); + + test("fileReference writes a path query that maps back to the file", () => { + const reference = fileReference( + { repo: repo00 }, + "main", + "docs/My File.md", + )!; + expect( + formatRadUri(reference.type === "uri" ? reference.uri : { repo: "" }), + ).toBe(`rad:${repo00}/commit/main?path=docs/My%20File.md`); + expect(explorerUrl(reference, base, "seed.example.com")).toBe( + `${node}/rad:${repo00}/tree/main/docs/My%20File.md`, + ); + }); + + test("fileReference keeps the remote", () => { + expect( + explorerUrl( + fileReference({ repo: repo00, namespace: node01 }, "main", "src")!, + base, + "seed.example.com", + ), + ).toBe(`${node}/rad:${repo00}/remotes/${node01}/tree/main/src`); + }); + + test.each([ + [`rad:${repo00}/commit/main?path=src`, true], + [`rad:${repo00}/tag/v1.0?blob=README.md`, true], + [`rad:${repo00}/commit/main`, false], + [`rad:${repo00}/commit/main?path=`, true], + [`rad:${repo00}?path=src`, false], + [`did:key:${node01}`, false], + ])("isFileReference(%s) is %s", (input, expected) => { + expect(isFileReference(parseReference(input)!)).toBe(expected); + }); + + test("filePath decodes the path", () => { + expect( + filePath( + parseReference(`rad:${repo00}/commit/main?path=docs/My%20File.md`)!, + ), + ).toEqual({ revision: "main", path: "docs/My File.md" }); + expect( + filePath(parseReference(`rad:${repo00}/commit/main`)!), + ).toBeUndefined(); + }); + + test("fileReference rejects a revision a URI cannot hold", () => { + expect(fileReference({ repo: repo00 }, "a b", "src")).toBeUndefined(); + }); +}); + +describe("tree roots and lists", () => { + const base = "https://radicle.network"; + const node = `${base}/nodes/seed`; + + test.each([ + `${node}/rad:${repo00}/tree/${oid}`, + `${node}/rad:${repo00}/issues`, + `${node}/rad:${repo00}/patches`, + `${node}/rad:${repo00}/releases`, + ])("maps %s both ways", url => { + expect(explorerUrl(parseExplorerUrl(url)!, base, "seed")).toBe(url); + }); + + test("fileReference with an empty path names the root", () => { + expect( + explorerUrl(fileReference({ repo: repo00 }, "main", "")!, base, "seed"), + ).toBe(`${node}/rad:${repo00}/tree/main`); + }); + + test.each([ + [`rad:${repo00}/cob/${issueType}`, issueType], + [`rad:${repo00}/cob/${issueType}/${oid}`, undefined], + [`rad:${repo00}`, undefined], + ])("cobListType(%s) is %s", (input, expected) => { + expect(cobListType(parseReference(input)!)).toBe(expected); + }); +}); + +describe("line fragments", () => { + const base = "https://radicle.network"; + const node = `${base}/nodes/seed`; + + test("a line of a file maps both ways", () => { + const url = `${node}/rad:${repo00}/tree/${oid}/src/lib.rs#L10`; + const reference = parseExplorerUrl(url)!; + expect(reference).toMatchObject({ uri: { fragment: "L10" } }); + expect(explorerUrl(reference, base, "seed")).toBe(url); + }); + + test("parseExplorerTreeUrl keeps the line", () => { + expect( + parseExplorerTreeUrl(`${node}/rad:${repo00}/tree/main/src/lib.rs#L10`), + ).toEqual({ repo: repo00, path: "main/src/lib.rs", fragment: "L10" }); + }); + + test("drops a fragment a URI cannot hold", () => { + expect( + parseExplorerUrl(`${node}/rad:${repo00}/tree/${oid}/src#a{b}`), + ).toMatchObject({ uri: { fragment: undefined } }); + }); + + test("only files carry a fragment onto the explorer", () => { + expect( + explorerUrl( + parseReference(`rad:${repo00}/commit/${oid}#L10`)!, + base, + "seed", + ), + ).toBe(`${node}/rad:${repo00}/commits/${oid}`); + }); +}); + +describe("explorer paths stay on the page they name", () => { + const base = "https://radicle.network"; + const link = (input: string) => + explorerUrl(parseReference(input)!, base, "seed"); + + test.each([ + `rad:${repo00}/commit/../..`, + `rad:${repo00}/commit/main?path=../../users`, + `rad:${repo00}/commit/main?path=src/./lib`, + ])("has no page for %s", input => { + expect(link(input)).toBeUndefined(); + }); + + test("encodes characters a path segment cannot hold", () => { + expect(link(`rad:${repo00}/commit/main?path=a?b`)).toBe( + `${base}/nodes/seed/rad:${repo00}/tree/main/a%3Fb`, + ); + }); +}); + +test.each([ + ["main", true], + ["feature/x", true], + ["v1.0", true], + [oid, true], + ["..", false], + ["a/../b", false], + ["a..b", false], + [".hidden", false], + ["main.lock", false], + ["v1.", false], +])("isSafeRef(%j) is %j", (ref, expected) => { + expect(isSafeRef(ref)).toBe(expected); +}); diff --git a/desktop/tests/unit/refs.test.ts b/desktop/tests/unit/refs.test.ts new file mode 100644 index 000000000..260b75e54 --- /dev/null +++ b/desktop/tests/unit/refs.test.ts @@ -0,0 +1,77 @@ +import type { Remote } from "@bindings/repo/Remote"; + +import { describe, expect, test } from "vitest"; + +import { selectedRefType, sortedTags, sortRemotes } from "@app/lib/refs"; + +function tag(timestamp: number) { + return { oid: "o", timestamp }; +} + +function remote(props: Partial = {}): Remote { + return { id: "z6Mk", delegate: false, branches: {}, tags: {}, ...props }; +} + +test("sortedTags lists the newest first, then by name descending", () => { + const sorted = sortedTags({ + "v1.0": tag(1), + "v1.2": tag(2), + "v1.1": tag(2), + }); + + expect(sorted.map(([name]) => name)).toEqual(["v1.2", "v1.1", "v1.0"]); +}); + +test("sortRemotes puts delegates first, then aliased peers by alias, then by NID", () => { + const sorted = sortRemotes([ + { id: "b", delegate: false }, + { id: "z", alias: "Bob", delegate: false }, + { id: "a", delegate: false }, + { id: "y", alias: "alice", delegate: false }, + { id: "d", delegate: true }, + { id: "x", alias: "zed", delegate: true }, + ]); + + expect(sorted.map(r => r.id)).toEqual(["x", "d", "y", "z", "a", "b"]); +}); + +describe("selectedRefType", () => { + const canonical = { branches: { main: "o" }, tags: { "v1.0": tag(1) } }; + + test.each([ + [undefined, "branch"], + ["main", "branch"], + ["v1.0", "tag"], + ["default", "branch"], + ["abc123", undefined], + ])("canonical %j is a %j", (revision, expected) => { + expect(selectedRefType(revision, undefined, canonical, "default")).toBe( + expected, + ); + }); + + test.each([ + ["feature", "branch"], + ["v2.0", "tag"], + ["main", undefined], + ["default", undefined], + ])("a peer's %j is a %j", (revision, expected) => { + const peer = remote({ + branches: { feature: "o" }, + tags: { "v2.0": tag(1) }, + }); + + expect(selectedRefType(revision, peer, canonical, "default")).toBe( + expected, + ); + }); + + test("refs that haven't loaded yet only know the default branch", () => { + expect(selectedRefType("default", undefined, undefined, "default")).toBe( + "branch", + ); + expect(selectedRefType("main", undefined, undefined, "default")).toBe( + undefined, + ); + }); +}); diff --git a/desktop/tests/unit/releases.test.ts b/desktop/tests/unit/releases.test.ts new file mode 100644 index 000000000..363c457b3 --- /dev/null +++ b/desktop/tests/unit/releases.test.ts @@ -0,0 +1,283 @@ +import type { Artifact } from "@bindings/cob/release/Artifact"; +import type { ReleaseCounts } from "@bindings/cob/release/ReleaseCounts"; +import type { Commit } from "@bindings/repo/Commit"; + +import { describe, expect, test } from "vitest"; + +import { + artifactView, + attestedBy, + canAttest, + canEditMetadata, + delegatesFirst, + displayMetadataValue, + locationsByNode, + matchCommits, + parseMetadataValue, + redactedByDelegate, + releaseListScope, + releaseWarning, + webLocations, +} from "@app/lib/releases"; + +const me = "did:key:z6MkMe"; +const them = "did:key:z6MkThem"; +const delegate = "did:key:z6MkDelegate"; +const delegates = new Set([delegate]); + +function artifact(cid: string, overrides: Partial = {}): Artifact { + return { + cid, + name: cid, + directory: false, + author: { did: delegate }, + locations: [], + attestations: [], + redactions: [], + redacted: false, + metadata: {}, + ...overrides, + }; +} + +function counts(delegate: number, other: number): ReleaseCounts { + return { delegate, delegateRedacted: 0, other, otherRedacted: 0 }; +} + +describe("releaseListScope", () => { + test("keeps a requested scope", () => { + expect(releaseListScope("trusted", counts(0, 3))).toBe("trusted"); + }); + + test("opens on untrusted when only others released", () => { + expect(releaseListScope(undefined, counts(0, 3))).toBe("untrusted"); + }); + + test.each([ + [1, 3], + [0, 0], + ])("opens on trusted with %d delegate and %d other", (d, o) => { + expect(releaseListScope(undefined, counts(d, o))).toBe("trusted"); + }); +}); + +describe("artifactView", () => { + const ours = artifact("ours"); + const ourRedacted = artifact("ours-redacted", { redacted: true }); + const theirs = artifact("theirs", { author: { did: them } }); + + test("shows the wanted scope", () => { + const view = artifactView([ours, theirs], delegates, "untrusted", false); + expect(view.scope).toBe("untrusted"); + expect(view.shown).toEqual([theirs]); + expect(view.showFilters).toBe(true); + }); + + test("falls back to the other scope when the wanted one is empty", () => { + const view = artifactView([theirs], delegates, "trusted", false); + expect(view.scope).toBe("untrusted"); + expect(view.shown).toEqual([theirs]); + expect(view.showFilters).toBe(false); + }); + + test("hides redacted artifacts unless asked", () => { + const hidden = artifactView( + [ours, ourRedacted], + delegates, + "trusted", + false, + ); + expect(hidden.shown).toEqual([ours]); + expect(hidden.redactedCount).toBe(1); + expect(hidden.counts.trusted).toBe(1); + + const shown = artifactView([ours, ourRedacted], delegates, "trusted", true); + expect(shown.shown).toEqual([ours, ourRedacted]); + expect(shown.counts.trusted).toBe(2); + }); + + test("keeps a scope whose artifacts are all redacted", () => { + const view = artifactView( + [ourRedacted, theirs], + delegates, + "trusted", + false, + ); + expect(view.scope).toBe("trusted"); + expect(view.shown).toEqual([]); + expect(view.counts).toEqual({ trusted: 0, untrusted: 1 }); + }); + + test("counts redactions within the current scope only", () => { + const theirRedacted = artifact("theirs-redacted", { + author: { did: them }, + redacted: true, + }); + const view = artifactView( + [ours, theirs, theirRedacted], + delegates, + "trusted", + false, + ); + expect(view.redactedCount).toBe(0); + }); +}); + +test("redactedByDelegate tells a delegate's redaction from others'", () => { + const byAuthor = artifact("a", { + author: { did: them }, + redactions: [{ user: { did: them }, reason: "" }], + }); + const byDelegate = artifact("b", { + redactions: [ + { user: { did: them }, reason: "" }, + { user: { did: delegate }, reason: "" }, + ], + }); + expect(redactedByDelegate(byAuthor, delegates)).toBe(false); + expect(redactedByDelegate(byDelegate, delegates)).toBe(true); +}); + +test("locationsByNode groups by node in first-seen order", () => { + expect( + locationsByNode([ + { user: { did: them }, url: "a" }, + { user: { did: me }, url: "b" }, + { user: { did: them }, url: "c" }, + ]), + ).toEqual([ + { user: { did: them }, urls: ["a", "c"] }, + { user: { did: me }, urls: ["b"] }, + ]); +}); + +test("delegatesFirst moves delegates up and keeps order within groups", () => { + const items = [them, delegate, me, `${delegate}2`]; + const result = delegatesFirst( + items, + did => did, + new Set([delegate, `${delegate}2`]), + ); + expect(result).toEqual([delegate, `${delegate}2`, them, me]); + expect(items[0]).toBe(them); +}); + +test("webLocations keeps http(s) URLs, delegates first", () => { + const theirs = { user: { did: them }, url: "https://them.example/a" }; + const ours = { user: { did: delegate }, url: "HTTP://us.example/a" }; + expect( + webLocations( + [theirs, { user: { did: delegate }, url: "radiroh://" }, ours], + delegates, + ), + ).toEqual([ours, theirs]); +}); + +describe("attestedBy", () => { + test("is zero without attestations", () => { + expect(attestedBy([], delegates)).toBe(0); + }); + + test("counts only delegates", () => { + expect( + attestedBy([{ did: them }, { did: delegate }, { did: me }], delegates), + ).toBe(1); + }); + + test("ignores attestations by others", () => { + expect(attestedBy([{ did: them }, { did: me }], delegates)).toBe(0); + }); +}); + +describe("canAttest", () => { + test("lets others attest", () => { + expect(canAttest(artifact("a"), me)).toBe(true); + }); + + test.each<[string, Partial]>([ + ["its author", { author: { did: me } }], + ["a trusted redaction", { redacted: true }], + ["an earlier attestation", { attestations: [{ did: me }] }], + ["your own redaction", { redactions: [{ user: { did: me }, reason: "" }] }], + ])("is barred by %s", (_, overrides) => { + expect(canAttest(artifact("a", overrides), me)).toBe(false); + }); +}); + +describe("canEditMetadata", () => { + const theirs = artifact("a", { author: { did: them } }); + + test.each([ + [them, true], + [delegate, true], + [me, false], + ])("%s can edit: %s", (did, expected) => { + expect(canEditMetadata(theirs, did, delegates)).toBe(expected); + }); +}); + +describe("metadata values", () => { + test.each<[string, unknown]>([ + ["42", 42], + ["true", true], + ['{"a":1}', { a: 1 }], + ["x86_64", "x86_64"], + ['"quoted"', "quoted"], + ])("%s parses to %j and displays back", (input, value) => { + expect(parseMetadataValue(input)).toEqual(value); + expect(parseMetadataValue(displayMetadataValue(value))).toEqual(value); + }); + + test("shows a string as it is, without JSON quotes", () => { + expect(displayMetadataValue("x86_64")).toBe("x86_64"); + }); +}); + +describe("matchCommits", () => { + function commit(id: string, summary: string): Commit { + const person = { name: "", email: "", time: 0 }; + return { + id, + summary, + message: summary, + author: person, + committer: person, + parents: [], + }; + } + const first = commit("abc123", "Add notes"); + const second = commit("def456", "Fix build"); + const pasted = commit("fff999", "Old change"); + + test.each([ + ["", [first, second]], + ["ABC", [first]], + [" fix ", [second]], + ["123", []], + ])("%j matches", (query, expected) => { + expect(matchCommits([first, second], query, undefined)).toEqual(expected); + }); + + test("offers a resolved commit outside the loaded history", () => { + expect(matchCommits([first, second], "fff999", pasted)).toEqual([pasted]); + }); + + test("keeps the matches when the resolved commit is among them", () => { + expect(matchCommits([first, second], "", first)).toEqual([first, second]); + }); +}); + +describe("releaseWarning", () => { + test("warns about a whole release by a non-delegate", () => { + expect(releaseWarning(them, delegates, "trusted")).toBe("release"); + expect(releaseWarning(them, delegates, "untrusted")).toBe("release"); + }); + + test("warns about others' artifacts on a delegate's release", () => { + expect(releaseWarning(delegate, delegates, "untrusted")).toBe("artifacts"); + }); + + test("does not warn about a delegate's release and artifacts", () => { + expect(releaseWarning(delegate, delegates, "trusted")).toBeUndefined(); + }); +}); diff --git a/desktop/tests/unit/repoImages.test.ts b/desktop/tests/unit/repoImages.test.ts new file mode 100644 index 000000000..1f9125fe9 --- /dev/null +++ b/desktop/tests/unit/repoImages.test.ts @@ -0,0 +1,387 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { + canonicalizeGithubImageUrl, + enhanceRepoImages, + imageMimeType, + RepoImages, + resolveRepoPath, +} from "@app/lib/repoImages"; + +describe("resolveRepoPath", () => { + test.each([ + ["notes.txt", "README.adoc", "notes.txt"], + ["./notes.txt", "README.adoc", "notes.txt"], + ["guide.adoc", "docs/index.adoc", "docs/guide.adoc"], + ["../README.adoc", "docs/index.adoc", "README.adoc"], + ["../../README.adoc", "docs/index.adoc", "README.adoc"], + ["../notes.txt", "README.adoc", "notes.txt"], + ["/src/main.rs", "docs/index.adoc", "src/main.rs"], + ["my%20notes.txt", "README.adoc", "my notes.txt"], + ["notes.txt?plain=1", "README.adoc", "notes.txt"], + ["notes.txt#usage", "README.adoc", "notes.txt"], + ["docs/", "README.adoc", "docs/"], + ["50%.png", "README.md", "50%.png"], + ["docs/100%25.png", "README.md", "docs/100%.png"], + ])("resolves %j from %j to %j", (href, from, expected) => { + expect(resolveRepoPath(href, from)).toBe(expected); + }); + + test.each([ + ["https://radicle.xyz", "README.adoc"], + ["//radicle.xyz/notes.txt", "README.adoc"], + ["mailto:alice@example.com", "README.adoc"], + ["javascript:alert(1)", "README.adoc"], + ["data:image/png;base64,AAAA", "README.md"], + ["blob:tauri://localhost/1234", "README.md"], + ["/", "README.adoc"], + ["..", "README.adoc"], + ])("leaves %j from %j outside the repository", (href, from) => { + expect(resolveRepoPath(href, from)).toBeUndefined(); + }); +}); + +describe("canonicalizeGithubImageUrl", () => { + test.each([ + [ + "https://github.com/radicle/app/blob/main/shot.png", + "https://github.com/radicle/app/blob/main/shot.png?raw=true", + ], + [ + "https://github.com/radicle/app/blob/main/shot.png?raw=true", + "https://github.com/radicle/app/blob/main/shot.png?raw=true", + ], + [ + "https://raw.githubusercontent.com/radicle/app/main/shot.png", + "https://raw.githubusercontent.com/radicle/app/main/shot.png", + ], + [ + "https://example.com/radicle/app/blob/main/shot.png", + "https://example.com/radicle/app/blob/main/shot.png", + ], + ["shot.png", "shot.png"], + ])("rewrites %j to %j", (input, expected) => { + expect(canonicalizeGithubImageUrl(input)).toBe(expected); + }); +}); + +describe("imageMimeType", () => { + test.each([ + ["logo.svg", "image/svg+xml"], + ["docs/LOGO.SVG", "image/svg+xml"], + ["shot.png", "image/png"], + ])("of %j is %j", (path, expected) => { + expect(imageMimeType(path)).toBe(expected); + }); + + test.each(["Makefile", "docs.v2/logo", "notes.txt", ""])( + "of %j is unknown", + path => { + expect(imageMimeType(path)).toBeUndefined(); + }, + ); +}); + +function u32(n: number): number[] { + return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]; +} + +function chunk(type: string, data: number[]): number[] { + const name = Array.from(type, c => c.charCodeAt(0)); + return [...u32(data.length), ...name, ...data, 0, 0, 0, 0]; +} + +// A PNG `width` pixels wide, saved at 144 DPI like a Retina screenshot. +function retinaPng(width: number): ArrayBuffer { + return new Uint8Array([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + ...chunk("IHDR", [...u32(width), ...u32(100), 8, 6, 0, 0, 0]), + ...chunk("pHYs", [...u32(5669), ...u32(5669), 1]), + ...chunk("IEND", []), + ]).buffer; +} + +let created: Blob[]; +let revoked: string[]; + +beforeEach(() => { + created = []; + revoked = []; + URL.createObjectURL = vi.fn((blob: Blob) => { + created.push(blob); + return `blob:test/${created.length}`; + }); + URL.revokeObjectURL = vi.fn((url: string) => { + revoked.push(url); + }); +}); + +function fakeFetch() { + return vi.fn(async (_rid: string, _sha: string, _path: string) => { + return new Uint8Array([1, 2, 3]).buffer; + }); +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("RepoImages", () => { + test("reads each file once", async () => { + const fetch = fakeFetch(); + const images = new RepoImages(fetch); + + const [first, second] = await Promise.all([ + images.load("rad:a", "sha1", "logo.png"), + images.load("rad:a", "sha1", "logo.png"), + ]); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(first).toEqual(second); + expect(created).toHaveLength(1); + }); + + test("reads the same path again at another revision", async () => { + const fetch = fakeFetch(); + const images = new RepoImages(fetch); + + await images.load("rad:a", "sha1", "logo.png"); + await images.load("rad:a", "sha2", "logo.png"); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + test("types an SVG so the webview shows it", async () => { + const images = new RepoImages(fakeFetch()); + + await images.load("rad:a", "sha1", "docs/logo.svg"); + + expect(created[0].type).toBe("image/svg+xml"); + }); + + test("sizes a Retina screenshot to its intended width", async () => { + const images = new RepoImages(async () => retinaPng(3680)); + + const image = await images.load("rad:a", "sha1", "shot.png"); + + expect(image?.width).toBe(1840); + }); + + test("sweeps the images not loaded since the last sweep", async () => { + const images = new RepoImages(fakeFetch()); + + const old = await images.load("rad:a", "sha1", "old.png"); + const kept = await images.load("rad:a", "sha1", "kept.png"); + images.sweep(); + await images.load("rad:a", "sha1", "kept.png"); + images.sweep(); + + expect(revoked).toEqual([old?.url]); + expect(revoked).not.toContain(kept?.url); + }); + + test("frees an image released before it loaded", async () => { + const pending = deferred(); + const images = new RepoImages(() => pending.promise); + + const image = images.load("rad:a", "sha1", "logo.png"); + images.sweep(); + images.sweep(); + pending.resolve(new Uint8Array([1]).buffer); + + expect(await image).toBeUndefined(); + expect(revoked).toEqual(["blob:test/1"]); + }); + + test("tries a failed file again", async () => { + const fetch = vi + .fn() + .mockRejectedValueOnce(new Error("not found")) + .mockResolvedValue(new Uint8Array([1]).buffer); + const images = new RepoImages(fetch); + + await expect(images.load("rad:a", "sha1", "logo.png")).rejects.toThrow(); + await expect( + images.load("rad:a", "sha1", "logo.png"), + ).resolves.toBeDefined(); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + test("frees every image when disposed, and those still loading", async () => { + const pending = deferred(); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Uint8Array([1]).buffer) + .mockReturnValueOnce(pending.promise); + const images = new RepoImages(fetch); + + const loaded = await images.load("rad:a", "sha1", "a.png"); + const loading = images.load("rad:a", "sha1", "b.png"); + images.dispose(); + pending.resolve(new Uint8Array([1]).buffer); + + expect(await loading).toBeUndefined(); + expect(revoked).toEqual([loaded?.url, "blob:test/2"]); + }); +}); + +function documentWith(html: string): HTMLElement { + const container = document.createElement("div"); + container.innerHTML = html; + return container; +} + +async function settled() { + await new Promise(resolve => setTimeout(resolve)); +} + +describe("enhanceRepoImages", () => { + test("shows repository images and rewrites GitHub page URLs", async () => { + const fetch = fakeFetch(); + const container = documentWith( + [ + '', + '', + '', + '', + ].join(""), + ); + + enhanceRepoImages( + container, + { rid: "rad:a", sha: "sha1", path: "docs/README.md" }, + new RepoImages(fetch), + ); + await settled(); + + const [logo, github, external, emoji] = container.querySelectorAll("img"); + expect(fetch).toHaveBeenCalledExactlyOnceWith( + "rad:a", + "sha1", + "images/logo.svg", + ); + expect(logo.getAttribute("src")).toBe("blob:test/1"); + expect(github.getAttribute("src")).toBe( + "https://github.com/a/b/blob/main/c.png?raw=true", + ); + expect(external.getAttribute("src")).toBe("https://example.com/d.png"); + expect(emoji.getAttribute("src")).toBe("twemoji/1f600.svg"); + }); + + test("leaves repository images alone without a revision", async () => { + const fetch = fakeFetch(); + const container = documentWith(''); + + enhanceRepoImages( + container, + { rid: "", sha: undefined, path: "" }, + new RepoImages(fetch), + ); + await settled(); + + expect(fetch).not.toHaveBeenCalled(); + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "logo.png", + ); + }); + + test("a stray percent sign doesn't stop the other images", async () => { + const fetch = fakeFetch(); + const container = documentWith(''); + + enhanceRepoImages( + container, + { rid: "rad:a", sha: "sha1", path: "README.md" }, + new RepoImages(fetch), + ); + await settled(); + + expect(fetch.mock.calls.map(([, , path]) => path)).toEqual([ + "50%.png", + "logo.png", + ]); + }); + + test("reads the images again when the revision changes", async () => { + const fetch = fakeFetch(); + const images = new RepoImages(fetch); + const container = documentWith(''); + + enhanceRepoImages( + container, + { rid: "rad:a", sha: "sha1", path: "README.md" }, + images, + ); + await settled(); + enhanceRepoImages( + container, + { rid: "rad:a", sha: "sha2", path: "README.md" }, + images, + ); + await settled(); + + expect(fetch.mock.calls).toEqual([ + ["rad:a", "sha1", "logo.png"], + ["rad:a", "sha2", "logo.png"], + ]); + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "blob:test/2", + ); + expect(revoked).toEqual(["blob:test/1"]); + }); + + test("an older revision loading late doesn't replace the newer one", async () => { + const first = deferred(); + const fetch = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockResolvedValue(new Uint8Array([2]).buffer); + const images = new RepoImages(fetch); + const container = documentWith(''); + + enhanceRepoImages( + container, + { rid: "rad:a", sha: "sha1", path: "README.md" }, + images, + ); + enhanceRepoImages( + container, + { rid: "rad:a", sha: "sha2", path: "README.md" }, + images, + ); + await settled(); + first.resolve(new Uint8Array([1]).buffer); + await settled(); + + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "blob:test/1", + ); + expect(revoked).toEqual(["blob:test/2"]); + }); + + test("shows a Retina screenshot at its intended width", async () => { + const container = documentWith(''); + + enhanceRepoImages( + container, + { rid: "rad:a", sha: "sha1", path: "README.md" }, + new RepoImages(async () => retinaPng(3680)), + ); + await settled(); + + expect(container.querySelector("img")?.style.width).toBe("1840px"); + }); +}); diff --git a/desktop/tests/unit/repoOrdering.test.ts b/desktop/tests/unit/repoOrdering.test.ts new file mode 100644 index 000000000..d1bb39223 --- /dev/null +++ b/desktop/tests/unit/repoOrdering.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "vitest"; + +import { + inboxRepoOrder, + pinnedFirst, + pinnedIn, + togglePinned, +} from "@app/lib/repoOrdering"; + +type Repo = { rid: string; name: string }; +const repo = (rid: string, name = rid): Repo => ({ rid, name }); +const rids = (repos: Repo[]) => repos.map(r => r.rid); +const ridOf = (r: Repo) => r.rid; + +describe("togglePinned", () => { + test("pins at the front", () => { + expect(togglePinned(["a"], "b")).toEqual(["b", "a"]); + }); + + test("unpins", () => { + expect(togglePinned(["a", "b", "c"], "b")).toEqual(["a", "c"]); + }); + + test("doesn't change the given list", () => { + const list = ["a"]; + togglePinned(list, "b"); + + expect(list).toEqual(["a"]); + }); +}); + +describe("pinned ordering", () => { + const repos = [repo("a"), repo("b"), repo("c"), repo("d")]; + + test("pinnedIn lists present pins in pin order", () => { + expect(rids(pinnedIn(repos, ["c", "gone", "a"], ridOf))).toEqual([ + "c", + "a", + ]); + }); + + test("pinnedFirst puts pins first and keeps the rest in order", () => { + expect(rids(pinnedFirst(repos, ["c", "a"], ridOf))).toEqual([ + "c", + "a", + "b", + "d", + ]); + }); +}); + +test("inboxRepoOrder lists pinned, then the rest by name, then hidden by name", () => { + const repos = [ + repo("1", "zebra"), + repo("3", "mango"), + repo("2", "apple"), + repo("4", "banana"), + repo("6", "yak"), + repo("5", "cherry"), + ]; + + expect( + rids(inboxRepoOrder(repos, ["3"], ["6", "5"], ridOf, r => r.name)), + ).toEqual(["3", "2", "4", "1", "5", "6"]); +}); diff --git a/desktop/tests/unit/repoRouter.test.ts b/desktop/tests/unit/repoRouter.test.ts new file mode 100644 index 000000000..cbe24385a --- /dev/null +++ b/desktop/tests/unit/repoRouter.test.ts @@ -0,0 +1,225 @@ +import type { RepoRoute } from "@app/views/repo/router"; + +import { repoRouteToPath, repoUrlToRoute } from "@app/views/repo/router"; +import { describe, expect, test } from "vitest"; + +const rid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe"; +const peer = "z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK"; +const sha = "a".repeat(40); + +function parse(path: string): RepoRoute | null { + const url = new URL(path, "http://localhost"); + const segments = url.pathname.substring(1).split("/"); + expect(segments.shift()).toBe("repos"); + return repoUrlToRoute(segments, url.searchParams); +} + +describe("repoRouteToPath and repoUrlToRoute round trip", () => { + test.each<[string, RepoRoute]>([ + ["/repos/RID/home", { resource: "repo.home", rid }], + ["/repos/RID/home/remotes/PEER", { resource: "repo.home", rid, peer }], + [ + "/repos/RID/home/feature/branch", + { resource: "repo.home", rid, revision: "feature/branch" }, + ], + [ + "/repos/RID/home/remotes/PEER/feature/branch", + { resource: "repo.home", rid, peer, revision: "feature/branch" }, + ], + [ + "/repos/RID/home/main?path=src%2Flib.rs", + { resource: "repo.home", rid, revision: "main", path: "src/lib.rs" }, + ], + ["/repos/RID/commits", { resource: "repo.commits", rid }], + [ + "/repos/RID/commits/main", + { resource: "repo.commits", rid, revision: "main" }, + ], + [ + "/repos/RID/commits/remotes/PEER/SHA", + { resource: "repo.commits", rid, peer, revision: sha }, + ], + ["/repos/RID/commits/SHA", { resource: "repo.commit", rid, commit: sha }], + ["/repos/RID/identity", { resource: "repo.identity", rid }], + [ + "/repos/RID/issues?status=all", + { resource: "repo.issues", rid, status: "all" }, + ], + [ + "/repos/RID/issues?status=closed", + { resource: "repo.issues", rid, status: "closed" }, + ], + [ + "/repos/RID/issues/abc?status=open", + { resource: "repo.issue", rid, issue: "abc", status: "open" }, + ], + [ + "/repos/RID/patches", + { resource: "repo.patches", rid, status: undefined }, + ], + [ + "/repos/RID/patches?status=merged", + { resource: "repo.patches", rid, status: "merged" }, + ], + [ + "/repos/RID/patches/abc", + { + resource: "repo.patch", + rid, + patch: "abc", + status: undefined, + reviewId: undefined, + }, + ], + [ + "/repos/RID/patches/abc?status=draft&review=def&view=changes", + { + resource: "repo.patch", + rid, + patch: "abc", + status: "draft", + reviewId: "def", + view: "changes", + }, + ], + [ + "/repos/RID/releases", + { resource: "repo.releases", rid, scope: undefined }, + ], + [ + "/repos/RID/releases?scope=untrusted", + { resource: "repo.releases", rid, scope: "untrusted" }, + ], + [ + "/repos/RID/releases?scope=trusted", + { resource: "repo.releases", rid, scope: "trusted" }, + ], + [ + "/repos/RID/releases/abc", + { + resource: "repo.release", + rid, + release: "abc", + scope: undefined, + artifactScope: undefined, + }, + ], + [ + "/repos/RID/releases/abc?scope=trusted", + { + resource: "repo.release", + rid, + release: "abc", + scope: "trusted", + artifactScope: undefined, + }, + ], + [ + "/repos/RID/releases/abc?scope=untrusted&artifacts=untrusted", + { + resource: "repo.release", + rid, + release: "abc", + scope: "untrusted", + artifactScope: "untrusted", + }, + ], + ])("%s", (template, route) => { + const path = template + .replace("RID", rid) + .replace("PEER", peer) + .replace("SHA", sha); + + expect(repoRouteToPath(route)).toBe(path); + expect(parse(path)).toEqual(route); + }); +}); + +describe("repoUrlToRoute", () => { + test("defaults issues listing status to all", () => { + expect(parse(`/repos/${rid}/issues`)).toEqual({ + resource: "repo.issues", + rid, + status: "all", + }); + }); + + test("falls back to all for an unknown issues listing status", () => { + expect(parse(`/repos/${rid}/issues?status=bogus`)).toEqual({ + resource: "repo.issues", + rid, + status: "all", + }); + }); + + test("defaults single issue status to all", () => { + expect(parse(`/repos/${rid}/issues/abc`)).toEqual({ + resource: "repo.issue", + rid, + issue: "abc", + status: "all", + }); + }); + + test("does not route issue creation", () => { + expect(parse(`/repos/${rid}/issues/create`)).toBeNull(); + }); + + test("ignores an unknown patch view", () => { + expect(parse(`/repos/${rid}/patches/abc?view=bogus`)).toEqual({ + resource: "repo.patch", + rid, + patch: "abc", + }); + }); + + test("leaves the trusted artifact scope out of the URL", () => { + expect( + repoRouteToPath({ + resource: "repo.release", + rid, + release: "abc", + scope: undefined, + artifactScope: "trusted", + }), + ).toBe(`/repos/${rid}/releases/abc`); + }); + + test("ignores an unknown release scope", () => { + expect( + parse(`/repos/${rid}/releases/abc?scope=bogus&artifacts=bogus`), + ).toEqual({ + resource: "repo.release", + rid, + release: "abc", + scope: undefined, + artifactScope: undefined, + }); + }); + + test("treats a sha as a revision when a peer is given", () => { + expect(parse(`/repos/${rid}/commits/remotes/${peer}/${sha}`)).toEqual({ + resource: "repo.commits", + rid, + peer, + revision: sha, + }); + }); + + test("treats a commits revision that is a sha as a single commit", () => { + const route: RepoRoute = { resource: "repo.commits", rid, revision: sha }; + + expect(parse(repoRouteToPath(route))).toEqual({ + resource: "repo.commit", + rid, + commit: sha, + }); + }); + + test.each([`/repos/${rid}`, `/repos/${rid}/bogus`, "/repos/"])( + "returns null for %s", + path => { + expect(parse(path)).toBeNull(); + }, + ); +}); diff --git a/desktop/tests/unit/reviewSummary.test.ts b/desktop/tests/unit/reviewSummary.test.ts new file mode 100644 index 000000000..067449819 --- /dev/null +++ b/desktop/tests/unit/reviewSummary.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "vitest"; + +import type { ReviewEntry } from "@app/lib/reviewSummary"; +import { + entriesFromReviewers, + entriesFromRevisions, + isOutdatedReview, + reviewSummary, + summaryTitle, +} from "@app/lib/reviewSummary"; + +import * as cobs from "./support/cobs"; + +function review( + alias: string, + revisionNumber: number, + verdict?: ReviewEntry["verdict"], + delegate = false, +): ReviewEntry { + return { + author: { did: `did:key:${alias}`, alias }, + verdict, + revisionNumber, + delegate, + }; +} + +describe("reviewSummary", () => { + test("a verdict on the current revision keeps its colour", () => { + const state = reviewSummary([review("alice", 2, "accept")], 2); + expect(state.outdated).toBe(false); + expect(state.allAccept).toBe(true); + }); + + test("an accept left behind by a newer revision is outdated, not approved", () => { + const state = reviewSummary([review("alice", 1, "accept")], 3); + expect(state.outdated).toBe(true); + // The whole point: a stale accept must not read as a current approval. + expect(state.allAccept).toBe(false); + }); + + test("a rejection is likewise suppressed once superseded", () => { + const state = reviewSummary([review("alice", 1, "reject")], 2); + expect(state.outdated).toBe(true); + expect(state.hasReject).toBe(false); + }); + + test("one review covering the head keeps the summary current", () => { + const state = reviewSummary( + [review("alice", 1, "accept"), review("bob", 3, "accept")], + 3, + ); + expect(state.outdated).toBe(false); + expect(state.allAccept).toBe(true); + }); + + test("a single non-accept verdict blocks the accepted state", () => { + const state = reviewSummary( + [review("alice", 2, "accept"), review("bob", 2)], + 2, + ); + expect(state.allAccept).toBe(false); + expect(state.hasReject).toBe(false); + }); + + test("counts reviews but lists people once", () => { + const state = reviewSummary( + [ + review("alice", 1, "accept"), + review("alice", 2, "accept"), + review("bob", 2, "accept"), + ], + 2, + ); + expect(state.reviews).toHaveLength(3); + expect(state.authors).toHaveLength(2); + }); + + test("delegates come first so they survive a truncated avatar stack", () => { + const state = reviewSummary( + [review("alice", 1), review("bob", 1, undefined, true)], + 1, + ); + expect(state.authors.map(a => a.alias)).toEqual(["bob", "alice"]); + expect(state.delegateCount).toBe(1); + }); + + test("groups an author's reviews together, newest revision first", () => { + const state = reviewSummary( + [review("alice", 1), review("bob", 2), review("alice", 3)], + 3, + ); + expect( + state.reviews.map(r => `${r.author.alias}${r.revisionNumber}`), + ).toEqual(["alice3", "alice1", "bob2"]); + }); + + test("names the newest reviewed revision for the outdated hint", () => { + const state = reviewSummary( + [review("alice", 1, "accept"), review("bob", 2, "accept")], + 5, + ); + expect(state.outdated).toBe(true); + // The pill renders this as "· r2" — the latest anyone looked at, not the + // oldest and not the patch's current revision. + expect(state.latestReviewedRevision).toBe(2); + }); + + test("no reviews is not outdated", () => { + const state = reviewSummary([], 3); + expect(state.outdated).toBe(false); + expect(state.reviews).toHaveLength(0); + }); + + test("an unknown revision count leaves the summary alone", () => { + // The revision picker renders one revision's own reviews and passes no + // count, since "outdated" has no meaning there. + const state = reviewSummary([review("alice", 1, "accept")], 0); + expect(state.outdated).toBe(false); + expect(state.allAccept).toBe(true); + }); +}); + +describe("entries", () => { + test("entriesFromRevisions numbers reviews by revision and marks delegates", () => { + const alice = cobs.author("alice"); + const bob = cobs.author("bob"); + const entries = entriesFromRevisions( + [ + cobs.revision({ + id: "rev1", + reviews: [cobs.review({ id: "a", author: alice, verdict: "accept" })], + }), + cobs.revision({ id: "rev2" }), + cobs.revision({ + id: "rev3", + reviews: [cobs.review({ id: "b", author: bob, verdict: "reject" })], + }), + ], + [bob.did], + ); + + expect(entries).toEqual([ + { + author: alice, + verdict: "accept", + revisionNumber: 1, + delegate: false, + reviewId: "a", + }, + { + author: bob, + verdict: "reject", + revisionNumber: 3, + delegate: true, + reviewId: "b", + }, + ]); + }); + + test("entriesFromReviewers keeps the reviewer fields, without a review id", () => { + const reviewer = { + author: cobs.author("alice"), + verdict: "accept" as const, + revisionNumber: 2, + delegate: true, + }; + + expect(entriesFromReviewers([reviewer])).toEqual([reviewer]); + }); +}); + +test.each([ + [2, 3, true], + [3, 3, false], + [1, 0, false], +])( + "isOutdatedReview for revision %j of %j is %j", + (revisionNumber, revisionCount, expected) => { + expect( + isOutdatedReview(review("alice", revisionNumber), revisionCount), + ).toBe(expected); + }, +); + +describe("summaryTitle", () => { + test("counts reviews and reviewers, singular and plural", () => { + expect(summaryTitle(reviewSummary([review("alice", 1)], 1))).toBe( + "1 review by 1 reviewer", + ); + expect( + summaryTitle( + reviewSummary( + [review("alice", 1), review("alice", 2), review("bob", 2)], + 2, + ), + ), + ).toBe("3 reviews by 2 reviewers"); + }); + + test("mentions delegates", () => { + expect( + summaryTitle(reviewSummary([review("alice", 1, undefined, true)], 1)), + ).toBe("1 review by 1 reviewer, 1 a delegate"); + }); + + test("warns when the reviews are outdated", () => { + expect(summaryTitle(reviewSummary([review("alice", 2)], 4))).toBe( + "1 review by 1 reviewer — newest is of revision 2, so these may no longer apply", + ); + }); +}); diff --git a/desktop/tests/unit/revisionDescription.test.ts b/desktop/tests/unit/revisionDescription.test.ts new file mode 100644 index 000000000..fd000aad8 --- /dev/null +++ b/desktop/tests/unit/revisionDescription.test.ts @@ -0,0 +1,144 @@ +import type { Commit } from "@bindings/repo/Commit"; + +import { describe, expect, test } from "vitest"; + +import { + groupCommitsByAuthor, + isCommitListDescription, + replaceDescriptionBody, + revisionTitle, + splitDescription, + visibleCommitsOf, +} from "@app/lib/revisionDescription"; + +import { revision } from "./support/cobs"; + +function commit(id: string, summary = id, name = "Alice"): Commit { + const person = { name, email: `${name}@x`, time: 0 }; + return { + id, + author: person, + committer: person, + summary, + message: summary, + parents: [], + }; +} + +describe("splitDescription", () => { + test.each([ + ["", {}], + [" ", {}], + ["Subject", { subject: "Subject" }], + [" Subject \n\n Body text ", { subject: "Subject", body: "Body text" }], + ["Subject\n", { subject: "Subject" }], + ])("splits %j into %j", (text, expected) => { + expect(splitDescription(text)).toEqual(expected); + }); +}); + +describe("replaceDescriptionBody", () => { + test.each([ + ["Subject\n\nold", "Subject\n\nnew"], + ["Subject\nold", "Subject\nnew"], + ["Subject\r\n\r\nold\r\n", "Subject\r\n\r\nnew\r\n"], + [" Subject \n\n old \n", " Subject \n\n new \n"], + ])("keeps the subject and spacing of %j", (description, expected) => { + expect(replaceDescriptionBody(description, "new")).toBe(expected); + }); + + test("replaces the body even when the subject contains its text", () => { + expect(replaceDescriptionBody("Fix old\n\nold", "new")).toBe( + "Fix old\n\nnew", + ); + }); + + test("adds a body to a description that has none", () => { + expect(replaceDescriptionBody("Subject", "new")).toBe("Subject\n\nnew"); + }); + + test("leaves an empty description as just the body", () => { + expect(replaceDescriptionBody("", "new")).toBe("new"); + }); +}); + +test("revisionTitle is the first line of the current description", () => { + const edit = (body: string) => ({ + author: revision().author, + timestamp: 0, + body, + }); + + expect( + revisionTitle(revision({ description: [edit("Old"), edit("New\nbody")] })), + ).toBe("New"); + expect(revisionTitle(revision({ description: [] }))).toBeUndefined(); + expect( + revisionTitle(revision({ description: [edit(" \n ")] })), + ).toBeUndefined(); +}); + +describe("isCommitListDescription", () => { + const commits = [commit("a", "Add a"), commit("b", "Fix b")]; + + test("matches a description that lists the commit summaries", () => { + expect(isCommitListDescription("Add a\nFix b", commits)).toBe(true); + expect(isCommitListDescription(" Fix b\n\nAdd a ", commits)).toBe(true); + }); + + test.each([ + ["Add a", "a line is missing"], + ["Add a\nFix b\nMore", "there is an extra line"], + ["Add a\nSomething else", "a line isn't a summary"], + ])("doesn't match %j because %s", description => { + expect(isCommitListDescription(description, commits)).toBe(false); + }); + + test("doesn't match without commits", () => { + expect(isCommitListDescription("", [])).toBe(false); + expect(isCommitListDescription("Add a", undefined)).toBe(false); + }); +}); + +test("groupCommitsByAuthor groups consecutive commits by author name", () => { + const groups = groupCommitsByAuthor([ + commit("1", "1", "Alice"), + commit("2", "2", "Alice"), + commit("3", "3", "Bob"), + commit("4", "4", "Alice"), + ]); + + expect(groups.map(g => g.map(c => c.id))).toEqual([["1", "2"], ["3"], ["4"]]); +}); + +describe("visibleCommitsOf", () => { + const group = (n: number) => + Array.from({ length: n }, (_, i) => commit(`${i}`)); + + test("shows a short group in full", () => { + expect(visibleCommitsOf(group(5), false)).toMatchObject({ + collapsed: false, + hiddenCount: 0, + }); + expect(visibleCommitsOf(group(5), false).visible).toHaveLength(5); + }); + + test("collapses a long group to its first three", () => { + const { collapsed, visible, hiddenCount } = visibleCommitsOf( + group(6), + false, + ); + + expect(collapsed).toBe(true); + expect(visible.map(c => c.id)).toEqual(["0", "1", "2"]); + expect(hiddenCount).toBe(3); + }); + + test("shows a long group in full once expanded", () => { + expect(visibleCommitsOf(group(6), true)).toMatchObject({ + collapsed: false, + hiddenCount: 0, + }); + expect(visibleCommitsOf(group(6), true).visible).toHaveLength(6); + }); +}); diff --git a/desktop/tests/unit/revisionList.test.ts b/desktop/tests/unit/revisionList.test.ts new file mode 100644 index 000000000..611f5ddbe --- /dev/null +++ b/desktop/tests/unit/revisionList.test.ts @@ -0,0 +1,121 @@ +import type { Commit } from "@bindings/repo/Commit"; + +import { describe, expect, test } from "vitest"; + +import { + newCommitsByRevision, + orderRevisions, + revisionNumbers, +} from "@app/lib/revisionList"; +import type { RevisionListSettings } from "@app/lib/revisionListSettings"; + +import { author, revision } from "./support/cobs"; + +const alice = author("alice"); +const bob = author("bob"); +const carol = author("carol"); + +// Timeline order: bob and carol both push before the patch author's second +// revision, so grouping has to move revisions around. +const revisions = [ + revision({ id: "a1", author: alice }), + revision({ id: "b1", author: bob }), + revision({ id: "c1", author: carol }), + revision({ id: "a2", author: alice }), + revision({ id: "b2", author: bob }), +]; + +function settings(props: Partial): RevisionListSettings { + return { + sortDesc: false, + groupByAuthor: false, + showNumber: false, + showStats: false, + showReviewers: true, + ...props, + }; +} + +function ids(ordered: { id: string }[]) { + return ordered.map(r => r.id); +} + +test("revisionNumbers numbers revisions by timeline position from 1", () => { + expect(revisionNumbers(revisions)).toEqual({ + a1: 1, + b1: 2, + c1: 3, + a2: 4, + b2: 5, + }); +}); + +describe("orderRevisions", () => { + test("keeps the timeline order by default", () => { + expect(ids(orderRevisions(revisions, alice.did, settings({})))).toEqual([ + "a1", + "b1", + "c1", + "a2", + "b2", + ]); + }); + + test("reverses the timeline when sorting descending", () => { + expect( + ids(orderRevisions(revisions, alice.did, settings({ sortDesc: true }))), + ).toEqual(["b2", "a2", "c1", "b1", "a1"]); + }); + + test("groups by author, patch author first, others by first appearance", () => { + expect( + ids( + orderRevisions(revisions, carol.did, settings({ groupByAuthor: true })), + ), + ).toEqual(["c1", "a1", "a2", "b1", "b2"]); + }); + + test("sorting descending reverses within groups, not the group order", () => { + expect( + ids( + orderRevisions( + revisions, + alice.did, + settings({ groupByAuthor: true, sortDesc: true }), + ), + ), + ).toEqual(["a2", "a1", "b2", "b1", "c1"]); + }); + + test("does not reorder the caller's array", () => { + const input = [...revisions]; + orderRevisions(input, alice.did, settings({ sortDesc: true })); + orderRevisions( + input, + alice.did, + settings({ groupByAuthor: true, sortDesc: true }), + ); + + expect(ids(input)).toEqual(ids(revisions)); + }); +}); + +test("newCommitsByRevision keeps only commits not in an earlier revision", () => { + const c = (id: string) => ({ id }) as Commit; + const commits: Record = { + a1: [c("1"), c("2")], + b1: [c("1"), c("2"), c("3")], + c1: [c("2"), c("4"), c("3"), c("5")], + }; + + const added = newCommitsByRevision( + [{ id: "a1" }, { id: "b1" }, { id: "c1" }], + id => commits[id], + ); + + expect( + Object.fromEntries( + Object.entries(added).map(([id, list]) => [id, list.map(x => x.id)]), + ), + ).toEqual({ a1: ["1", "2"], b1: ["3"], c1: ["4", "5"] }); +}); diff --git a/desktop/tests/unit/revisionListSettings.test.ts b/desktop/tests/unit/revisionListSettings.test.ts new file mode 100644 index 000000000..1896a0933 --- /dev/null +++ b/desktop/tests/unit/revisionListSettings.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +const key = "revisionListSettings"; + +const defaults = { + sortDesc: false, + groupByAuthor: false, + showNumber: false, + showStats: false, + showReviewers: true, +}; + +async function load() { + const { revisionListSettings } = + await import("@app/lib/revisionListSettings"); + return revisionListSettings; +} + +beforeEach(() => { + localStorage.clear(); + vi.resetModules(); + vi.spyOn(console, "error").mockReturnValue(undefined); +}); + +test("starts with the defaults when nothing is stored", async () => { + expect((await load()).value).toEqual(defaults); +}); + +test.each(Object.entries(defaults))( + "fills in the defaults around a stored %s", + async (setting, value) => { + localStorage.setItem(key, JSON.stringify({ [setting]: !value })); + + expect((await load()).value).toEqual({ ...defaults, [setting]: !value }); + }, +); + +test("falls back to the defaults for an invalid stored value", async () => { + localStorage.setItem(key, JSON.stringify({ sortDesc: "yes" })); + + expect((await load()).value).toEqual(defaults); +}); + +test("toggle flips one setting and persists it", async () => { + const settings = await load(); + settings.toggle("showReviewers"); + settings.toggle("groupByAuthor"); + + const expected = { ...defaults, showReviewers: false, groupByAuthor: true }; + expect(settings.value).toEqual(expected); + expect(JSON.parse(localStorage.getItem(key) ?? "")).toEqual(expected); + + settings.toggle("groupByAuthor"); + + expect(settings.value).toEqual({ ...defaults, showReviewers: false }); +}); diff --git a/desktop/tests/unit/roles.test.ts b/desktop/tests/unit/roles.test.ts new file mode 100644 index 000000000..b870f0daa --- /dev/null +++ b/desktop/tests/unit/roles.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "vitest"; + +import { + canResolveReviewComment, + isDelegate, + isDelegateOrAuthor, +} from "@app/lib/roles"; + +const me = "z6Mkme"; +const delegates = ["did:key:z6Mkdelegate", "did:key:z6Mkother"]; + +describe("isDelegate", () => { + test("matches a delegate's public key", () => { + expect(isDelegate("z6Mkdelegate", delegates)).toBe(true); + }); + + test.each([[me], [undefined]])("is undefined for %j", key => { + expect(isDelegate(key, delegates)).toBeUndefined(); + }); +}); + +describe("isDelegateOrAuthor", () => { + test("allows delegates and the author", () => { + expect(isDelegateOrAuthor("z6Mkdelegate", delegates, "did:key:x")).toBe( + true, + ); + expect(isDelegateOrAuthor(me, delegates, `did:key:${me}`)).toBe(true); + }); + + test("is undefined for anyone else", () => { + expect(isDelegateOrAuthor(me, delegates, "did:key:x")).toBeUndefined(); + expect( + isDelegateOrAuthor(undefined, delegates, "did:key:x"), + ).toBeUndefined(); + }); +}); + +describe("canResolveReviewComment", () => { + const nobody = { comment: undefined, review: undefined, revision: undefined }; + + test("a delegate can resolve any review comment", () => { + expect(canResolveReviewComment("z6Mkdelegate", delegates, nobody)).toBe( + true, + ); + }); + + test.each(["comment", "review", "revision"] as const)( + "the %s author can resolve it", + role => { + expect( + canResolveReviewComment(me, delegates, { + ...nobody, + [role]: `did:key:${me}`, + }), + ).toBe(true); + }, + ); + + test("nobody else can", () => { + expect( + canResolveReviewComment(me, delegates, { + comment: "did:key:a", + review: "did:key:b", + revision: "did:key:c", + }), + ).toBe(false); + }); +}); diff --git a/desktop/tests/unit/router.test.ts b/desktop/tests/unit/router.test.ts new file mode 100644 index 000000000..608e98bcd --- /dev/null +++ b/desktop/tests/unit/router.test.ts @@ -0,0 +1,148 @@ +import { get } from "svelte/store"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import type { Route } from "@app/lib/router"; +import { + activeUnloadedRouteStore, + loadFromLocation, + push, + routeToPath, +} from "@app/lib/router"; + +const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock("@app/lib/invoke", () => ({ invoke })); +vi.mock("@app/lib/router/definitions", () => ({ + loadRoute: async (route: Route) => route, +})); + +const rid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe"; +const sha = "a".repeat(40); + +async function resolve(path: string) { + window.history.pushState(null, "", path); + await loadFromLocation(); + return get(activeUnloadedRouteStore); +} + +beforeEach(() => { + vi.stubGlobal("origin", window.location.origin); + vi.spyOn(console, "error").mockReturnValue(undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("routeToPath and loadFromLocation round trip", () => { + test.each<[string, Route]>([ + ["/inbox", { resource: "inbox" }], + ["/guide", { resource: "guide" }], + [`/repos/${rid}/home`, { resource: "repo.home", rid }], + [ + `/repos/${rid}/commits/main`, + { resource: "repo.commits", rid, revision: "main" }, + ], + [ + `/repos/${rid}/commits/${sha}`, + { resource: "repo.commit", rid, commit: sha }, + ], + [`/repos/${rid}/identity`, { resource: "repo.identity", rid }], + [ + `/repos/${rid}/issues?status=closed`, + { resource: "repo.issues", rid, status: "closed" }, + ], + [ + `/repos/${rid}/issues/abc?status=open`, + { resource: "repo.issue", rid, issue: "abc", status: "open" }, + ], + [ + `/repos/${rid}/patches?status=merged`, + { resource: "repo.patches", rid, status: "merged" }, + ], + [ + `/repos/${rid}/patches/abc?status=draft`, + { + resource: "repo.patch", + rid, + patch: "abc", + status: "draft", + reviewId: undefined, + }, + ], + ])("%s", async (path, route) => { + expect(routeToPath(route)).toBe(path); + expect(await resolve(path)).toEqual(route); + expect(console.error).not.toHaveBeenCalled(); + }); +}); + +test("booting has no path", () => { + expect(routeToPath({ resource: "booting" })).toBe(""); +}); + +test("resolves the root to the inbox", async () => { + await resolve("/guide"); + + expect(await resolve("/")).toEqual({ resource: "inbox" }); + expect(console.error).not.toHaveBeenCalled(); +}); + +test.each([ + "/bogus", + "/inbox/extra", + "/guide/extra", + "/repos/", + `/repos/${rid}/bogus`, +])("falls back to the inbox for %s", async path => { + await resolve("/guide"); + + expect(await resolve(path)).toEqual({ resource: "inbox" }); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Could not resolve route"), + ); +}); + +describe("clicks on app links", () => { + function clickLink(href: string, handle?: (event: Event) => void) { + const anchor = document.createElement("a"); + anchor.setAttribute("href", href); + if (handle) { + anchor.addEventListener("click", handle); + } + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + } + + test("navigate to the route", async () => { + await resolve("/guide"); + + clickLink("/inbox"); + await vi.waitFor(() => + expect(get(activeUnloadedRouteStore)).toEqual({ resource: "inbox" }), + ); + }); + + test("leave links that handled their own click alone", async () => { + await resolve("/guide"); + + clickLink("/inbox", event => event.preventDefault()); + expect(get(activeUnloadedRouteStore)).toEqual({ resource: "guide" }); + expect(window.location.pathname).toBe("/guide"); + }); +}); + +test("push a route with a fragment to scroll to", async () => { + await resolve("/guide"); + + await push({ resource: "repo.home", rid, path: "docs/guide.md" }, "café"); + expect(get(activeUnloadedRouteStore)).toEqual({ + resource: "repo.home", + rid, + path: "docs/guide.md", + }); + expect(decodeURIComponent(window.location.hash)).toBe("#café"); + + await push({ resource: "guide" }); + expect(window.location.hash).toBe(""); +}); diff --git a/desktop/tests/unit/seedRepo.test.ts b/desktop/tests/unit/seedRepo.test.ts new file mode 100644 index 000000000..e3f6c5274 --- /dev/null +++ b/desktop/tests/unit/seedRepo.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "vitest"; + +import { seedTarget } from "@app/lib/seedRepo"; + +const rid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe"; +const bare = "z3fpY7nttPPa6MBnAv2DccHzQJnqe"; + +describe("seedTarget", () => { + test.each([rid, bare, ` ${rid} `])("accepts %j as %j", input => { + expect(seedTarget(input, [], [])).toEqual({ rid }); + }); + + test.each(["", "rad:", "rad:z0OIl", "not a rid", "rad:zabc"])( + "rejects %j", + input => { + expect(seedTarget(input, [], [])).toEqual({ error: "RID is not valid" }); + }, + ); + + test.each([rid, bare])("reports %j as already queued", input => { + expect(seedTarget(input, [rid], [])).toEqual({ + error: "This repo is already queued for fetching", + }); + }); + + test.each([rid, bare])("reports %j as already seeded", input => { + expect(seedTarget(input, [], [rid])).toEqual({ + error: "This repo is already seeded", + }); + }); +}); diff --git a/desktop/tests/unit/shareAction.test.ts b/desktop/tests/unit/shareAction.test.ts new file mode 100644 index 000000000..b89940e56 --- /dev/null +++ b/desktop/tests/unit/shareAction.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +let flushSync: () => void; + +async function load() { + vi.resetModules(); + const { shareAction } = await import("@app/lib/shareAction.svelte"); + ({ flushSync } = await import("svelte")); + flushSync(); + return shareAction; +} + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test("defaults to copying the link", async () => { + expect((await load()).value).toBe("copyLink"); +}); + +test.each([ + ["copyId", "copyId"], + ["open", "open"], + ["share", "copyLink"], + ['"open"', "copyLink"], +])("reads a stored %j as %j", async (stored, expected) => { + localStorage.setItem("shareAction", stored); + + expect((await load()).value).toBe(expected); +}); + +test("persists a change", async () => { + const shareAction = await load(); + + shareAction.value = "open"; + flushSync(); + + expect(localStorage.getItem("shareAction")).toBe("open"); + expect((await load()).value).toBe("open"); +}); + +test("works without localStorage", async () => { + vi.stubGlobal("localStorage", undefined); + const shareAction = await load(); + + shareAction.value = "open"; + flushSync(); + + expect(shareAction.value).toBe("open"); +}); diff --git a/desktop/tests/unit/shortcuts.svelte.test.ts b/desktop/tests/unit/shortcuts.svelte.test.ts new file mode 100644 index 000000000..240df2ccc --- /dev/null +++ b/desktop/tests/unit/shortcuts.svelte.test.ts @@ -0,0 +1,339 @@ +import { flushSync } from "svelte"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import * as modal from "@app/lib/modal"; +import type { Binding, ShortcutId } from "@app/lib/shortcuts.svelte"; +import { + ariaKeyShortcuts, + comboKeys, + listenForShortcuts, + matchesCombo, + matchesShortcut, + modifierHeld, + useShortcuts, +} from "@app/lib/shortcuts.svelte"; + +const platform = vi.hoisted(() => ({ mac: false })); +vi.mock("@app/lib/utils", async importOriginal => ({ + ...(await importOriginal()), + isMac: () => platform.mac, + modifierKey: () => (platform.mac ? "⌘" : "ctrl"), +})); + +function key(init: KeyboardEventInit): KeyboardEvent { + return new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ...init, + }); +} + +afterEach(() => { + platform.mac = false; +}); + +describe("matchesCombo", () => { + test.each<[boolean, string, KeyboardEventInit, boolean]>([ + [false, "Mod+F", { key: "f", ctrlKey: true }, true], + [false, "Mod+F", { key: "F", ctrlKey: true }, true], + [false, "Mod+F", { key: "f", metaKey: true }, false], + [false, "Mod+F", { key: "f", ctrlKey: true, metaKey: true }, false], + [false, "Mod+F", { key: "f", ctrlKey: true, altKey: true }, false], + [false, "Mod+F", { key: "F", ctrlKey: true, shiftKey: true }, false], + [false, "Mod+F", { key: "f" }, false], + [false, "Mod+F", { key: "g", ctrlKey: true }, false], + [true, "Mod+F", { key: "f", metaKey: true }, true], + [true, "Mod+F", { key: "f", ctrlKey: true }, false], + [true, "Mod+F", { key: "f", metaKey: true, ctrlKey: true }, false], + [false, "F", { key: "f", ctrlKey: true }, false], + [false, "Alt+ArrowLeft", { key: "ArrowLeft", altKey: true }, true], + [false, "Alt+ArrowLeft", { key: "ArrowLeft" }, false], + [false, "Alt+ArrowLeft", { key: "ArrowRight", altKey: true }, false], + [false, "ArrowUp", { key: "ArrowUp", shiftKey: true }, false], + [false, "Mod+Shift+P", { key: "P", ctrlKey: true, shiftKey: true }, true], + [false, "Mod+Shift+P", { key: "p", ctrlKey: true }, false], + [false, "?", { key: "?", shiftKey: true }, true], + [false, "?", { key: "/" }, false], + [false, "Mod++", { key: "+", ctrlKey: true, shiftKey: true }, true], + [false, "Mod++", { key: "=", ctrlKey: true }, false], + [false, "Mod+=", { key: "=", ctrlKey: true }, true], + [false, "Mod+1", { key: "1", ctrlKey: true }, true], + [false, "Mod+F", { key: "а", code: "KeyF", ctrlKey: true }, true], + [false, "Mod+F", { key: "а", code: "KeyG", ctrlKey: true }, false], + [false, "Mod+1", { key: "ё", code: "Digit1", ctrlKey: true }, true], + [false, "Mod+1", { key: "ё", code: "Key1", ctrlKey: true }, false], + [false, "Mod+F", { key: "u", code: "KeyF", ctrlKey: true }, false], + [false, "Mod+F", { key: "Dead", code: "KeyF", ctrlKey: true }, false], + ])("mac %s: %s with %j is %s", (mac, combo, init, expected) => { + platform.mac = mac; + expect(matchesCombo(key(init), combo)).toBe(expected); + }); +}); + +test.each<[ShortcutId, KeyboardEventInit]>([ + ["goToRepo", { key: "9", ctrlKey: true }], + ["increaseFontSize", { key: "=", ctrlKey: true }], +])("matchesShortcut tries every combo of %s", (id, init) => { + expect(matchesShortcut(key(init), id)).toBe(true); +}); + +test.each<[boolean, string, string[]]>([ + [false, "Mod+F", ["ctrl", "f"]], + [true, "Mod+F", ["⌘", "f"]], + [false, "Alt+ArrowLeft", ["alt", "←"]], + [true, "Alt+ArrowRight", ["⌥", "→"]], + [false, "ArrowUp", ["↑"]], + [false, "ArrowDown", ["↓"]], + [false, "Escape", ["esc"]], + [false, "Mod++", ["ctrl", "+"]], + [false, "Shift+Enter", ["shift", "enter"]], +])("comboKeys on mac %s labels %s as %j", (mac, combo, expected) => { + platform.mac = mac; + expect(comboKeys(combo)).toEqual(expected); +}); + +test.each<[boolean, ShortcutId, string]>([ + [false, "filter", "Control+F"], + [true, "filter", "Meta+F"], + [false, "help", "?"], + [false, "increaseFontSize", "Control++"], + [false, "allCommits", "Escape"], + [false, "back", "Alt+ArrowLeft"], +])("ariaKeyShortcuts on mac %s gives %s as %s", (mac, id, expected) => { + platform.mac = mac; + expect(ariaKeyShortcuts(id)).toBe(expected); +}); + +test("history shortcuts use brackets on macOS", async () => { + platform.mac = true; + vi.resetModules(); + const { shortcuts } = await import("@app/lib/shortcuts.svelte"); + + expect(shortcuts.back.combos).toEqual(["Mod+["]); + expect(shortcuts.forward.combos).toEqual(["Mod+]"]); +}); + +describe("dispatch", () => { + let stopListening: () => void; + let cleanups: (() => void)[] = []; + + function bind(...bindings: Binding[]) { + cleanups.push($effect.root(() => useShortcuts(...bindings))); + flushSync(); + } + + function press(init: KeyboardEventInit, target: EventTarget = document) { + const event = key(init); + target.dispatchEvent(event); + return event; + } + + beforeEach(() => { + stopListening = listenForShortcuts(); + }); + + afterEach(() => { + cleanups.forEach(fn => fn()); + cleanups = []; + window.dispatchEvent(new Event("blur")); + stopListening(); + modal.forceHide(); + document.body.replaceChildren(); + }); + + test("runs a matching binding with the combo index and prevents default", () => { + const run = vi.fn(); + bind({ shortcut: "goToRepo", run }); + + expect(press({ key: "x", ctrlKey: true }).defaultPrevented).toBe(false); + expect(run).not.toHaveBeenCalled(); + + const event = press({ key: "3", ctrlKey: true }); + expect(run).toHaveBeenCalledWith(event, 2); + expect(event.defaultPrevented).toBe(true); + }); + + test.each<[string, () => KeyboardEvent]>([ + [ + "already handled", + () => { + const event = key({ key: "?" }); + event.preventDefault(); + return event; + }, + ], + ["being composed", () => key({ key: "?", isComposing: true })], + ])("ignores keys %s", (_, event) => { + const run = vi.fn(); + bind({ shortcut: "help", run }); + + document.dispatchEvent(event()); + + expect(run).not.toHaveBeenCalled(); + }); + + test.each<[string, string, boolean, KeyboardEventInit, boolean]>([ + ["input", "", false, { key: "?" }, false], + ["textarea", "", false, { key: "?" }, false], + ["div", "", true, { key: "?" }, false], + ["div", "", false, { key: "?" }, true], + ["input", "", false, { key: "f", ctrlKey: true }, false], + ["input", "data-mod-shortcuts", false, { key: "f", ctrlKey: true }, true], + ["input", "data-mod-shortcuts", false, { key: "?" }, false], + ])( + "from a %s %s (editable %s), %j runs: %s", + (tag, attribute, editable, init, runs) => { + const run = vi.fn(); + bind({ shortcut: "help", run }, { shortcut: "filter", run }); + const element = document.createElement(tag); + if (attribute) element.setAttribute(attribute, ""); + if (editable) element.contentEditable = "true"; + document.body.append(element); + + press(init, element); + + expect(run).toHaveBeenCalledTimes(runs ? 1 : 0); + }, + ); + + test("skips bindings while a modal is open unless they allow it", () => { + const outside = vi.fn(); + const inside = vi.fn(); + bind({ shortcut: "help", run: outside }); + modal.show({ component: vi.fn(), props: {} }); + + press({ key: "?" }); + expect(outside).not.toHaveBeenCalled(); + + bind({ shortcut: "closeModal", run: inside, allowInModal: true }); + press({ key: "Escape" }); + expect(inside).toHaveBeenCalledTimes(1); + }); + + test("skips disabled bindings", () => { + const disabled = vi.fn(); + const enabled = vi.fn(); + bind( + { shortcut: "help", run: enabled, enabled: () => true }, + { shortcut: "help", run: disabled, enabled: () => false }, + ); + + press({ key: "?" }); + + expect(disabled).not.toHaveBeenCalled(); + expect(enabled).toHaveBeenCalledTimes(1); + }); + + test("tries higher priority first, then the most recently registered", () => { + const calls: string[] = []; + const binding = (name: string, priority?: number): Binding => ({ + shortcut: "allCommits", + priority, + run: () => { + calls.push(name); + return false; + }, + }); + bind(binding("low", -1), binding("first"), binding("high", 1)); + bind(binding("second")); + + const event = press({ key: "Escape" }); + + expect(calls).toEqual(["high", "second", "first", "low"]); + expect(event.defaultPrevented).toBe(false); + }); + + test("returning false passes the key on to the next binding", () => { + const next = vi.fn(); + bind( + { shortcut: "help", run: next }, + { shortcut: "help", run: () => false }, + ); + + expect(press({ key: "?" }).defaultPrevented).toBe(true); + expect(next).toHaveBeenCalledTimes(1); + }); + + test("repeated presses cycle past the active binding", () => { + let focused = ""; + const binding = (name: string): Binding => ({ + shortcut: "filter", + run: () => (focused = name), + active: () => focused === name, + }); + bind(binding("c"), binding("b"), binding("a")); + + const order = [1, 2, 3, 4].map(() => { + press({ key: "f", ctrlKey: true }); + return focused; + }); + + expect(order).toEqual(["a", "b", "c", "a"]); + }); + + test("unbinds when the owner is destroyed", () => { + const run = vi.fn(); + bind({ shortcut: "help", run }); + cleanups.pop()?.(); + + press({ key: "?" }); + + expect(run).not.toHaveBeenCalled(); + }); + + test("stops listening once the listener is removed", () => { + const run = vi.fn(); + bind({ shortcut: "help", run }); + press({ key: "Control" }); + stopListening(); + + press({ key: "?" }); + document.dispatchEvent(new KeyboardEvent("keyup", { key: "Control" })); + window.dispatchEvent(new Event("blur")); + + expect(run).not.toHaveBeenCalled(); + expect(modifierHeld.value).toBe(true); + stopListening = listenForShortcuts(); + }); + + test.each<[boolean, string]>([ + [false, "Control"], + [true, "Meta"], + ])("modifierHeld follows mac %s %s", (mac, modifier) => { + platform.mac = mac; + press({ key: mac ? "Control" : "Meta" }); + expect(modifierHeld.value).toBe(false); + + press({ key: modifier }); + expect(modifierHeld.value).toBe(true); + document.dispatchEvent(new KeyboardEvent("keyup", { key: "Shift" })); + expect(modifierHeld.value).toBe(true); + document.dispatchEvent(new KeyboardEvent("keyup", { key: modifier })); + expect(modifierHeld.value).toBe(false); + + press({ key: modifier }); + window.dispatchEvent(new Event("blur")); + expect(modifierHeld.value).toBe(false); + }); + + test.each<[string, string, boolean]>([ + ["a field", "", false], + ["a Mod-shortcut field", "data-mod-shortcuts", true], + ])("modifierHeld from %s is %s", (_, attribute, held) => { + const input = document.createElement("input"); + if (attribute) input.setAttribute(attribute, ""); + document.body.append(input); + + press({ key: "Control" }, input); + + expect(modifierHeld.value).toBe(held); + }); + + test("modifierHeld stays false while a modal is open", () => { + modal.show({ component: vi.fn(), props: {} }); + + press({ key: "Control" }); + + expect(modifierHeld.value).toBe(false); + }); +}); diff --git a/desktop/tests/unit/sidebar.test.ts b/desktop/tests/unit/sidebar.test.ts new file mode 100644 index 000000000..90b4e06aa --- /dev/null +++ b/desktop/tests/unit/sidebar.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +function setWindow(innerWidth: number, fontSize = "") { + Object.defineProperty(window, "innerWidth", { + value: innerWidth, + configurable: true, + }); + document.documentElement.style.fontSize = fontSize; +} + +async function load() { + return await import("@app/lib/sidebar.svelte"); +} + +beforeEach(() => { + localStorage.clear(); + vi.resetModules(); + setWindow(2000); +}); + +test("rootFontSize falls back to 16px when it can't be read", async () => { + const { rootFontSize } = await load(); + vi.spyOn(window, "getComputedStyle").mockReturnValueOnce({ + fontSize: "", + } as CSSStyleDeclaration); + + expect(rootFontSize()).toBe(16); +}); + +describe("clampSidebarWidth", () => { + test.each([ + [20, 20], + [12, 12], + [11.9, 12], + [-5, 12], + [30, 30], + [30.1, 30], + [NaN, 16.5], + [Infinity, 16.5], + ])("clamps %d rem to %d in a wide window", async (rem, expected) => { + const { clampSidebarWidth } = await load(); + + expect(clampSidebarWidth(rem)).toBe(expected); + }); + + test.each([ + [1000, "", 25, 25], + [1000, "", 26, 25], + [1000, "20px", 25, 20], + [100, "", 25, 12], + ])( + "caps the width at 40%% of a %dpx window with font size %j", + async (innerWidth, fontSize, rem, expected) => { + setWindow(innerWidth, fontSize); + const { clampSidebarWidth } = await load(); + + expect(clampSidebarWidth(rem)).toBe(expected); + }, + ); +}); + +describe("sidebarWidth", () => { + test("starts with the default width", async () => { + const { renderedSidebarWidth } = await load(); + + expect(renderedSidebarWidth.value).toBe(16.5); + expect(localStorage.getItem("sidebarWidth")).toBeNull(); + }); + + test.each([ + ["20", 20, "20"], + ["50", 30, "30"], + ["5", 12, "12"], + ])("restores a stored %s rem as %d", async (stored, expected, afterLoad) => { + localStorage.setItem("sidebarWidth", stored); + const { renderedSidebarWidth } = await load(); + + expect(renderedSidebarWidth.value).toBe(expected); + expect(localStorage.getItem("sidebarWidth")).toBe(afterLoad); + }); + + test("stores a clamped width set directly", async () => { + const { renderedSidebarWidth, setSidebarWidth } = await load(); + setSidebarWidth(40); + + expect(renderedSidebarWidth.value).toBe(30); + expect(localStorage.getItem("sidebarWidth")).toBe("30"); + }); + + test("stores a drag's width only once it is committed", async () => { + const { + commitSidebarWidth, + previewSidebarWidth, + renderedSidebarWidth, + sidebarWidth, + } = await load(); + previewSidebarWidth(20); + previewSidebarWidth(40); + + expect(renderedSidebarWidth.value).toBe(30); + expect(localStorage.getItem("sidebarWidth")).toBeNull(); + + commitSidebarWidth(); + + expect(renderedSidebarWidth.value).toBe(30); + expect(localStorage.getItem("sidebarWidth")).toBe("30"); + + sidebarWidth.value = 20; + + expect(renderedSidebarWidth.value).toBe(20); + }); + + test("a discarded drag keeps the stored width", async () => { + const { + discardSidebarWidthPreview, + previewSidebarWidth, + renderedSidebarWidth, + setSidebarWidth, + } = await load(); + setSidebarWidth(20); + previewSidebarWidth(25); + discardSidebarWidthPreview(); + + expect(renderedSidebarWidth.value).toBe(20); + expect(localStorage.getItem("sidebarWidth")).toBe("20"); + }); + + test("committing without a drag stores nothing", async () => { + const { commitSidebarWidth, renderedSidebarWidth } = await load(); + commitSidebarWidth(); + + expect(renderedSidebarWidth.value).toBe(16.5); + expect(localStorage.getItem("sidebarWidth")).toBeNull(); + }); + + test("a direct set drops an ongoing drag", async () => { + const { + commitSidebarWidth, + previewSidebarWidth, + renderedSidebarWidth, + setSidebarWidth, + } = await load(); + previewSidebarWidth(25); + setSidebarWidth(20); + + expect(renderedSidebarWidth.value).toBe(20); + + commitSidebarWidth(); + + expect(localStorage.getItem("sidebarWidth")).toBe("20"); + }); +}); + +test("toggleSidebar flips and stores the collapsed state", async () => { + const { sidebarCollapsed, toggleSidebar } = await load(); + expect(sidebarCollapsed.value).toBe(false); + + toggleSidebar(); + + expect(sidebarCollapsed.value).toBe(true); + expect(localStorage.getItem("sidebarCollapsed")).toBe("true"); + + toggleSidebar(); + + expect(sidebarCollapsed.value).toBe(false); +}); diff --git a/desktop/tests/unit/sidebarResize.test.ts b/desktop/tests/unit/sidebarResize.test.ts new file mode 100644 index 000000000..e46dcd15f --- /dev/null +++ b/desktop/tests/unit/sidebarResize.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; + +import { doublePressDetector, dragResult } from "@app/lib/sidebarResize"; + +describe("dragResult", () => { + test("widens by the dragged distance", () => { + expect(dragResult(16, 32, 16)).toEqual({ + collapse: false, + width: 18, + railStretch: 0, + }); + }); + + test("collapses only past the slack below the minimum width", () => { + expect(dragResult(12, -2 * 16, 16).collapse).toBe(false); + expect(dragResult(12, -2 * 16 - 1, 16).collapse).toBe(true); + }); + + test("stretches the rail with a collapsed drag, never below its width", () => { + expect(dragResult(5, 2 * 16, 16)).toEqual({ + collapse: true, + width: 7, + railStretch: 0.7, + }); + expect(dragResult(5, -16, 16).railStretch).toBe(0); + }); +}); + +describe("doublePressDetector", () => { + const press = (timeStamp: number, clientX = 100) => ({ timeStamp, clientX }); + + test("pairs two quick presses in the same place", () => { + const isDoublePress = doublePressDetector(); + expect(isDoublePress(press(1000), false)).toBe(false); + expect(isDoublePress(press(1499, 106), false)).toBe(true); + }); + + test("a third press starts a new pair", () => { + const isDoublePress = doublePressDetector(); + isDoublePress(press(1000), false); + isDoublePress(press(1100), false); + expect(isDoublePress(press(1200), false)).toBe(false); + expect(isDoublePress(press(1300), false)).toBe(true); + }); + + test.each([ + ["too late", press(1500), false], + ["too far", press(1100, 107), false], + ["after a resize", press(1100), true], + ])("doesn't pair a press %s", (_, second, resized) => { + const isDoublePress = doublePressDetector(); + isDoublePress(press(1000), false); + expect(isDoublePress(second, resized)).toBe(false); + }); + + test("the first press right after launch isn't a pair", () => { + expect(doublePressDetector()(press(10, 0), false)).toBe(false); + }); +}); diff --git a/desktop/tests/unit/stageArtifacts.test.ts b/desktop/tests/unit/stageArtifacts.test.ts new file mode 100644 index 000000000..aa798cf10 --- /dev/null +++ b/desktop/tests/unit/stageArtifacts.test.ts @@ -0,0 +1,278 @@ +import type { Artifact } from "@bindings/cob/release/Artifact"; + +import { describe, expect, test } from "vitest"; + +import type { StagedArtifact } from "@app/lib/stageArtifacts"; +import { + duplicatePicks, + effective, + planDetail, + planSummary, + planTitle, + registerPlan, + stageArtifacts, +} from "@app/lib/stageArtifacts"; + +const me = "did:key:z6MkMe"; +const them = "did:key:z6MkThem"; +const delegate = "did:key:z6MkDelegate"; + +function picked(name: string, cid = "cid") { + return { + path: `/tmp/${name}`, + name, + digest: { cid, sizeBytes: 3, fileCount: 1, directory: false }, + }; +} + +function artifact(overrides: Partial = {}): Artifact { + return { + cid: "cid", + name: "build.tar", + directory: false, + author: { did: me }, + locations: [], + attestations: [], + redactions: [], + redacted: false, + metadata: {}, + ...overrides, + }; +} + +function stage( + name: string, + artifacts: Artifact[], + { nodeRunning = true, seeded = new Set() } = {}, +) { + const [item] = stageArtifacts([picked(name)], artifacts, { + ownDid: me, + delegates: new Set([delegate]), + nodeRunning, + seeded, + }); + return item; +} + +describe("stageArtifacts", () => { + test("registers and seeds a new artifact", () => { + const item = stage("build.tar", []); + expect(item).toMatchObject({ register: true, seed: true }); + expect(item.existing).toBeUndefined(); + }); + + test("registers a new artifact without seeding when the node is down", () => { + expect(stage("build.tar", [], { nodeRunning: false })).toMatchObject({ + register: true, + seed: false, + }); + }); + + test("registers a rename of your own artifact", () => { + expect(stage("renamed.tar", [artifact()])).toMatchObject({ + register: true, + existing: { name: "build.tar", keepsName: false }, + }); + }); + + test("keeps the name of someone else's artifact and only seeds", () => { + const item = stage("renamed.tar", [artifact({ author: { did: them } })]); + expect(item).toMatchObject({ + register: false, + seed: true, + existing: { name: "build.tar", keepsName: true }, + }); + }); + + test("skips an artifact your node already seeds", () => { + expect( + stage("build.tar", [artifact()], { seeded: new Set(["cid"]) }), + ).toMatchObject({ register: false, seed: false }); + }); + + test("skips an existing artifact when the node is down", () => { + expect( + stage("build.tar", [artifact()], { nodeRunning: false }), + ).toMatchObject({ register: false, seed: false }); + }); + + test("carries the redaction reason of a redacted artifact", () => { + const item = stage("build.tar", [ + artifact({ + author: { did: them }, + redacted: true, + redactions: [ + { user: { did: "did:key:z6MkOther" }, reason: "ignored" }, + { user: { did: delegate }, reason: "malware" }, + ], + }), + ]); + expect(item.existing?.redaction).toBe("malware"); + expect(effective(item, false)).toEqual({ register: false, seed: false }); + expect(effective(item, true)).toEqual({ register: false, seed: true }); + }); + + test("skips a later pick with the same contents", () => { + const [first, second] = stageArtifacts( + [picked("a.tar"), picked("b.tar")], + [], + { + ownDid: me, + delegates: new Set(), + nodeRunning: true, + seeded: new Set(), + }, + ); + expect(first).toMatchObject({ register: true, seed: true }); + expect(first.duplicateOf).toBeUndefined(); + expect(second).toMatchObject({ + register: false, + seed: false, + duplicateOf: "a.tar", + }); + expect(effective(second, true)).toEqual({ register: false, seed: false }); + }); +}); + +describe("duplicatePicks", () => { + test("maps later picks to the first with the same contents", () => { + expect( + duplicatePicks([ + picked("a.tar", "x"), + picked("b.tar", "y"), + picked("c.tar", "x"), + picked("d.tar", "x"), + ]), + ).toEqual( + new Map([ + ["/tmp/c.tar", "a.tar"], + ["/tmp/d.tar", "a.tar"], + ]), + ); + }); + + test("ignores picks still hashing", () => { + expect( + duplicatePicks([ + { path: "/tmp/a.tar", name: "a.tar" }, + { path: "/tmp/b.tar", name: "b.tar" }, + ]).size, + ).toBe(0); + }); +}); + +function staged(overrides: Partial = {}): StagedArtifact { + return { ...picked("build.tar"), register: true, seed: false, ...overrides }; +} + +const renamed = staged({ + existing: { name: "old.tar", keepsName: false }, +}); +const kept = staged({ + register: false, + existing: { name: "old.tar", keepsName: true }, +}); +const redacted = staged({ + register: false, + existing: { name: "old.tar", keepsName: false, redaction: "bad" }, +}); +const reseeded = staged({ + register: false, + seed: true, + existing: { name: "build.tar", keepsName: false }, +}); +const duplicate = staged({ register: false, duplicateOf: "a.tar" }); + +describe("registerPlan", () => { + test("counts what each pick does", () => { + const plan = registerPlan( + [staged(), renamed, reseeded, kept, duplicate], + false, + ); + expect(plan).toMatchObject({ + newCount: 1, + renamedCount: 1, + seededCount: 1, + skippedCount: 2, + }); + expect(plan.acting).toHaveLength(3); + }); + + test("skips a redacted match unless it is included", () => { + expect(registerPlan([redacted], false).skippedCount).toBe(1); + expect( + registerPlan([{ ...redacted, register: true }], true).acting, + ).toHaveLength(1); + }); + + test("is empty for no picks", () => { + expect(registerPlan([], false)).toMatchObject({ + newCount: 0, + skippedCount: 0, + acting: [], + }); + }); +}); + +describe("planTitle", () => { + test.each([ + [[kept], "Already registered"], + [[staged()], "Register 1 artifact"], + [[staged(), renamed], "Register 2 artifacts"], + [[renamed], "Update 1 artifact"], + ])("%#", (items, expected) => { + expect(planTitle(registerPlan(items, false))).toBe(expected); + }); +}); + +describe("planSummary", () => { + test("lists only the counts that are not zero", () => { + expect(planSummary(registerPlan([staged(), kept, duplicate], false))).toBe( + "1 registered, 2 skipped", + ); + }); + + test("is empty when nothing is staged", () => { + expect(planSummary(registerPlan([], false))).toBe(""); + }); +}); + +describe("planDetail", () => { + function detail(item: StagedArtifact, includeRedacted = false) { + const [planned] = registerPlan([item], includeRedacted).items; + return planDetail(planned, includeRedacted); + } + + test.each([ + ["a new file", staged(), "3 B"], + [ + "a new folder", + staged({ + digest: { cid: "c", sizeBytes: 3, fileCount: 2, directory: true }, + }), + "2 files · 3 B", + ], + [ + "a folder with one file", + staged({ + digest: { cid: "c", sizeBytes: 3, fileCount: 1, directory: true }, + }), + "1 file · 3 B", + ], + ["a repeated pick", duplicate, "same as “a.tar” · skipped"], + ["a redacted match", redacted, "redacted · skipped"], + ["a rename", renamed, "renames “old.tar”"], + ["someone else's name", kept, "stays “old.tar” · skipped"], + [ + "an existing artifact to seed", + reseeded, + "already registered · will seed", + ], + ])("%s", (_, item, expected) => { + expect(detail(item)).toBe(expected); + }); + + test("describes an included redacted match like any other", () => { + expect(detail(redacted, true)).toBe("already registered · skipped"); + }); +}); diff --git a/desktop/tests/unit/support/cobs.ts b/desktop/tests/unit/support/cobs.ts new file mode 100644 index 000000000..d5114f486 --- /dev/null +++ b/desktop/tests/unit/support/cobs.ts @@ -0,0 +1,81 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Operation } from "@bindings/cob/Operation"; +import type { Action } from "@bindings/cob/patch/Action"; +import type { Review } from "@bindings/cob/patch/Review"; +import type { Revision } from "@bindings/cob/patch/Revision"; +import type { CodeLocation } from "@bindings/cob/thread/CodeLocation"; +import type { Comment } from "@bindings/cob/thread/Comment"; +import type { Thread } from "@bindings/cob/thread/Thread"; + +// Pass `null` for an author without an alias. +export function author(name: string, alias: string | null = name): Author { + return { did: `did:key:z6Mk${name}`, alias: alias ?? undefined }; +} + +export function location( + path: string, + side: "old" | "new", + start: number, + end = start + 1, +): CodeLocation { + const range = { type: "lines" as const, range: { start, end } }; + return { + commit: "c".repeat(40), + path, + old: side === "old" ? range : null, + new: side === "new" ? range : null, + }; +} + +export function comment( + props: Partial> & { timestamp?: number } = {}, +): Comment { + const { timestamp = 0, ...rest } = props; + const by = rest.author ?? author("alice"); + return { + id: "comment", + author: by, + edits: [{ author: by, timestamp, body: "body" }], + reactions: [], + replyTo: null, + location: null, + resolved: false, + ...rest, + }; +} + +export function thread(root: Comment): Thread { + return { root, replies: [] }; +} + +export function review(props: Partial = {}): Review { + return { + id: "review", + author: author("alice"), + summary: "", + comments: [], + timestamp: 0, + labels: [], + ...props, + }; +} + +export function revision(props: Partial = {}): Revision { + return { + id: "revision", + author: author("alice"), + description: [], + base: "b".repeat(40), + head: "h".repeat(40), + timestamp: 0, + ...props, + }; +} + +export function operation
( + by: Author, + timestamp: number, + actions: A[], +): Operation { + return { id: `op-${timestamp}`, author: by, timestamp, actions }; +} diff --git a/desktop/tests/unit/threads.test.ts b/desktop/tests/unit/threads.test.ts new file mode 100644 index 000000000..d305ed8d3 --- /dev/null +++ b/desktop/tests/unit/threads.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "vitest"; + +import { + buildThreads, + codeThreadsByReview, + discussionThreadsByReview, + groupThreadsByFile, + isCodeRoot, + reviewDiscussionThreads, + reviewIdByComment, + revisionDiscussionThreads, + revisionIdByComment, +} from "@app/lib/threads"; + +import { + author, + comment, + location, + review, + revision, + thread, +} from "./support/cobs"; + +const alice = author("alice"); +const bob = author("bob"); +const code = location("a.ts", "new", 1); + +function ids(threads: { root: { id: string }; replies: { id: string }[] }[]) { + return threads.map(t => [t.root.id, ...t.replies.map(r => r.id)]); +} + +describe("buildThreads", () => { + test("attaches replies to their root, oldest first", () => { + const threads = buildThreads( + [ + comment({ id: "root" }), + comment({ id: "late", replyTo: "root", timestamp: 20 }), + comment({ id: "early", replyTo: "root", timestamp: 10 }), + comment({ id: "other" }), + ], + c => !c.replyTo, + ); + + expect(ids(threads)).toEqual([["root", "early", "late"], ["other"]]); + }); + + test("isCodeRoot picks located comments that aren't replies", () => { + expect(isCodeRoot(comment({ location: code }))).toBe(true); + expect(isCodeRoot(comment({ location: code, replyTo: "x" }))).toBe(false); + expect(isCodeRoot(comment({ location: null }))).toBe(false); + }); +}); + +describe("threads by review", () => { + const reviews = [ + review({ + id: "r1", + comments: [ + comment({ id: "code", location: code }), + comment({ id: "code-reply", location: code, replyTo: "code" }), + comment({ id: "general" }), + comment({ id: "on-review", location: code, replyTo: "r1" }), + ], + }), + review({ id: "r2", comments: [] }), + ]; + + test("codeThreadsByReview keeps reviews with code threads", () => { + const map = codeThreadsByReview(reviews); + + expect([...map.keys()]).toEqual(["r1"]); + expect(ids(map.get("r1") ?? [])).toEqual([["code", "code-reply"]]); + }); + + test("a reply to the review itself starts a discussion thread", () => { + expect(ids(reviewDiscussionThreads(reviews[0]))).toEqual([ + ["general"], + ["on-review"], + ]); + }); + + test("discussionThreadsByReview keeps reviews with discussion threads", () => { + expect([...discussionThreadsByReview(reviews).keys()]).toEqual(["r1"]); + }); + + test("reviewIdByComment maps roots and replies to their review", () => { + const map = reviewIdByComment( + codeThreadsByReview(reviews), + discussionThreadsByReview(reviews), + ); + + expect(Object.fromEntries(map)).toEqual({ + code: "r1", + "code-reply": "r1", + general: "r1", + "on-review": "r1", + }); + }); +}); + +test("revisionIdByComment maps discussion comments to their revision", () => { + const map = revisionIdByComment([ + revision({ id: "v1", discussion: [comment({ id: "a" })] }), + revision({ id: "v2", discussion: [comment({ id: "b" })] }), + ]); + + expect(Object.fromEntries(map)).toEqual({ a: "v1", b: "v2" }); +}); + +test("groupThreadsByFile groups by path in order of first appearance", () => { + const groups = groupThreadsByFile([ + thread(comment({ id: "b1", location: location("b.ts", "new", 1) })), + thread(comment({ id: "a1", location: location("a.ts", "new", 1) })), + thread(comment({ id: "b2", location: location("b.ts", "old", 4) })), + thread(comment({ id: "none", location: null })), + ]); + + expect(groups.map(g => [g.path, ...g.threads.map(t => t.root.id)])).toEqual([ + ["b.ts", "b1", "b2"], + ["a.ts", "a1"], + ]); +}); + +describe("revisionDiscussionThreads", () => { + test("collects each revision's plain comments and replies to it", () => { + const threads = revisionDiscussionThreads([ + revision({ + id: "v1", + discussion: [ + comment({ id: "c1" }), + comment({ id: "reply", replyTo: "c1" }), + comment({ id: "code", location: code }), + comment({ id: "on-revision", replyTo: "v1" }), + ], + }), + revision({ id: "v2", discussion: [comment({ id: "c2" })] }), + ]); + + expect(ids(threads)).toEqual([["c1", "reply"], ["on-revision"], ["c2"]]); + }); + + test("skips the revision's own description comment", () => { + expect( + revisionDiscussionThreads([ + revision({ id: "v1", discussion: [comment({ id: "v1" })] }), + ]), + ).toEqual([]); + }); + + test("skips a comment repeating its author's review summary", () => { + const threads = revisionDiscussionThreads([ + revision({ + id: "v1", + reviews: [review({ author: alice, summary: "LGTM" })], + discussion: [ + comment({ + id: "repeat", + author: alice, + edits: [edit(alice, "LGTM")], + }), + comment({ + id: "other-author", + author: bob, + edits: [edit(bob, "LGTM")], + }), + comment({ + id: "edited", + author: alice, + edits: [edit(alice, "LGTM"), edit(alice, "Changed")], + }), + ], + }), + ]); + + expect(ids(threads)).toEqual([["other-author"], ["edited"]]); + }); +}); + +function edit(by: typeof alice, body: string) { + return { author: by, timestamp: 0, body }; +} diff --git a/desktop/tests/unit/timelineRuns.test.ts b/desktop/tests/unit/timelineRuns.test.ts new file mode 100644 index 000000000..c450d02fe --- /dev/null +++ b/desktop/tests/unit/timelineRuns.test.ts @@ -0,0 +1,150 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Thread } from "@bindings/cob/thread/Thread"; + +import { describe, expect, test } from "vitest"; + +import type { Run, TimelineEntry } from "@app/lib/timelineRuns"; +import { mergeTimeline, timelineRuns } from "@app/lib/timelineRuns"; + +import { author, comment } from "./support/cobs"; + +const alice = author("alice"); +const bob = author("bob"); + +type Data = { by: Author }; + +function activity(key: string, by: Author, standalone = false) { + return { + kind: "activity" as const, + key, + timestamp: 0, + data: { by }, + standalone, + }; +} + +function threadEntry(key: string, by: Author): TimelineEntry { + return { + kind: "thread", + key, + timestamp: 0, + thread: { + root: comment({ id: key, author: by }), + replies: [], + } as unknown as Thread, + }; +} + +// `a:x` for a single entry, `a:[x,y]` for a group, `thread:x` for a thread; +// `+` marks a run that repeats the previous run's author. +function shape(runs: Run[]): string[] { + return runs.map(run => { + if (run.kind === "thread") return `thread:${run.entry.key}`; + const repeat = run.repeatsAuthor ? "+" : ""; + if (run.kind === "single") return `${repeat}${run.entry.key}`; + return `${repeat}[${run.entries.map(e => e.key).join(",")}]`; + }); +} + +const authorOf = (data: Data) => data.by; + +describe("mergeTimeline", () => { + test("merges threads and activity, oldest first", () => { + const merged = mergeTimeline( + [ + { + root: comment({ id: "t", timestamp: 2 }), + replies: [], + } as unknown as Thread, + ], + [ + { key: "late", timestamp: 3, data: { by: alice } }, + { key: "early", timestamp: 1, data: { by: alice }, standalone: true }, + ], + ); + + expect(merged.map(e => [e.kind, e.key])).toEqual([ + ["activity", "early"], + ["thread", "t"], + ["activity", "late"], + ]); + expect(merged[0]).toMatchObject({ standalone: true }); + expect(merged[2]).toMatchObject({ standalone: false }); + }); +}); + +describe("timelineRuns", () => { + test("groups consecutive activity by the same author", () => { + expect( + shape( + timelineRuns( + [activity("a1", alice), activity("a2", alice), activity("a3", alice)], + authorOf, + ), + ), + ).toEqual(["[a1,a2,a3]"]); + }); + + test("starts a new run for another author", () => { + expect( + shape( + timelineRuns( + [activity("a1", alice), activity("b1", bob), activity("a2", alice)], + authorOf, + ), + ), + ).toEqual(["a1", "b1", "a2"]); + }); + + test("a thread breaks a run, and the author isn't named again after it", () => { + const thread = threadEntry("t", alice); + const runs = timelineRuns( + [ + activity("a1", alice), + thread, + activity("a2", alice), + activity("a3", alice), + ], + authorOf, + ); + + expect(shape(runs)).toEqual(["a1", "thread:t", "+[a2,a3]"]); + expect(runs[1]).toEqual({ kind: "thread", entry: thread }); + }); + + test("standalone entries are never grouped", () => { + expect( + shape( + timelineRuns( + [ + activity("a1", alice), + activity("card", alice, true), + activity("a2", alice), + ], + authorOf, + ), + ), + ).toEqual(["a1", "+card", "+a2"]); + }); + + test("a group keeps the repeat flag of the entry it grew from", () => { + expect( + shape( + timelineRuns( + [ + activity("card", alice, true), + activity("a1", alice), + activity("a2", alice), + ], + authorOf, + ), + ), + ).toEqual(["card", "+[a1,a2]"]); + }); + + test("activity without an author is never grouped", () => { + expect( + shape(timelineRuns([activity("x", alice), activity("y", alice)])), + ).toEqual(["x", "y"]); + }); +}); diff --git a/desktop/tests/unit/updateChecker.test.ts b/desktop/tests/unit/updateChecker.test.ts new file mode 100644 index 000000000..df9b1830a --- /dev/null +++ b/desktop/tests/unit/updateChecker.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock("@app/lib/invoke", () => ({ invoke })); + +const storageKey = "updateChecker.isEnabled"; +const latestUrl = + "https://files.radicle.dev/releases/radicle-desktop/latest/latest.json"; +const hour = 3600 * 1000; + +const fetch = vi.fn(); +let latest: Promise; + +async function start(current: string, stored?: boolean) { + if (stored !== undefined) { + localStorage.setItem(storageKey, JSON.stringify(stored)); + } + invoke.mockResolvedValue(current); + vi.resetModules(); + const { updateChecker } = await import("@app/lib/updateChecker.svelte"); + await vi.advanceTimersByTimeAsync(0); + return updateChecker; +} + +beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + latest = Promise.resolve("1.0.0"); + fetch.mockReset(); + fetch.mockImplementation(async () => ({ + json: async () => ({ version: await latest }), + })); + vi.stubGlobal("fetch", fetch); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +test("is disabled by default and doesn't check", async () => { + const checker = await start("0.9.0"); + + expect(invoke).toHaveBeenCalledWith("version"); + expect(checker.currentVersion).toBe("0.9.0"); + expect(checker.isEnabled).toBe(false); + expect(checker.newVersion).toBeUndefined(); + expect(checker.upToDate).toBe(false); + + await vi.advanceTimersByTimeAsync(hour); + expect(fetch).not.toHaveBeenCalled(); +}); + +test("stays disabled when disabling was stored", async () => { + const checker = await start("0.9.0", false); + + expect(checker.isEnabled).toBe(false); + expect(fetch).not.toHaveBeenCalled(); +}); + +test.each<[string, string, string | undefined, boolean]>([ + ["0.9.0", "1.0.0", "1.0.0", false], + ["0.9.0", "0.9.0", undefined, true], + ["1.1.0", "1.0.0", undefined, true], + ["v0.9.0-12-gabcdef", "0.9.1", "0.9.1", false], + ["not a version", "1.0.0", undefined, false], +])( + "on %s with %s released, offers %s and is up to date: %s", + async (current, released, newVersion, upToDate) => { + latest = Promise.resolve(released); + const checker = await start(current, true); + + expect(fetch).toHaveBeenCalledWith(latestUrl, { cache: "no-store" }); + expect(checker.currentVersion).toBe(current); + expect(checker.isEnabled).toBe(true); + expect(checker.newVersion).toBe(newVersion); + expect(checker.upToDate).toBe(upToDate); + }, +); + +test("a failed check claims neither an update nor being up to date", async () => { + latest = Promise.reject(new Error("offline")); + const checker = await start("0.9.0", true); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(checker.newVersion).toBeUndefined(); + expect(checker.upToDate).toBe(false); +}); + +test("enable stores the choice and checks now and every hour", async () => { + const checker = await start("0.9.0"); + + checker.enable(); + checker.enable(); + await vi.advanceTimersByTimeAsync(0); + expect(localStorage.getItem(storageKey)).toBe("true"); + expect(checker.isEnabled).toBe(true); + expect(checker.newVersion).toBe("1.0.0"); + expect(fetch).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(hour - 1); + expect(fetch).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(fetch).toHaveBeenCalledTimes(3); +}); + +test("an hourly check picks up a new release", async () => { + latest = Promise.resolve("0.9.0"); + const checker = await start("0.9.0", true); + expect(checker.upToDate).toBe(true); + + latest = Promise.resolve("0.10.0"); + await vi.advanceTimersByTimeAsync(hour); + + expect(checker.newVersion).toBe("0.10.0"); + expect(checker.upToDate).toBe(false); +}); + +test("disable stores the choice and stops checking", async () => { + latest = Promise.resolve("0.9.0"); + const checker = await start("0.9.0", true); + expect(fetch).toHaveBeenCalledTimes(1); + expect(checker.upToDate).toBe(true); + + checker.disable(); + await vi.advanceTimersByTimeAsync(hour); + + expect(localStorage.getItem(storageKey)).toBe("false"); + expect(checker.isEnabled).toBe(false); + expect(checker.upToDate).toBe(false); + expect(fetch).toHaveBeenCalledTimes(1); + + checker.enable(); + await vi.advanceTimersByTimeAsync(hour); + expect(fetch).toHaveBeenCalledTimes(3); +}); + +test("disabling keeps offering an update that was already found", async () => { + const checker = await start("0.9.0", true); + + checker.disable(); + + expect(checker.newVersion).toBe("1.0.0"); +}); diff --git a/desktop/tests/unit/useLocalStorage.test.ts b/desktop/tests/unit/useLocalStorage.test.ts new file mode 100644 index 000000000..bbc4a5ed6 --- /dev/null +++ b/desktop/tests/unit/useLocalStorage.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { array, string } from "zod"; + +import useLocalStorage from "@app/lib/useLocalStorage.svelte"; + +const key = "test.list"; + +beforeEach(() => { + localStorage.clear(); + vi.spyOn(console, "error").mockReturnValue(undefined); +}); + +describe("useLocalStorage", () => { + test("starts with the initial value when nothing is stored", () => { + expect(useLocalStorage(key, array(string()), ["x"]).value).toEqual(["x"]); + }); + + test("reads a valid stored value", () => { + localStorage.setItem(key, JSON.stringify(["a", "b"])); + + expect(useLocalStorage(key, array(string()), []).value).toEqual(["a", "b"]); + }); + + test.each(["{not json", JSON.stringify([1, 2]), JSON.stringify("text")])( + "falls back to the initial value for %j", + stored => { + localStorage.setItem(key, stored); + + expect(useLocalStorage(key, array(string()), ["x"]).value).toEqual(["x"]); + }, + ); + + test("writes through on set and update", () => { + const store = useLocalStorage(key, array(string()), []); + store.value = ["a"]; + expect(localStorage.getItem(key)).toBe('["a"]'); + + store.update(list => [...list, "b"]); + expect(store.value).toEqual(["a", "b"]); + expect(localStorage.getItem(key)).toBe('["a","b"]'); + }); + + test("clear resets the value and removes it from storage", () => { + const store = useLocalStorage(key, array(string()), ["x"]); + store.value = ["a"]; + store.clear(); + + expect(store.value).toEqual(["x"]); + expect(localStorage.getItem(key)).toBeNull(); + }); + + test("with storage disabled, neither reads nor writes it", () => { + localStorage.setItem(key, '["stored"]'); + const store = useLocalStorage(key, array(string()), ["x"], true); + expect(store.value).toEqual(["x"]); + + store.value = ["a"]; + + expect(store.value).toEqual(["a"]); + expect(localStorage.getItem(key)).toBe('["stored"]'); + }); +}); diff --git a/desktop/tests/unit/utils.test.ts b/desktop/tests/unit/utils.test.ts new file mode 100644 index 000000000..e2af74140 --- /dev/null +++ b/desktop/tests/unit/utils.test.ts @@ -0,0 +1,384 @@ +import type { Config } from "@bindings/config/Config"; + +import { describe, expect, test, vi } from "vitest"; + +import { + coAuthors, + creditedCoAuthors, + explorerHost, + explorerLink, + formatBytes, + formatGitIdentity, + formatRepositoryId, + formatTimestamp, + formatUptime, + identityKey, + isPublishableReview, + parseNodeId, + parseRepositoryId, + pluralize, + revisionPosition, + safeHttpUrl, + shortenCids, + truncateDid, + unqualifyBranch, +} from "@app/lib/utils"; + +const rid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe"; +const nid = "z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK"; + +describe("safeHttpUrl", () => { + test.each([ + ["http://example.com/", "http://example.com/"], + ["https://example.com/path?a=1&b=2", "https://example.com/path?a=1&b=2"], + ["HTTP://EXAMPLE.com/Path", "http://example.com/Path"], + ["http://example.com/", + "vbscript:msgbox(1)", + "file:///etc/passwd", + "//example.com", + "/relative/path", + "relative", + "", + "not a url", + ])("rejects %j", input => { + expect(safeHttpUrl(input)).toBeUndefined(); + }); +}); + +describe("coAuthors", () => { + test("reads trailers from the final paragraph", () => { + expect( + coAuthors( + "Fix the thing\n\nSome body text.\n\nSigned-off-by: A \nCo-authored-by: Alice Lidell \nCo-authored-by: Bob ", + ), + ).toEqual([ + { name: "Alice Lidell", email: "alice@lidell.com" }, + { name: "Bob", email: "bob@example.com" }, + ]); + }); + + test("is case-insensitive", () => { + expect( + coAuthors("Subject\n\nCO-AUTHORED-BY: Bob "), + ).toEqual([{ name: "Bob", email: "bob@example.com" }]); + }); + + test("ignores a trailer that is not in the last paragraph", () => { + expect( + coAuthors( + "Subject\n\nCo-authored-by: Bob \n\nThat line was quoted, not a trailer.", + ), + ).toEqual([]); + }); + + test("keeps the last paragraph rule for CRLF messages", () => { + expect( + coAuthors( + "Subject\r\n\r\nCo-authored-by: Bob \r\n\r\nThat line was quoted, not a trailer.\r\n", + ), + ).toEqual([]); + }); + + test("reads a trailer that carries no name", () => { + expect(coAuthors("Subject\n\nCo-authored-by: ")).toEqual([ + { name: "", email: "bob@example.com" }, + ]); + }); + + test("ignores malformed lines", () => { + expect( + coAuthors( + "Subject\n\nCo-authored-by: Bob\nCo-authored-by:\nNot-a-trailer: x ", + ), + ).toEqual([]); + }); + + test("returns nothing when there are no trailers", () => { + expect(coAuthors("Subject\n\nJust a body.")).toEqual([]); + }); +}); + +describe("parseRepositoryId", () => { + test("parses a RID with and without the rad: prefix", () => { + expect(parseRepositoryId(rid)).toEqual({ + prefix: "rad:", + pubkey: "z3fpY7nttPPa6MBnAv2DccHzQJnqe", + }); + expect(parseRepositoryId("z3fpY7nttPPa6MBnAv2DccHzQJnqe")).toEqual({ + prefix: "rad:", + pubkey: "z3fpY7nttPPa6MBnAv2DccHzQJnqe", + }); + }); + + test.each([ + "", + "rad:", + "rad:z3fpY7ntt", + `${rid} `, + "rad:x3fpY7nttPPa6MBn", + // Base58 has no 0, O, I or l. + "rad:z0OIl", + ])("rejects %j", input => { + expect(parseRepositoryId(input)).toBeUndefined(); + }); +}); + +describe("formatRepositoryId", () => { + test("truncates a valid RID", () => { + expect(formatRepositoryId(rid)).toBe("rad:z3fpY7…zQJnqe"); + }); + + test("returns an invalid RID unchanged", () => { + expect(formatRepositoryId("not-a-rid")).toBe("not-a-rid"); + }); +}); + +describe("parseNodeId", () => { + test("parses a NID with and without the did:key: prefix", () => { + expect(parseNodeId(nid)).toEqual({ prefix: "did:key:", pubkey: nid }); + expect(parseNodeId(`did:key:${nid}`)).toEqual({ + prefix: "did:key:", + pubkey: nid, + }); + }); + + test("rejects a RID", () => { + expect(parseNodeId("z3fpY7nttPPa6MBnAv2DccHzQJnqe")).toBeUndefined(); + }); + + test("rejects input that is not base58", () => { + vi.spyOn(console, "error").mockReturnValue(undefined); + expect(parseNodeId("z0OIl")).toBeUndefined(); + }); +}); + +test("truncateDid", () => { + expect(truncateDid(`did:key:${nid}`)).toBe("did:key:z6MkqG…WTvLSK"); +}); + +test.each([ + ["refs/heads/main", "main"], + ["refs/heads/feature/x", "feature/x"], + ["main", "main"], + ["refs/tags/v1", "refs/tags/v1"], +])("unqualifyBranch(%j) is %j", (input, expected) => { + expect(unqualifyBranch(input)).toBe(expected); +}); + +describe("formatTimestamp", () => { + const now = Date.UTC(2026, 0, 1); + const minute = 60 * 1000; + const hour = 60 * minute; + const day = 24 * hour; + + test.each([ + [0, "now"], + [59 * 1000, "now"], + [minute, "1m"], + [59 * minute, "59m"], + [hour, "1h"], + [23 * hour, "23h"], + [day, "1d"], + [30 * day, "30d"], + [31 * day, "1mo"], + [364 * day, "11mo"], + [365 * day, "1y"], + [3 * 365 * day, "3y"], + ])("%j ms ago is %j", (elapsed, expected) => { + expect(formatTimestamp(now - elapsed, now)).toBe(expected); + }); +}); + +test.each([ + ["issue", 0, "issues"], + ["issue", 1, "issue"], + ["issue", 2, "issues"], + ["patch", 2, "patches"], + ["box", 2, "boxes"], + ["class", 2, "classes"], +])("pluralize(%j, %j) is %j", (singular, count, expected) => { + expect(pluralize(singular, count)).toBe(expected); +}); + +test("revisionPosition", () => { + const revisions = [{ id: "a" }, { id: "b" }, { id: "c" }]; + expect(revisionPosition(revisions, "a")).toBe(1); + expect(revisionPosition(revisions, "c")).toBe(3); + expect(revisionPosition(revisions, "x")).toBeUndefined(); +}); + +describe("identityKey", () => { + test("matches identities by email case-insensitively", () => { + expect(identityKey({ name: "Alice", email: " Alice@Example.com " })).toBe( + identityKey({ name: "A. Lidell", email: "alice@example.com" }), + ); + }); + + test("falls back to the name when there is no email", () => { + expect(identityKey({ name: "Alice", email: "" })).toBe("name:alice"); + expect(identityKey({ name: "Alice", email: "" })).not.toBe( + identityKey({ name: "Bob", email: "" }), + ); + }); +}); + +describe("explorer links", () => { + function config(overrides: Partial): Config { + return { + publicExplorer: "https://radicle.network/nodes/$host/$rid$path", + preferredSeeds: [], + ...overrides, + } as Config; + } + + const repo = { type: "uri", uri: { repo: rid.slice(4) } } as const; + + test("uses the first preferred seed host", () => { + expect( + explorerLink( + repo, + config({ + preferredSeeds: [ + `${nid}@seed.example.com:8776`, + `${nid}@other.example.com:8776`, + ], + }), + ), + ).toBe(`https://radicle.network/nodes/seed.example.com/${rid}`); + }); + + test("falls back to the default seed", () => { + expect(explorerLink(repo, config({}))).toBe( + `https://radicle.network/nodes/rosa.radicle.network/${rid}`, + ); + }); + + test("explorerHost returns the host of the template", () => { + expect(explorerHost(config({}))).toBe("radicle.network"); + }); + + test("explorerHost returns an unparseable template unchanged", () => { + expect(explorerHost(config({ publicExplorer: "not a url" }))).toBe( + "not a url", + ); + }); +}); + +describe("isPublishableReview", () => { + test.each([ + ["accept", "", true], + ["reject", " ", true], + [undefined, "Looks fine", true], + [undefined, " \n", false], + ] as const)( + "a %j review with summary %j can be published: %j", + (verdict, summary, expected) => { + expect(isPublishableReview(verdict, summary)).toBe(expected); + }, + ); +}); + +describe("commit credits", () => { + const alice = { name: "Alice", email: "alice@example.com" }; + const bob = { name: "Bob", email: "bob@example.com" }; + const carol = { name: "Carol", email: "carol@example.com" }; + + test("formatGitIdentity writes a trailer identity", () => { + expect(formatGitIdentity(alice)).toBe("Alice "); + expect(formatGitIdentity({ name: "", email: "a@b.c" })).toBe("a@b.c"); + }); + + test("creditedCoAuthors skips the author, the committer and repeats", () => { + const message = [ + "Squashed", + "", + "Co-authored-by: Alice ", + "Co-authored-by: Bob ", + "Co-authored-by: Carol ", + "Co-authored-by: Carol C. ", + ].join("\n"); + + expect( + creditedCoAuthors({ author: alice, committer: bob, message }), + ).toEqual([carol]); + }); +}); + +describe("formatBytes", () => { + test.each([ + [0, "0 B"], + [1023, "1023 B"], + [1024, "1.0 KiB"], + [1536, "1.5 KiB"], + [10 * 1024 - 1, "10.0 KiB"], + [10 * 1024, "10 KiB"], + [1024 ** 2, "1.0 MiB"], + [1024 ** 4, "1.0 TiB"], + [1024 ** 5, "1024 TiB"], + ])("%d is %j", (bytes, expected) => { + expect(formatBytes(bytes)).toBe(expected); + }); +}); + +describe("formatUptime", () => { + test.each([ + [0, "0s"], + [59.9, "59s"], + [60, "1m"], + [3599, "59m"], + [3600, "1h"], + [86399, "23h"], + [86400, "1d"], + [86400 * 400, "400d"], + ])("%d is %j", (seconds, expected) => { + expect(formatUptime(seconds)).toBe(expected); + }); +}); + +describe("shortenCids", () => { + const prefix = "bafkr4i"; + + test("returns nothing for no ids", () => { + expect(shortenCids([]).size).toBe(0); + }); + + test("keeps the minimum head when ids differ early", () => { + const a = "abcdefghij0123456789"; + const b = "zbcdefghij9876543210"; + const labels = shortenCids([a, b]); + expect(labels.get(a)).toBe("abcdefg…456789"); + expect(labels.get(b)).toBe("zbcdefg…543210"); + }); + + test("grows the head past a shared prefix until ids differ", () => { + const a = `${prefix}aaaa${"x".repeat(20)}111111`; + const b = `${prefix}aaab${"x".repeat(20)}111111`; + const labels = shortenCids([a, b]); + expect(labels.get(a)).toBe(`${prefix}aaaa…111111`); + expect(labels.get(b)).toBe(`${prefix}aaab…111111`); + }); + + test("stops growing the head at the maximum", () => { + const a = `${"p".repeat(20)}a${"x".repeat(10)}`; + const b = `${"p".repeat(20)}b${"x".repeat(10)}`; + expect(shortenCids([a, b]).get(a)).toBe(`${"p".repeat(16)}…xxxxxx`); + }); + + test("shows an id in full when head and tail cover it", () => { + const short = "abcdefghijklm"; + expect(shortenCids([short]).get(short)).toBe(short); + }); + + test("labels repeated ids once", () => { + const a = "abcdefghij0123456789"; + expect(shortenCids([a, a]).size).toBe(1); + }); +}); diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json new file mode 100644 index 000000000..bc414a460 --- /dev/null +++ b/desktop/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "include": ["src", "tests", "./*.js", "./*.ts"], + "exclude": ["node_modules/*", "isolation/*"], + "compilerOptions": { + "noEmit": true, + "target": "es2024", + "module": "es2022", + "types": ["vite/client"], + "sourceMap": true, + "moduleResolution": "bundler", + "strict": true, + "resolveJsonModule": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useDefineForClassFields": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "paths": { + "@app/*": ["./src/*"], + "@bindings/*": ["./crates/radicle-types/bindings/*"], + "@tests/*": ["./tests/*"] + } + } +} diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts new file mode 100644 index 000000000..b7fbffb3d --- /dev/null +++ b/desktop/vite.config.ts @@ -0,0 +1,95 @@ +import path from "node:path"; + +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { defineConfig } from "vitest/config"; + +// https://vitejs.dev/config/ +export default defineConfig({ + test: { + environment: "happy-dom", + include: ["tests/unit/**/*.test.ts"], + reporters: "verbose", + }, + plugins: [ + // Asciidoctor derives Node-only LIB_DIR/ROOT_DIR/DATA_DIR from + // `import.meta.url`. Those paths are unused in the browser, but Vite still + // emits the whole module as an asset, duplicating it unminified in the + // bundle. Neutralize the URL so nothing is emitted. + { + name: "asciidoctor-drop-node-paths", + enforce: "pre" as const, + transform: { + filter: { id: /@asciidoctor\/core/ }, + handler(code: string, id: string) { + const pattern = /new URL\((.*?), import\.meta\.url\)/g; + const patched = code.replaceAll(pattern, "new URL($1, `file:///`)"); + const expected = code.includes("DATA_DIR") ? 3 : 0; + const found = code.match(pattern)?.length ?? 0; + + if (found !== expected) { + throw new Error( + `Expected ${expected} import.meta.url uses in ${id}, found ${found}. ` + + `Asciidoctor may now rely on them at runtime; re-check before patching.`, + ); + } + + return found > 0 ? { code: patched } : undefined; + }, + }, + }, + // Asciidoctor's browser build lazily imports Node built-ins for file input + // and output, behind guards that tolerate them being absent. Resolve them + // to an empty module, as Vite would, without its externalization warning. + { + name: "asciidoctor-stub-node-builtins", + enforce: "pre" as const, + resolveId: { + filter: { id: /^node:/ }, + handler(source: string, importer: string | undefined) { + if (importer?.includes("@asciidoctor/core")) { + return `\0asciidoctor-node-stub:${source}`; + } + }, + }, + load: { + filter: { id: /^\0asciidoctor-node-stub:/ }, + handler() { + return "export default {};"; + }, + }, + }, + svelte({ + // Reference: https://github.com/sveltejs/vite-plugin-svelte/issues/270#issuecomment-1033190138 + dynamicCompileOptions({ filename }) { + if (path.basename(filename) === "Clipboard.svelte") { + return { customElement: true }; + } + }, + }), + ], + build: { + outDir: "build", + chunkSizeWarningLimit: 5000, + }, + // prevent vite from obscuring rust errors + clearScreen: false, + server: { + port: 1420, + strictPort: true, + watch: { + ignored: [ + "**/crates/radicle-tauri/**", + "**/tests/artifacts/**", + "**/tests/tmp/**", + ], + }, + }, + resolve: { + // Svelte's server build never runs effects, which rune tests need. + conditions: process.env.VITEST ? ["browser"] : undefined, + alias: { + "@app": path.resolve("./src"), + "@bindings": path.resolve("./crates/radicle-types/bindings/"), + }, + }, +});