commit 852cba1840d0dadfbaa3b50ff93b95ac90f5709b Author: PJ Date: Fri Aug 28 09:16:00 2026 +0530 features diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7bb0190 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v6 + with: + node-version: 26 + + - uses: pnpm/action-setup@v6 + with: + version: 10 + + - run: pnpm install --frozen-lockfile + + # `pnpm build` is tsc then vite, so this is the typecheck and the bundle in one step. + - run: pnpm build + + - run: pnpm test + + rust: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v7 + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf \ + libxdo-dev \ + libssl-dev \ + build-essential + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - uses: swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + + # tauri_build::build() wants a frontendDist that exists. The example file is what a fresh + # clone compiles against, so that is what CI uses. + - name: Stub the build inputs + run: mkdir -p dist && touch dist/index.html + + - name: Test + working-directory: src-tauri + run: cargo test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1d28646 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,168 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: "Release version, e.g. 0.2.0. Leave empty to bump the patch number." + required: false + type: string + +permissions: + contents: write + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + release_id: ${{ steps.release.outputs.release_id }} + steps: + - uses: actions/checkout@v7 + + - name: Determine version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + VERSION="${VERSION#v}" + else + CURRENT=$(jq -r .version src-tauri/tauri.conf.json) + IFS=. read -r MAJOR MINOR PATCH <<< "$CURRENT" + VERSION="$MAJOR.$MINOR.$((PATCH + 1))" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + echo "Releasing v$VERSION" + + - name: Bump version in manifests + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + tmp=$(mktemp) + jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > "$tmp" && mv "$tmp" src-tauri/tauri.conf.json + jq --arg v "$VERSION" '.version = $v' package.json > "$tmp" && mv "$tmp" package.json + sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" src-tauri/Cargo.toml + + - name: Commit and tag + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add src-tauri/tauri.conf.json package.json src-tauri/Cargo.toml + git commit -m "chore(release): $TAG" + for attempt in 1 2 3 4 5; do + git fetch origin main + git rebase origin/main + if git push origin HEAD; then + break + fi + if [ "$attempt" = "5" ]; then + echo "::error::main kept advancing; could not push release bump after 5 attempts." + exit 1 + fi + echo "main advanced during release; rebasing and retrying ($attempt)…" + sleep 3 + done + git tag "$TAG" + git push origin "$TAG" + + - name: Create draft release + id: release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + run: | + gh release create "$TAG" --draft --title "Margin Docs $TAG" --notes "Release $TAG" + ID=$(gh release view "$TAG" --json databaseId --jq .databaseId) + echo "release_id=$ID" >> "$GITHUB_OUTPUT" + + build: + needs: prepare + strategy: + fail-fast: false + matrix: + include: + - os: macos-26 + args: "--target universal-apple-darwin --config src-tauri/tauri.release.conf.json" + rust-targets: "aarch64-apple-darwin,x86_64-apple-darwin" + # Ubuntu 22.04 is the glibc baseline: the bundle will not run on anything older than the + # glibc it was linked against, so build on the oldest supported. + - os: ubuntu-22.04 + args: "--config src-tauri/tauri.release.conf.json" + rust-targets: "" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - name: Install Linux dependencies + if: startsWith(matrix.os, 'ubuntu') + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf \ + libxdo-dev \ + libssl-dev \ + build-essential \ + curl \ + wget \ + file + + - uses: actions/setup-node@v6 + with: + node-version: 26 + + - uses: pnpm/action-setup@v6 + with: + version: 10 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rust-targets }} + + - uses: swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + - name: Build and upload + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + with: + releaseId: ${{ needs.prepare.outputs.release_id }} + args: ${{ matrix.args }} + + publish: + needs: [prepare, build] + runs-on: ubuntu-latest + steps: + - name: Verify manifest is complete, then publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + TAG: ${{ needs.prepare.outputs.tag }} + run: | + gh release download "$TAG" --repo "$REPO" --pattern latest.json --output latest.json --clobber + echo "Platforms in latest.json:" + jq '.platforms | keys' latest.json + for key in darwin-aarch64 darwin-x86_64 linux-x86_64; do + if ! jq -e ".platforms[\"$key\"].url" latest.json > /dev/null; then + echo "::error::latest.json is missing platform '$key': refusing to publish a partial update manifest. Re-run the release." + exit 1 + fi + done + gh release edit "$TAG" --repo "$REPO" --draft=false --latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be74383 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Tauri build output +src-tauri/target/ +src-tauri/gen/schemas/ + +# Xcode build output. The project under src-tauri/gen/apple is generated but committed, the +# products of building it are not: Externals holds the static Rust library per architecture and +# runs to several hundred MB. +src-tauri/gen/apple/build/ +src-tauri/gen/apple/Externals/ +src-tauri/gen/apple/Pods/ +src-tauri/gen/apple/Podfile.lock +xcuserdata/ + +# Screenshots & Playwright MCP artifacts +.playwright-mcp/ +/*.png + +# Signing keys for the updater. Never committed. +.env +.env.* +!.env.example diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..80d92ee --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Priyanshu Jain + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..699812c --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Margin Docs + +A local first WYSIWYG editor for the plain markdown files already on your disk, macOS only. +Markdown syntax is never visible, and nothing is written to your folders but the markdown itself +and the images you paste into it. + +It is a sibling to [margin](https://github.com/priyanshujain/margin) and +[margin-calendar](https://github.com/priyanshujain/margin-calendar) and shares their stack and +their visual language. + +Setup is in [docs/setup.md](docs/setup.md). How releases are cut is in +[docs/release.md](docs/release.md). The product decisions are in [docs/design.md](docs/design.md), +how it is built is in [docs/architecture.md](docs/architecture.md), and the conventions it follows +are in [docs/conventions.md](docs/conventions.md). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f07885e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,432 @@ +# Architecture + +Tauri 2, React 19, Vite, TypeScript and zustand on the front, Rust behind. Same stack as margin and +margin-calendar, which means the build and bundle setup, the token layer and the TipTap editor core +carry over from margin rather than being invented again, while the zustand and CSS conventions +carry over from margin-calendar, the newer and more disciplined of the two scaffolds. + +The split is strict. Rust owns the filesystem: reading and writing documents, walking and watching +the tree, the SQLite index, moving files to the trash, and atomic writes. TypeScript owns +everything above that: the markdown bridge that turns file bytes into an editable document and +back, the WYSIWYG editor itself, and all rendering. Rust never parses markdown and TypeScript never +touches the filesystem directly; everything between the two crosses through the `dto.rs`/`ipc.ts` +contract described below. + +## The markdown bridge + +The bridge is built on `remark-parse` and `remark-stringify` through `unified`, not on +`@tiptap/markdown`, TipTap's own markdown extension. Two things rule that out. `@tiptap/markdown` is +built on `marked`, which has no frontmatter support, and frontmatter here is not optional: it has to +be parsed, hidden from the editor, and written back unchanged on every save. More fundamentally, a +`marked` AST does not carry source positions, while every node `remark-parse` produces carries a +`position` with byte offsets into the original file. That offset is the whole trick the rest of this +document depends on: when a piece of syntax has no model in the editor, the bridge does not +reconstruct its text from a generic AST node, it slices the exact bytes out of the original source +between `position.start.offset` and `position.end.offset` and carries that slice forward untouched. +Writing back byte identical is a property of the source string, not of the printer, and only an AST +that remembers where it came from can hand that string back. + +## Where a hand wrapped line lives + +Most markdown in the world is hard wrapped: a paragraph is several lines in the file and one +paragraph on screen, and the author chose where those lines end. That choice is theirs and this +editor does not get to reflow it, so a soft line break survives the round trip as a real newline in +the paragraph's text rather than being folded into a space. + +Keeping it takes three declarations that have to agree, and it is worth naming all three because +each one on its own looks like an optimisation somebody could remove. `src/markdown/parse.ts` puts +the newline in the text. `prose.css` draws a paragraph `pre-wrap` so it is on screen where the +author put it. `src/model/schema.ts` declares the paragraph `whitespace: "pre"`, and that is the one +that was missing: two libraries read that field and both of them rewrite the paragraph without it. +`prosemirror-view` reads it to decide how to parse the editor's own DOM back after a keystroke, and +at the default every soft wrap in the paragraph being typed into came back as a hard break, so one +character typed into a hand wrapped file put a backslash at the end of every line in that paragraph. +`prosemirror-transform` asks the same field before it joins two blocks, so Backspace between two +hand wrapped paragraphs rewrote the wraps in both. + +The paragraph's parse rule says the opposite on purpose, and a rule's own answer outranks the +node's. Whitespace is significant in a paragraph this editor rendered, and meaningless in a `

` +off somebody else's web page, where the line endings are that page's source indentation and keeping +them would put breaks and runs of spaces through the middle of a pasted sentence. + +Only a running editor can prove any of this: the fault is between the keystroke and the serializer, +and the serializer never saw it. `tests/bytes.spec.ts` is where that proof lives. + +## Callouts, toggles and unknown syntax + +On disk a callout is a GitHub alert: a blockquote whose first line reads `[!NOTE]`, `[!WARNING]`, +`[!IMPORTANT]`, `[!TIP]` or `[!CAUTION]`. `remark-parse` hands back an ordinary `blockquote` node for +these, so a visitor walks the tree afterward, recognises the marker in the first child, tags the +node with its variant and strips the marker before the editor ever sees it. Serialization reverses +exactly that: re-emit the blockquote and put the marker back as its first line. + +A toggle is `

` and ``, and remark does not parse HTML blocks into a tree at all; +it hands them back as opaque `html` nodes carrying raw text. The bridge looks for a matching +`details`/`summary` pair in that raw text, and where it finds one it lifts the summary and the body +into a toggle node the editor can open and close like a native control. A `details` block that does +not match that exact shape, nested oddly, missing a summary, carrying attributes the editor has no +model for, is left exactly as it was found. Everything else the parser cannot classify, an HTML +block that is not a details/summary pair, a construct remark itself does not model, a piece of +syntax nobody has written a visitor for yet, becomes a raw node under the rule in +[conventions.md](conventions.md): the literal source slice, editable as monospace text, written +back unchanged. Nothing is ever silently reformatted or dropped. + +## Tables, maths and the rest of what the bridge learned to model + +The first pass over the bridge modelled prose and left everything structural as a raw node. A GFM +table, a `$$` math block and a `
` toggle were all bytes the editor showed and refused to +touch. Modelling them is what turns those blocks from something the editor carries into something +it edits, and every one of them was built as the exact inverse of the writer that puts it back: the +first mdast row becomes header cells and the delimiter row's per column alignment is copied on to +every cell of that column, because markdown has no way to say that one cell is centred and the rest +of its column is not. That inverse relationship is the whole test: a construct is modelled only +where parsing it and writing it back is the identity, and the parser refuses everything else. + +The refusals are the interesting half and they are all the same shape. A table row with more cells +than its header has no column to put them in, so there is no answer between dropping the cells and +inventing a column, and the block stays raw. A toggle whose summary does not survive being unescaped +and escaped again character for character stays raw. A math fence that runs to the end of the file +unclosed stays raw, because writing it back would invent the closing delimiter. So does a carriage +return anywhere inside any of the three, since remark reads a lone `\r` as a line ending and the +writer never puts one back. In every case the file keeps its exact bytes and the user gets an +editable monospace block instead of a rich one, which is the trade this project has always made. + +Modelling a table does cost one thing, and it is on the record rather than hidden. The house style +writes a table with its cells padded out to the width of the column, and a table written compact by +hand is repadded by the save that settles the file. Nothing else about it moves, no cell content +changes, and the second save is byte identical, which is the same one time normalisation the house +style has always applied to a setext heading or a tilde fence. The alternative would be a `source` +attribute on the table node holding the author's spelling, and that cannot work: the schema is +frozen, and the editor rebuilds every node from JSON when it installs a document, so the node the +writer sees is never the node the parser made. `roundtrip.test.ts` names the two corpus files this +affects and asserts, cell by cell, that the padding is the only thing that changed. + +## What the writer proves before it writes + +mdast writes the tree, so escaping, fence lengths and list indentation are decided by the library +that read the file rather than by string concatenation. What mdast cannot decide is whether this +reader will hand the block back as the block the document holds, and several spellings turn on +exactly that. A url the file wrote bare should stay bare, not become `[https://x](https://x)` and +put a diff in front of somebody who edited a different paragraph. A code span or an equation the +file wrapped across two lines should keep its line ending rather than have it swapped for a space. +A `` should keep the line endings the file gave it. None of those is safe on the strength +of a regular expression here, because GFM's literal autolink grammar has more corners than one, and +every corner that was got wrong cost a destination. + +So the writer proves rather than assumes, and it proves the same way every time: write the block +out, read it back with the pair the app opens files with, and compare. There are two ladders. The +outer one is about the block, and it has three rungs, from writing every line ending where the +document puts it, through handing the spans back to mdast, to writing the two a text node cannot +hold as ` ` and ` `. The inner one is about a url and also has three, shortest first: the +url on its own, ``, then `[url](url)`, which has no grammar left to get wrong. The last rung of +the outer ladder is written whether it verifies or not, because a document holding something +markdown cannot spell still has to be saved and the one thing that must not happen is a file that +gains bytes on every save for the rest of its life. + +The comparison is against the ProseMirror node, not against the mdast tree the block was built +from, and that is the whole point of it. The two trees disagree in ways that mean nothing: remark +reads an item's `spread` off the blank lines inside it while the writer sets it from the list, and a +parser extension hangs its own fields on the nodes it made. Every one of those differences is a +comparison that fails on a block that is perfectly fine, and each one was a rung of this ladder +quietly switched off. The document is the thing that has to survive, so the document is what is +compared. + +The comparison itself is by name, not by object. The block the writer built and the block it reads +back are bound to two different `Schema` instances over one set of specs, so every `NodeType` and +every `MarkType` in one is a different object from its twin in the other, and `Node.eq`, which +compares them by identity, answered "different" for every block it was ever given. That was the +whole ladder switched off at the top rung, and `src/document.ts` has the same comparison for the +same reason. + +Two questions about a file are not questions about any block in it, and each has a check of its +own because the ladder cannot see either. The seam between two blocks is one: a blank line closes +every construct markdown has except a list, which carries on over one and swallows the block below, +so a list written next to preserved source is proved apart and respelled, with a wider item indent +or the other bullet, until it is. + +Those respellings cover every raw block a file can hand over, because a file's own raw block starts +in the first three columns and column four is indented code. An edited one can be anything, and for +some of them no spelling exists at all: four spaces typed into a raw block below a list makes bytes +that no seam can hold, since they read back as a code block wherever they are put. So the seam has a +last resort. When a list beside a raw block has no spelling left, the raw block is written from the +bytes the file gave it and the edit does not reach disk that save. That is the one place this editor +knowingly drops something the user typed, and it is the right way round: the alternative measured on +the same file swallowed the list into the raw block and then moved bytes around inside somebody's +html on the save after. A raw block with no list beside it is written exactly as it always was. + +Where the body starts is the other: `---` on the first line of a +file is not a rule, it is the opening delimiter of frontmatter, and everything down to the next one +stops being markdown. That check is asked only of a body whose first three characters could open a +delimiter, and only when the body really is at the first byte of the file. A body with frontmatter +coming in front of it was never in danger, and running the defence there did the damage it exists +to prevent, because the blank line it pushes the body down by is handed back as part of the +frontmatter and written again on the next save. `serializeMarkdown` is the one thing that knows +whether a prefix is coming, so it is what tells the body writer. + +One inline shape has no spelling either, and the writer settles it rather than the editor. A hard +break is a line ending inside a block, and there is no line left for one to start at the end of a +block, so a break with nothing under it is written as nothing. It used to be written as a backslash +on a line of its own, which reads back as a literal backslash in the user's words: a character they +never typed. That answer is in the serializer and nowhere else on purpose, because the trailing +break has more than one way in. Shift+Enter at the end of a paragraph is the obvious one, and is +still allowed, since it is a line somebody is halfway through typing and the next character makes it +a real break. Deleting the text out from under a break that was legal where it was put is the other, +and no keystroke guard sees that one at all. The editor refuses only the level 1 and 2 heading, +where a trailing break also takes the setext underline with it, and that refusal is about the +marker, not about the backslash. + +Reading costs about eight times what writing does, and this editor saves half a second after the +user stops typing, so neither ladder runs on a block that has nothing in it to get wrong. A block +reaches the outer one only when it holds a line ending somewhere markdown has no way to write one, +or bytes mdast did not choose, and it reaches the inner one only when it holds a url that could be +written short. On the real corpus and on a 200 KB document of ordinary prose that is no blocks at +all: a save is 24 ms and no reparse happens. The shape that pays is a document with a bare url in +every paragraph, which costs two trips through the parser per block. Measured in Chromium on three +large real files, a save is 26 ms for the 179 KB yt-dlp README, 56 ms for the 266 KB +opentelemetry-js changelog and 91 ms for the 251 KB node changelog, each byte identical on the +second save. + +## The block lanes + +A node's shape is in the frozen schema and is turned into a TipTap extension mechanically. What a +node does, its ProseMirror plugins, its node views, its keymap and its input rules, is not derivable +from a shape and does not belong on a generated extension, so each of the five blocks with real +behaviour is one extension in one file, listed once in a registry that the extension list spreads +without knowing what is in it. Tables, syntax highlighting, maths, mermaid and toggles are then five +files that can be worked on at once, each exporting its extension and whatever commands the editor +handle delegates to when a toolbar button is pressed. + +The toggle is the one whose behaviour is mostly outside ProseMirror. It is a real `
`, and +its summary row is chrome rather than content: the row is declared not editable, the title inside it +is declared editable again, and every keystroke in that island is turned into an ordinary +transaction that writes the `summary` attribute. Native disclosure is cancelled and `open` is +written by a command for the same reason, since both of those are bytes in the file and a browser +flipping an attribute behind the document's back is a title or a state the next save has to guess +at. Cancelling the click is not the whole of it either: a disclosure is a control, so the browser +works one from the keyboard too and sends the row a click for every space typed anywhere inside it, +which was every other space in a title flipping the toggle and writing `open` to disk. The flip asks +for a pointer press, or for the row itself holding the keyboard, and takes nothing else as consent. + +Being chrome has a second consequence that reaches further than the node. A caret in the title is +not in the document at all, so ProseMirror's selection stays wherever it last was, and a toolbar +button pressed while a title is being typed runs its command against a paragraph the user is not +looking at. Rather than give the title a selection, which makes Horizontal rule and Insert image +replace or split the toggle, the file refuses: a transaction that changes the document is thrown out +while a title holds the caret, bar the two the title's own surface makes, and the caret is put back +in the title on the frame after TipTap's own focus lands. The toolbar draws itself disabled for the +same condition, read off the page rather than off a transaction, because clicking into a title +dispatches none. + +And a toggle only ever sits among the document's own children, because that is the only place the +bridge pairs one. A `
` inside a quote or a list item goes to disk as a `
` inside +that container and comes back as a single raw block: the bytes survive, and both constructs stop +being editable. So a transaction that puts a toggle below the top level is refused, and the ranges +it is asked about are widened to the whole top level block first, since a wrap rewrites only the two +markers it puts either side and the thing it moved a level down is in the gap between them. + +The order in that registry is load bearing rather than alphabetical. ProseMirror resolves a node +view first plugin wins by node name and gives a node view constructor no way to decline, and a +mermaid diagram is not a node of its own but a code block whose language happens to be `mermaid`. So +mermaid owns the code block node view outright and draws an ordinary fence for every other language, +while the highlighter contributes decorations only, which is what it wanted anyway: highlighting is +inherently a decoration and never a transaction that touches the text. + +Two rules cut across all five. A command that has nothing to act on where the cursor is returns +false and does nothing, which is what a button pressed in the wrong place should do. And anything +that puts a node in the document asks first whether a node can go there at all: a table cell holds +inline content and is isolating, and ProseMirror asked to insert a block into one will split the +table around it and leave a row with no cells behind, which is a table the writer turns into three +blank lines. A fence and a raw block are the same question from the other side, since both hold +bytes that are the user's and an insert cuts them in half. + +That guard is `src/editor/fits.ts`, and it now answers two questions rather than one, because the +first one turned out to be half the problem. + +The first is about a node: can this thing go here. `fits` answers for one position and is the rule +itself. `placeable` asks it for a whole selection, which is the question a command actually has: a +selection has two ends, and a rectangle of table cells dragged out has neither of them anywhere a +caret would be, so it is refused outright whatever the type. An inline formula fits a cell perfectly +happily, which is right for a caret and catastrophic for a drag, and that gap is how one insert +emptied six cells of somebody's table. `place` is the guard and the insert in one call, so a command +written through it has no insert in it to forget to guard. + +The second is about a command: may this edit happen here at all. Nothing asked that for two +milestones, and a block conversion is where it showed: `setNode` asks whether the new type fits and +never asks what the old block was, so the Heading tool rewrote a raw block's own bytes as escaped +markdown, and the Paragraph item, which TipTap answers by lifting once the block is already a +paragraph, deleted the callout around the caret along with its label. `change` is that half. It +refuses over a raw block outright, and otherwise it builds the transaction without dispatching it, +looks at what it did, and throws it away when a callout or a toggle that was there is gone, or when +a finished line break has landed somewhere the file swallows. Answering on the finished transaction +rather than on the command is the point: there is no list here of which commands lift, so a command +that starts lifting in a later TipTap is refused on the day it does. Its one exemption is the button +that names the wrapper, since the Toggle button pressed inside a toggle is meant to remove it. +`markable` and `breakable` are the same shape for the Link tool and for Shift+Enter. + +Four things reach these guards from outside the toolbar. A pasted image asks before the bytes are +sent to be written, so a paste that cannot land leaves nothing in the assets folder, and again after +the write returns, because the caret is the user's during a round trip to disk; the Insert image +tool asks the same question through `canInsertImage`, since it has to write the picture before it +has a path to insert. A plain text paste is not refused anywhere, since text goes everywhere, but +where paragraphs would tear the block open it arrives as text instead, and a drop of blocks where +blocks do not fit is refused outright. And "--- " typed at the start of a line is the one typing +rule that inserts a node beside the caret rather than wrapping or retyping the block the caret is +in, so it is the one typing rule that has to ask; the others are operations ProseMirror declines on +its own. + +`src/editor/fits.test.ts` is what holds all of that together, and it enumerates rather than lists. +It asks the running editor for its entry points: every method on the editor handle, off +`createCommands`; every method on the find handle, off `createFind`; every chord, read off every +extension's `addKeyboardShortcuts` the way the extension manager reads them; every ProseMirror prop, +read off every extension's plugins; and every prop the component puts straight on the view, off +`createEditorProps`. That last channel is neither an extension nor a handle and was invisible to the +enumeration until it was lifted out of the component, which is exactly the shape of gap this file +exists for. Each entry declares a family and each hostile context answers for every family, so a new +family forces a column and a new context forces a row, both at compile time. Three checks then apply +to every cell whatever its row says: the number of raw blocks is unchanged, no callout or toggle is +lost by anything not asked to lose one, and no toggle ends up below the top level. + +Nothing in that file calls a handler. Every entry point goes through `EditorView.prototype.someProp`, +borrowed off prosemirror-view rather than modelled here, because the walk it does is the question: +the props the component put on the view, then the direct plugins, then the state's plugins in order, +first answer that is not false. Calling a guard by name and asserting what it answers proves nothing +about whether the running program asks it, and four guards in this project's history were shipped +with a green test of exactly that shape. What the unit suite still cannot do is construct a view at +all, because vitest runs in node with no DOM, so the event object and the parsed slice are built by +hand there. `tests/clipboard.spec.ts` is the other half: a real `EditorView` in Chromium, a real +mouse drag across a rectangle of cells, a real Cmd+V off the system clipboard, and assertions only +about what is on screen afterwards. It is the only place in the repository where the whole chain +from a key to a byte is real. + +Where a guard sits in the plugin list is part of the guard. ProseMirror offers an event to the +plugins in order and takes the first answer that is not false, and prosemirror-tables installs a +paste handler that claims every paste made while a rectangle of cells is up, replacing the content +of every cell in the rectangle with whatever the slice holds. It sat at index nine and this app's +clipboard plugin sat at fifteen, so for two rounds the app's paste guard was written, documented, +unit tested and asked about nothing. One word on the clipboard replaced six cells; an image, which +carries no HTML for ProseMirror to parse and so arrives as the empty slice, emptied them. The +clipboard extension now declares a priority above every other extension in the tree, which puts it +at the head of the list whatever order the extension file lists things in, and being first is +asserted rather than believed: `src/editor/fits.test.ts` reads the built plugin list, computes the +index of every plugin claiming a paste or a drop, and fails if anything is in front. Being first +also means standing aside deliberately, for the one paste the library does better: cells copied out +of a table and pasted into one, recognised with prosemirror-tables' own predicate rather than a +reimplementation of it. Emptying cells is a real thing to want and it is Backspace. + +There is no table op for the header row for a related reason. GFM has exactly one header row, it is +the first one, and there is no spelling for a table without one, so a toggle would offer an edit the +file cannot hold and the next open would silently take back. The same rule is why a new formula is +created holding a placeholder rather than nothing: an empty formula is `$$$$` on disk, which reads +back as four characters of text, so a box the editor draws and the file cannot hold is a box that +disappears on the next save. + +Not every change to the document is a change to the file, and the block layer is where that first +became true. Dragging a column edge writes a width on to every cell in the column, which is a real +ProseMirror transaction and marks the buffer dirty, and GFM has nowhere at all to put a column +width. Sniffing for that particular transaction would be fragile, so the question is asked of two +trees instead: a walk comparing type, text, marks, child count and attributes, skipping the handful +of attributes the serializer never reads. It short circuits on identity, which is every subtree a +transaction did not visit, so it costs nothing per keystroke. The allowlist is deliberately short +and each entry has its reason written beside it, because an attribute wrongly called insignificant +is a user edit that never gets saved. + +Types are compared by name and marks by name and attributes, never by object, and that is not a +shortcut. The two trees this walk is handed are never built on the same schema: the disk side comes +off the bridge, which parses against the frozen schema, and the live side is bound to the schema +TipTap generates from those same specs and which the editor rebinds every opened document on to. +Two instances over one set of specs means every type object in one differs from its twin in the +other, so `a.type !== b.type` and `Mark.sameSet` were both true of every pair this function had +ever been given, and everything behind them, the attribute allowlist included, was unreachable. +Name is also the right thing to compare rather than a way around that: the serializer dispatches on +`node.type.name` and `mark.type.name` and reads nothing else off a type. + +Which two trees is the part that had to be got right twice. Asking whether this keystroke changed +anything since the last one makes dirty a count of transactions rather than a fact about the file: +the answer can only ever go up, so an edit and its undo inside one debounce still wrote, and on a +hand written file the save path's own byte comparison cannot catch it, since that file's serialized +bytes differ from its own bytes from the moment it opens. So `src/document.ts` keeps `diskDoc` +beside `diskText`, the tree whose serialization is the bytes currently on disk, and +`differsFromDisk` asks the question of that rather than of the previous keystroke. The two move +together through one function, so they cannot drift into one guard sending a write and the other +holding it back. Behind that the save path still serializes and compares the result against the +bytes it last saw on disk, which catches an edit that really did happen and really did come out +identical. Between them they are what stops a gesture that moved a line on screen, or a typo typed +and taken back, from putting a whole file diff in somebody's git status. + +## Mermaid, and what is not in the main bundle + +Mermaid is about 700kB before its per diagram chunks, which are several megabytes between them, and +almost no document has a diagram in it. It is imported dynamically the first time a diagram is +actually drawn, not when one is merely present with the caret inside it, so the library is a chunk +of its own and the main bundle contains none of it. Rendering is asynchronous, so the node view +draws the fence first and swaps the SVG in when it arrives, and it does that without dispatching a +transaction: nothing mermaid does reaches the document, and a diagram that fails to parse stays as +the code the user typed with the parser's complaint beside it. Nothing it needs is fetched over the +network, which matters because the app runs under a strict CSP with no `connect-src` for anything +but the Tauri IPC. KaTeX is the opposite trade and is bundled outright, since a formula is small, +common, and has to render synchronously; its fonts are emitted as files rather than inlined, because +the CSP's `font-src` is `self` and refuses the `data:` URI a small enough font would otherwise +become. + +## The dto.rs and ipc.ts contract + +Rust and TypeScript meet at exactly one seam: `src-tauri/src/dto.rs`, a set of +`#[serde(rename_all = "camelCase")]` structs, and its hand-written mirror `src/ipc.ts`. Both sides +are built against that shape rather than against each other, which is what lets the filesystem +layer and the markdown bridge be built independently of one another: the tree, the read and write +commands and the index queries only need their DTO agreed, not implemented, before anything above +them is built against a stand-in of the same shape. The file is frozen by convention: a command's +Rust body can change freely, but adding, renaming or retyping a field is a decision made once, in +both files, in the same change. + +## The dev IPC mock + +`pnpm dev` runs the Vite dev server on its own, with no Tauri process behind it and no +`window.__TAURI_INTERNALS__` for `@tauri-apps/api` to find. `src/ipc.ts` checks for that bridge and, +when it is absent, swaps in a mock implementation of the same command surface backed by an +in-memory fixture, rather than throwing or leaving the screen blank. That is what lets the +Playwright suite in `tests/` (`pnpm test:ui`) drive the real editor, the real store and the real +components in plain Chromium without a compiled Rust binary anywhere in the loop, and it is also +what makes iterating on the editor fast day to day: no Rust recompile between changes. The mock is +exactly as wide as `dto.rs`, never wider, so a command the mock cannot answer is a command that has +not been added to the contract yet, not a gap in the mock to be patched around. + +That fixture is also how a test reads what a save actually wrote. `tests/disk.ts` puts the shim and +the fixture behind one import, and `tests/bytes.spec.ts` uses it to type one character into a +running editor and then ask the file what happened. Every data loss bug this project has had lived +between the keystroke and the serializer, where a unit test that builds a document by hand cannot +see it, so that suite is deliberately about bytes and deliberately short: one test per way the app +has been caught rewriting something nobody touched. + +The corpus under `src/markdown/corpus/` is globbed by folder rather than by a list of folder names, +so a fixture written for one test is inside every sweep the moment it is added. That is the whole +reason a sweep is worth having, and naming folders meant the twenty hardest files in the corpus sat +outside four of the gates that were supposed to cover them. + +## The SQLite index + +SQLite through `rusqlite` with the `bundled` feature, so there is no system SQLite dependency, and +with FTS5 compiled in as a side effect of that same feature rather than a separate cargo flag. The +database lives in the app data directory, never inside any folder the user opened, and it is +derived state rather than a source of truth: every row is rebuilt from the markdown files on disk, +so deleting it costs nothing but the time to walk the open roots again. It is kept current by the +same `notify`/`notify-debouncer-full` watcher the tree uses, debounced because a git checkout or +another editor's save fires a burst of filesystem events for what is really one change. The index +answers three things a plain file tree cannot: quick open by filename and path on `Cmd+P`, full text +search across every open root on `Cmd+Shift+F`, and the backlinks section appended to a document, +a reverse lookup of every relative markdown link elsewhere that resolves to the file currently open. + +## Order of work + +The filesystem layer and the markdown bridge are the two things everything else depends on, and +neither is proven by the other, so the first milestone is Rust: roots, the tree, atomic read and +write, the watcher and trash, built from the start against a frozen `dto.rs` and `ipc.ts`, with the +dev IPC mock standing in for it on the TypeScript side. The second is the bridge itself: parsing, +the raw-node slicing that makes the round trip byte identical, frontmatter separation and +serialization, proven with round-trip tests before a single line of editor UI exists, because a +round-trip bug is obvious in a plain text diff and invisible once it is glued to a rich text view. +The third is the WYSIWYG editor: TipTap wired to the bridge, the sticky bottom toolbar, callouts, +toggles and image paste into `assets/`. The fourth is the SQLite index: the schema, the +watcher-driven indexer, quick open, full text search and backlinks. The fifth is the rest of the +shell: multiple roots open at once, the tree showing every file including the greyed-out ones that +open in the system default app, and settings and the updater. diff --git a/docs/conventions.md b/docs/conventions.md new file mode 100644 index 0000000..e2e5b24 --- /dev/null +++ b/docs/conventions.md @@ -0,0 +1,102 @@ +# Conventions + +This project is a sibling to `../margin` and `../margin-calendar` and follows their conventions +deliberately rather than inventing new ones. When something here is unclear, the answer is almost +always "do what margin does" for the editor, the token layer and the visual language, and "do what +margin-calendar does" for the scaffold underneath: the store, the CSS rules and the shape of the +IPC boundary. + +## Rust + +`Result` everywhere. No `anyhow`, no custom error enums. + +DTOs crossing the IPC boundary live in `src-tauri/src/dto.rs` and are marked +`#[serde(rename_all = "camelCase")]`. That file is the contract and is frozen: implementation +modules add bodies, not fields. Its mirror is `src/ipc.ts`. + +Every write to a document is atomic: write to a temp file beside it, flush, then rename over the +original. A crash or a full disk must never leave a half-written file sitting where the user's file +used to be. Deletions go through the `trash` crate, never `fs::remove_file`, since these are the +user's own files and this app does not get to be the reason one of them is gone for good. + +Comments are rare and explain why, never what. + +## TypeScript + +One zustand store per domain in `src/store/`. No middleware. One selector call per field +(`useThing((s) => s.field)`, never a destructured object), actions as inline arrow properties, and +`set((s) => ...)` returning `{}` to no-op. + +Async actions use a string phase union, never boolean loading flags. Errors stringify with +`String(e)` and surface as a toast. + +Side effects that touch disk, the DOM or Tauri live in a sibling module, never inside the store. + +Typed IPC wrappers live in `src/api/`, one module per domain, one thin function per command, +mirroring `dto.rs` field for field. + +## Markdown + +The serializer has one house style, fixed in one place under `src/markdown/` and never varied per +document or per call site: one heading style, one list marker, one rule for when a link gets angle +brackets. A file saved twice with no edits in between produces byte-identical output, and a file +opened and then closed with no edits is never written at all. + +No markdown construct is ever dropped on a round trip. Anything the editor cannot model, rather +than being reformatted into the nearest thing it understands or silently discarded, becomes a raw +node: the exact source bytes, preserved and shown as an editable monospace block. Losing a +construct silently is worse than never having supported it, because the loss is invisible until the +file is opened somewhere else and the diff shows a missing paragraph. + +The editor never writes a file the user has not edited. Opening a document, looking at it, +switching away from it and closing the app again must leave the file on disk untouched, and that is +worth an actual round-trip test rather than an assumption. + +An edit that has no spelling is refused, never approximated. Where a keystroke or a paste would +produce bytes the reader hands back as a different document, the answer is to decline it and say so +in a toast, or to write the block from the source the file gave it and leave the edit off that save. +Both cost the user one gesture. Reflowing their text into the nearest thing that does have a +spelling costs them the file, and they find out somewhere else. + +## Tests + +A guard is proved by running the program, not by calling the guard. Four rounds of review found a +guard that answered correctly and was never reached, which is a guard that ships green and does +nothing, so anything that claims to refuse a gesture is asserted through the gesture: the real key, +the real paste, the real drag, in `tests/`. A unit test that builds the document by hand cannot see +what happens between the keystroke and the serializer, and that is where every data loss bug in this +project has been. + +A sweep that appends its own marker to the corpus asserts that no corpus file already contains it. +The word is otherwise a collision waiting for somebody to add a fixture, and a collided sweep does +not fail loudly, it quietly stops sweeping. + +Never weaken, skip or delete a test to reach green. A test pinning behaviour that has since changed +is rewritten to the new truth with the reason written next to it. + +## CSS + +Flat kebab-case class names, not BEM. State is a `data-*` attribute, never an `is-` class. + +Every colour, radius and size goes through a token in `src/styles/tokens.css`. If a value is not in +there, add a token rather than a literal. + +Dark mode is `data-theme` on ``, with both palettes defining an identical variable set. Never +a media query for theme. + +Transitions name explicit properties and use `var(--ease)`. Never `transition: all`. + +## Icons + +Inline Feather-style 24x24 stroke `d` strings passed to ``. There is no icon set +and no registry, and there will not be one. + +## Storage keys + +Anything in `localStorage` is prefixed `margindocs-`, following margin and margin-calendar's own +prefixes. + +## Never + +No CSS framework, no component library, no router, no zustand middleware, no directory trees in any +document, and no em dashes or en dashes anywhere, including code comments. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..946708e --- /dev/null +++ b/docs/design.md @@ -0,0 +1,66 @@ +# Design + +Margin Docs is a local first WYSIWYG editor for the plain markdown files already on your disk, +macOS only. It exists because the documents that actually matter, notes, specs, journals, project +READMEs, already live as files in folders you control, readable by every other tool you own and +tracked by git if you want that, while the editors that are pleasant to write prose in tend to +belong to a browser tab and own the content themselves: a database, a proprietary block format, a +folder of the app's own metadata sitting next to your text. Margin Docs starts from the file and +refuses to add a second copy of the content anywhere. The SQLite index it keeps is deliberately +disposable (see [architecture.md](architecture.md)), because the moment an index is required for a +file to mean anything, the file has quietly stopped being the truth. + +## The file contract + +Nothing is written into a user's folders except the markdown file itself and the images pasted into +the `assets/` folder beside it. Opening a file never writes it: reading it into the editor, looking +at it, and closing it again leaves the bytes on disk exactly as they were. This is a promise to the +user before it is an implementation detail, which is why [conventions.md](conventions.md) turns it +into a rule with a test behind it rather than an intention. + +## Why there is no slash menu and no drag handles + +Full WYSIWYG means markdown syntax is never visible, but it does not mean the editor secretly +models the document as a stack of blocks you assemble one command at a time. A slash menu and a +drag handle both come from that block-based idea of a document, and a markdown file is not one: it +is prose with headings, lists and the occasional table, written top to bottom the way you would +type it into any text editor. Reaching for a menu to insert a callout is slower than typing the +paragraph and toggling it from the toolbar, and a drag handle implies blocks are things you +rearrange as objects, which is a different mental model from writing, and the wrong one for a tool +whose whole pitch is that the file underneath stays exactly as legible as it always was. + +## Why the toolbar is a permanent pill at the bottom + +A toolbar that appears only on selection hides its own existence until you have already selected +something, which is backwards for someone who does not yet know a feature is there. A toolbar +fixed to the top competes with the title and the frontmatter for the same strip of attention a +document opens with. The pill at the bottom is a stable landmark instead: always in the same place, +never jumping to follow the selection, out of the way of what you are reading, and close to where a +trackpad or a thumb already is. + +## Why the filename and the H1 are unrelated + +A markdown file's identity outside this app is its path. Git tracks it by path, every other editor +opens it by path, and a relative link from another document points at that path, not at whatever +the first heading happens to say today. Treating the H1 as the filename, the way some note apps do, +means every edit to a title is secretly a rename, and a rename nothing else agreed to breaks every +link that pointed at the old path and confuses git into showing a delete and an add instead of an +edit. Margin Docs keeps the two separate and never renames a file behind the user's back: the H1 is +content, the filename is identity, and conflating them only looks harmless until real folders and +real links are involved. + +## Why one document at a time + +One editor instance, one parsed frontmatter, one set of raw nodes, one dirty flag. A tab bar would +let a document sit half-edited in the background, invisible, while attention moved elsewhere, which +is exactly the kind of silent state the file contract above is trying to rule out. Multiple roots +open at once is about how much of the disk you can see; one document open at a time is about how +much of it you are allowed to be quietly changing. + +## Visual language + +Lifted from margin unchanged, the same way margin-calendar's is: warm paper surfaces, ink and two +softer ink tones, hairline borders, a four-step type scale, three radii, one easing curve, light and +dark driven by `data-theme` on the root. Margin Docs adds nothing to that layer; it is a sibling +application, not a new visual identity, and the token file is the proof of that rather than a +description of it. diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..e3e4d89 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,56 @@ +# Releasing + +## Installing locally + +`just install` builds the app for whatever machine you are sitting at and installs it where that +machine expects to find applications: `/Applications` (or `~/Applications` when `/Applications` +is not writable) on macOS, the package manager or an AppImage plus a desktop entry on Linux. It is +the same command whether or not the app is already installed, so it doubles as the update. On +macOS it asks a running copy to quit first, because replacing a bundle under a live process leaves +it half old and half new. `just uninstall` reverses it and leaves the data directory alone. + +The local build skips the dmg and builds only the `.app` on macOS, or the `.deb` and the AppImage +on Linux, since nothing about copying a bundle into place needs a disk image and building one is +the slowest part of a mac bundle. That makes a locally installed app slightly different from a +released one: it is ad hoc signed and carries no updater artifacts, so it will not update itself. +Rerun `just install`. + +## Cutting a release + +Releases are manual: run the Release workflow from the Actions tab. Leave the version empty to +bump the patch number, or give one to set it. The workflow bumps `tauri.conf.json`, `package.json` +and `src-tauri/Cargo.toml` together, commits that to main, tags it, and builds the tag rather than +whatever main happens to be by then. + +It builds a universal macOS bundle and an x86_64 Linux one, and publishes nothing until both have +landed. The last job downloads `latest.json` and refuses to take the release out of draft unless +`darwin-aarch64`, `darwin-x86_64` and `linux-x86_64` are all present in it. A half-populated +manifest is worse than no release at all: the updater would offer an update to the platforms that +made it and error on the ones that did not. + +Linux builds on Ubuntu 22.04 on purpose. The bundle will not run on anything older than the glibc +it was linked against, and 22.04 is the oldest baseline worth supporting. + +Windows is not built. Nothing in `tauri.conf.json` targets it and the app has never claimed it. + +## What the build needs + +Two repository secrets: `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, +which sign the updater artifacts so an installed copy can tell a real update from anything else +offered at the same URL. The public half lives in `src-tauri/tauri.release.conf.json`, baked into +every build, so the private half can never be rotated without stranding everyone who has not +updated yet; back up the key and its password somewhere that is not the machine that generated +them. That file currently carries the placeholder `REPLACE_WITH_TAURI_SIGNER_PUBKEY`; generating +the real keypair and committing its public half in is a one-time step that has to happen before the +first signed release can go out. + +Beyond the signing key there is nothing to provision. Margin Docs talks to no external API and +holds no OAuth client, unlike margin-calendar, so there are no other repository secrets and nothing +equivalent to a `google-credentials.json` to embed at build time. + +## Updates + +Installed copies check +`https://github.com/priyanshujain/margin-docs/releases/latest/download/latest.json` and update +themselves from it. `--latest` on the publish step is what moves that pointer, so a release that +fails the manifest check stays a draft and no one is offered a broken update. diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..1a45e3e --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,29 @@ +# Setup + +## Building + +``` +pnpm tauri dev +``` + +or `just dev`, which is the same command. `just test` is the gate: `pnpm test` for the frontend +suites and `cargo test` inside `src-tauri` for the Rust ones, both green before anything is +considered done. `just test-ui` runs the Playwright suite in `tests/` against the real UI in +Chromium, using the dev IPC mock described in [architecture.md](architecture.md) rather than a +built Tauri binary. + +## Where the data lives + +`~/Library/Application Support/studio.margin.docs/` on macOS. It holds nothing but the SQLite +index described in [architecture.md](architecture.md): the tables behind quick open, full text +search and the backlinks section it powers. It never holds a document or a copy of one, so deleting +the directory costs nothing except the time the app takes to walk your open folders and rebuild the +index the next time it starts. Quick open and search go blank until that finishes; nothing else +notices. + +## No credentials to provision + +Unlike margin-calendar, there is no OAuth client to create and no `google-credentials.json` to drop +in the repo root before the app does anything useful. Margin Docs talks to no external service; the +only account it needs is the one already logged into the machine it is running on, to read and +write the folders you point it at. A fresh clone builds and runs immediately. diff --git a/index.html b/index.html new file mode 100644 index 0000000..ea0c8e0 --- /dev/null +++ b/index.html @@ -0,0 +1,34 @@ + + + + + + + Margin Docs + + +
+ + + diff --git a/justfile b/justfile new file mode 100644 index 0000000..da5f552 --- /dev/null +++ b/justfile @@ -0,0 +1,132 @@ +# Building and installing Margin Docs on the machine you are sitting at. The release pipeline in +# .github/workflows/release.yml is what builds for everyone else; this is the local equivalent, and +# `just install` is deliberately the same command whether or not the app is already installed. + +set shell := ["bash", "-euo", "pipefail", "-c"] + +app := "Margin Docs" +bundle := "src-tauri/target/release/bundle" + +# List the recipes. +default: + @just --list + +# Run the app against the Vite dev server. +dev: + pnpm tauri dev + +# The gate: the frontend suites and the Rust suites, both of which must be green. +test: + pnpm test + cd src-tauri && cargo test + +# The browser suite that drives the real UI. +test-ui: + pnpm test:ui + +# Build the release bundle for this machine. +build: + #!/usr/bin/env bash + set -euo pipefail + # The .app on macOS, the .deb and AppImage on Linux. No dmg: nothing here needs a disk image + # to copy a bundle into place, and building one is the slowest part of a mac bundle. + pnpm install + case "$(uname -s)" in + Darwin) pnpm tauri build --bundles app ;; + Linux) pnpm tauri build --bundles deb,appimage ;; + *) echo "just: no local build for $(uname -s); macOS and Linux are the desktop targets." >&2; exit 1 ;; + esac + +# Build and install, replacing whatever version is already installed. +install: build + #!/usr/bin/env bash + set -euo pipefail + case "$(uname -s)" in + Darwin) just _install-macos ;; + Linux) just _install-linux ;; + *) echo "just: no installer for $(uname -s); macOS and Linux are the desktop targets." >&2; exit 1 ;; + esac + +_install-macos: + #!/usr/bin/env bash + set -euo pipefail + src="{{bundle}}/macos/{{app}}.app" + [ -d "$src" ] || { echo "just: nothing to install, $src does not exist." >&2; exit 1; } + + # /Applications is writable by admin users, so the common case needs no sudo. When it is not, + # ~/Applications is a real Launchpad location and beats prompting for a password mid-build. + if [ -w /Applications ]; then dir=/Applications; else dir="$HOME/Applications"; mkdir -p "$dir"; fi + dest="$dir/{{app}}.app" + + # Which of the two names the executable carries depends on how the bundle was configured, so + # ask about both rather than assuming, and replacing a bundle out from under a live process. + running() { pgrep -x "{{app}}" > /dev/null || pgrep -x margin-docs > /dev/null; } + + # Replacing the bundle under a running app leaves it half old and half new, and the running + # copy holds the deleted files open. Ask it to quit and wait, rather than killing it. + if running; then + echo "Quitting the running {{app}}…" + osascript -e 'quit app "{{app}}"' 2> /dev/null || true + for _ in $(seq 40); do running || break; sleep 0.25; done + if running; then echo "just: {{app}} is still running; quit it and try again." >&2; exit 1; fi + fi + + rm -rf "$dest" + cp -R "$src" "$dest" + version=$(defaults read "$dest/Contents/Info.plist" CFBundleShortVersionString 2> /dev/null || echo "?") + echo "Installed $version to $dest" + +_install-linux: + #!/usr/bin/env bash + set -euo pipefail + deb=$(ls -t {{bundle}}/deb/*.deb 2> /dev/null | head -1 || true) + appimage=$(ls -t {{bundle}}/appimage/*.AppImage 2> /dev/null | head -1 || true) + + # apt over dpkg where it exists: it pulls in the webkit and gtk runtime the package depends on, + # and --reinstall makes installing over the same version an update rather than a no-op. + if [ -n "$deb" ] && command -v apt-get > /dev/null; then + sudo apt-get install -y --reinstall "$PWD/$deb" + echo "Installed $deb" + elif [ -n "$deb" ] && command -v dpkg > /dev/null; then + sudo dpkg -i "$deb" + echo "Installed $deb" + elif [ -n "$appimage" ]; then + install -Dm755 "$appimage" "$HOME/.local/bin/margin-docs" + install -Dm644 src-tauri/icons/128x128.png "$HOME/.local/share/icons/margin-docs.png" + mkdir -p "$HOME/.local/share/applications" + printf '%s\n' \ + '[Desktop Entry]' \ + 'Type=Application' \ + 'Name=Margin Docs' \ + 'Comment=A folder of markdown documents' \ + 'Exec=margin-docs' \ + 'Icon=margin-docs' \ + 'Categories=Office;' \ + > "$HOME/.local/share/applications/margin-docs.desktop" + echo "Installed $appimage to ~/.local/bin/margin-docs" + echo "Make sure ~/.local/bin is on your PATH." + else + echo "just: nothing to install, no .deb or AppImage under {{bundle}}." >&2 + exit 1 + fi + +# Remove the installed app, leaving its data directory alone. +uninstall: + #!/usr/bin/env bash + set -euo pipefail + # What the data directory is and what deleting it costs you is in docs/setup.md. + case "$(uname -s)" in + Darwin) + for dest in "/Applications/{{app}}.app" "$HOME/Applications/{{app}}.app"; do + if [ -d "$dest" ]; then rm -rf "$dest"; echo "Removed $dest"; fi + done + ;; + Linux) + if dpkg -s margin-docs > /dev/null 2>&1; then sudo apt-get remove -y margin-docs; fi + rm -f "$HOME/.local/bin/margin-docs" \ + "$HOME/.local/share/icons/margin-docs.png" \ + "$HOME/.local/share/applications/margin-docs.desktop" + echo "Removed the AppImage install, if there was one." + ;; + *) echo "just: nothing to uninstall on $(uname -s)." >&2; exit 1 ;; + esac diff --git a/package.json b/package.json new file mode 100644 index 0000000..557c119 --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "margin-docs", + "private": true, + "version": "0.0.1", + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "playwright test" + }, + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2", + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2", + "@tauri-apps/plugin-updater": "^2", + "@tiptap/core": "3.30.2", + "@tiptap/extension-placeholder": "3.30.2", + "@tiptap/pm": "3.30.2", + "@tiptap/react": "3.30.2", + "@tiptap/starter-kit": "3.30.2", + "@types/mdast": "^4", + "highlight.js": "^11.12.0", + "katex": "^0.18.4", + "lowlight": "^3.3.0", + "mdast-util-to-markdown": "^2", + "mermaid": "^11.17.1", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "remark-frontmatter": "^5", + "remark-gfm": "^4.0.1", + "remark-math": "^6", + "remark-parse": "^11", + "remark-stringify": "^11", + "unified": "^11", + "unist-util-visit": "^5", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@playwright/test": "^1.62.1", + "@tauri-apps/cli": "^2", + "@types/katex": "^0.16.8", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "typescript": "~5.8.3", + "vite": "^7.0.4", + "vitest": "^3.2.4" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..da99aca --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,45 @@ +// The end-to-end suite drives the real UI in a browser. One command starts everything: +// `pnpm test:ui` brings up Vite itself and reuses a dev server that is already running. + +import { defineConfig } from "@playwright/test"; + +const PORT = 1440; +const BASE_URL = `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + // No retries on purpose. A test that only passes on the second go is a test that is lying about + // something. + retries: 0, + reporter: [["list"]], + // Traces and failure screenshots go somewhere already ignored, so a run cannot leave anything + // behind for the next `git add` to pick up. The paths are printed with the failure that made + // them. + outputDir: "node_modules/.cache/playwright", + timeout: 30_000, + expect: { timeout: 5_000 }, + + use: { + baseURL: BASE_URL, + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 1, + locale: "en-GB", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + + // Deliberately not `devices["Desktop Chrome"]`: that device pins a Windows user agent, and the + // keymap reads the platform off the user agent to decide whether the primary modifier is Command + // or Control. A faked platform would test the wrong half of every shortcut. + projects: [{ name: "chromium", use: { browserName: "chromium" } }], + + webServer: { + command: "pnpm dev", + url: BASE_URL, + reuseExistingServer: true, + timeout: 60_000, + stdout: "ignore", + stderr: "pipe", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..42f8ef1 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3826 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tauri-apps/api': + specifier: ^2 + version: 2.11.1 + '@tauri-apps/plugin-dialog': + specifier: ^2 + version: 2.7.2 + '@tauri-apps/plugin-opener': + specifier: ^2 + version: 2.5.4 + '@tauri-apps/plugin-process': + specifier: ^2 + version: 2.3.1 + '@tauri-apps/plugin-updater': + specifier: ^2 + version: 2.10.1 + '@tiptap/core': + specifier: 3.30.2 + version: 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/extension-placeholder': + specifier: 3.30.2 + version: 3.30.2(@tiptap/extensions@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)) + '@tiptap/pm': + specifier: 3.30.2 + version: 3.30.2 + '@tiptap/react': + specifier: 3.30.2 + version: 3.30.2(@floating-ui/dom@1.8.0)(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tiptap/starter-kit': + specifier: 3.30.2 + version: 3.30.2 + '@types/mdast': + specifier: ^4 + version: 4.0.4 + highlight.js: + specifier: ^11.12.0 + version: 11.12.0 + katex: + specifier: ^0.18.4 + version: 0.18.4 + lowlight: + specifier: ^3.3.0 + version: 3.3.0 + mdast-util-to-markdown: + specifier: ^2 + version: 2.1.2 + mermaid: + specifier: ^11.17.1 + version: 11.17.1 + react: + specifier: ^19.1.0 + version: 19.2.8 + react-dom: + specifier: ^19.1.0 + version: 19.2.8(react@19.2.8) + remark-frontmatter: + specifier: ^5 + version: 5.0.0 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + remark-math: + specifier: ^6 + version: 6.0.0 + remark-parse: + specifier: ^11 + version: 11.0.0 + remark-stringify: + specifier: ^11 + version: 11.0.0 + unified: + specifier: ^11 + version: 11.0.5 + unist-util-visit: + specifier: ^5 + version: 5.1.0 + zustand: + specifier: ^5.0.14 + version: 5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) + devDependencies: + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@tauri-apps/cli': + specifier: ^2 + version: 2.11.4 + '@types/katex': + specifier: ^0.16.8 + version: 0.16.8 + '@types/react': + specifier: ^19.1.8 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.1.6 + version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^4.6.0 + version: 4.7.0(vite@7.3.6) + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vite: + specifier: ^7.0.4 + version: 7.3.6 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/debug@4.1.13) + +packages: + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.4': + resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mermaid-js/parser@1.2.1': + resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.5': + resolution: {integrity: sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.5': + resolution: {integrity: sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.5': + resolution: {integrity: sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.5': + resolution: {integrity: sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.5': + resolution: {integrity: sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.5': + resolution: {integrity: sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.5': + resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.5': + resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.5': + resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.5': + resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.5': + resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.5': + resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.5': + resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.5': + resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.5': + resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.5': + resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.5': + resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.5': + resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.5': + resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.5': + resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.5': + resolution: {integrity: sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.5': + resolution: {integrity: sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.5': + resolution: {integrity: sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.5': + resolution: {integrity: sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.5': + resolution: {integrity: sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==} + cpu: [x64] + os: [win32] + + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + + '@tauri-apps/cli-darwin-arm64@2.11.4': + resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.11.4': + resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.11.4': + resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} + engines: {node: '>= 10'} + hasBin: true + + '@tauri-apps/plugin-dialog@2.7.2': + resolution: {integrity: sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==} + + '@tauri-apps/plugin-opener@2.5.4': + resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} + + '@tauri-apps/plugin-process@2.3.1': + resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} + + '@tauri-apps/plugin-updater@2.10.1': + resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + + '@tiptap/core@3.30.2': + resolution: {integrity: sha512-QbZC/s1OOqcoUdkhIY16TjR/gCtR0qAk9e4bJwUqOJqZuv5ozqCL5hzWm22jjTPp6c6Ei2tPd6t30VwfIKW4lQ==} + peerDependencies: + '@tiptap/pm': 3.30.2 + + '@tiptap/extension-blockquote@3.30.2': + resolution: {integrity: sha512-BOkwhZenek7vzXBOgKppSrlx4YryBdAYu1p1MXKn0R9A9eNmE2HVhmm0gG49+E8BhsE/TG8wKVclwET42JJiIg==} + peerDependencies: + '@tiptap/core': 3.30.2 + '@tiptap/pm': 3.30.2 + + '@tiptap/extension-bold@3.30.2': + resolution: {integrity: sha512-MsvJhPgYejY2D9MhwYJv8AmscozvLBI8qtJ7YLdYZBWkMR4bgmxHq5+xqEfBsao9bOMMwBon9p3+P+/Tq5ReWA==} + peerDependencies: + '@tiptap/core': 3.30.2 + + '@tiptap/extension-bubble-menu@3.30.2': + resolution: {integrity: sha512-oS0WiWNXHKpiPYMkcnHm1j7iEvufTGGtLFtcJJd3olb5OS6V2acoXVDL0nNJDmRQ32K3QXut3fbRcMseMxT+lw==} + peerDependencies: + '@tiptap/core': 3.30.2 + '@tiptap/pm': 3.30.2 + + '@tiptap/extension-bullet-list@3.30.2': + resolution: {integrity: sha512-+awIL/TUz4aB3rL68igU1rWfaaoBIAkPcakkktkRq8gYf0bd9eSb48P6kHkpx/3q+JyK7g9vsnltLNHNh6twnA==} + peerDependencies: + '@tiptap/extension-list': 3.30.2 + + '@tiptap/extension-code-block@3.30.2': + resolution: {integrity: sha512-9otGKaQZmePHrLXFtCtz+BYDn5z4sSumTkUqQIQHz0gVxwPoTi7g51RedwxvViTb/zu2XV5ROXYLHIxKxypMPg==} + peerDependencies: + '@tiptap/core': 3.30.2 + '@tiptap/pm': 3.30.2 + + '@tiptap/extension-code@3.30.2': + resolution: {integrity: sha512-r8EZk3R9yGpF6v5xxafAU1HwrD/e+RpbfnmVi2TeB/ZHAsVO62fW96E32G0t6IdaCtOFtAd85hkDAaOfCT4yGg==} + peerDependencies: + '@tiptap/core': 3.30.2 + + '@tiptap/extension-document@3.30.2': + resolution: {integrity: sha512-+xIv67V+/2L1uvz98FAT5W7kWEfHwfNV3MD7b4UsKPU0lhcCWuVOXy0JB8yYmdNExqpI7xT9g3MWzREoBvBQSg==} + peerDependencies: + '@tiptap/core': 3.30.2 + + '@tiptap/extension-dropcursor@3.30.2': + resolution: {integrity: sha512-nyRKUmItATnKI9AiRChmjhcBbCEsNxRu+AaCz+cx8EvnAcNHsVRdNYL5PmBs3WlNA/Et4Eb2DG0hVQDnNF61eg==} + peerDependencies: + '@tiptap/extensions': 3.30.2 + + '@tiptap/extension-floating-menu@3.30.2': + resolution: {integrity: sha512-A8PLvvh8W6PUMrqh+EpBerxm+Ucr0irGxJvwAnzYQmNGNIJ9U4OVgw4OcEU+9JH0gMmEzDcHzMPP2s/s1lIcyw==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': 3.30.2 + '@tiptap/pm': 3.30.2 + + '@tiptap/extension-gapcursor@3.30.2': + resolution: {integrity: sha512-7Xk0ut6FM+RAsvKxDN3bAtk7zvYZ6Aa8pawJ6s7dLAmLR9JwrZevlcL4FSrj4bR7rqKOj92RYCFxzgTEbWimag==} + peerDependencies: + '@tiptap/extensions': 3.30.2 + + '@tiptap/extension-hard-break@3.30.2': + resolution: {integrity: sha512-IxSNgmG3d4OZdUTeebrOI7SxdIWXXJqlcGiSNDabWqxipUitfy3mZ3gDDE6G01koKxZRbhz4KIplAZlpxnTFSg==} + peerDependencies: + '@tiptap/core': 3.30.2 + + '@tiptap/extension-heading@3.30.2': + resolution: {integrity: sha512-PblDvgSJ05p1t6hzyPi02xeiBjB0M2abReoGEImqSWCy79UqnAGacgsZo4EeEawtJV1NEP8chhvmX+nRtzdT1A==} + peerDependencies: + '@tiptap/core': 3.30.2 + + '@tiptap/extension-horizontal-rule@3.30.2': + resolution: {integrity: sha512-j8aswLTsuEdJKC62DF+kw0EgvIRL7QMUyAVp2fdjR0qgM0ZVlEwCC4qIEq3kK9tFVU4kRtQ5BSj/jn6QwrlbCA==} + peerDependencies: + '@tiptap/core': 3.30.2 + '@tiptap/pm': 3.30.2 + + '@tiptap/extension-italic@3.30.2': + resolution: {integrity: sha512-pp8uaiuXsUbLm5rYzR1jlWbwm1mAahRajdHwAKBtthFRB2rDvC7ZWhKaCSoKhZvfIDRmu9/B67+uAHoutL0dCA==} + peerDependencies: + '@tiptap/core': 3.30.2 + + '@tiptap/extension-link@3.30.2': + resolution: {integrity: sha512-jwdcymKcrbFpj5hRAuGVLCq8FieVkGFnENyroYmvkad+XAt8ZLy/MTFYRN6SK3ukH6PZMY7H4iObGtciQaC5nw==} + peerDependencies: + '@tiptap/core': 3.30.2 + '@tiptap/pm': 3.30.2 + + '@tiptap/extension-list-item@3.30.2': + resolution: {integrity: sha512-HWgRCRlGxulE+hN1VUcnWD6P2NE08VBgGtcaxOfdXVqaI93BCK6AhRQZGpLsfKgajLk+5DXTBraaitwnBqzCxg==} + peerDependencies: + '@tiptap/extension-list': 3.30.2 + + '@tiptap/extension-list-keymap@3.30.2': + resolution: {integrity: sha512-TTve3WOlQaYu1ahMqsQ/T0wzaxfgZvcOl3/OuPyInOi8QtxXhqGhFjmYe5jOr56G9W2QDuFWVsZecVwfDte9zg==} + peerDependencies: + '@tiptap/extension-list': 3.30.2 + + '@tiptap/extension-list@3.30.2': + resolution: {integrity: sha512-MIUpo1Bd9Rf1Qg+TNYNwDZ4xsfFeQahjU9Xhy6UcaszKQzbAM7KCzn5BObNytK1NdcqNHsC8Wj5vFvMKEzrXdw==} + peerDependencies: + '@tiptap/core': 3.30.2 + '@tiptap/pm': 3.30.2 + + '@tiptap/extension-ordered-list@3.30.2': + resolution: {integrity: sha512-Z7OO1HcF0idda1n6vodXeQ3h2ylN9JR4IfIGUYkar5Xl9JusK8PDETTBQZQn//96p49I2d+GoWsD2LXPtjHXXg==} + peerDependencies: + '@tiptap/extension-list': 3.30.2 + + '@tiptap/extension-paragraph@3.30.2': + resolution: {integrity: sha512-ulEu3LNt+kPVAWEnrhoz13Fs8Q/v/8NUxQbAeteuBchQ8joxJXuWExhpy1fUfZir5+b+W5z7/NesgPjZQfv47w==} + peerDependencies: + '@tiptap/core': 3.30.2 + + '@tiptap/extension-placeholder@3.30.2': + resolution: {integrity: sha512-Bj1seUvPCoRrD/LpzMoKD+jQIjxuc+oq931GpPq4wobSUUbD4pF/0NMwpCLpiHO19QnTQz8+9p2dqPdlc44LHA==} + peerDependencies: + '@tiptap/extensions': 3.30.2 + + '@tiptap/extension-strike@3.30.2': + resolution: {integrity: sha512-fBLxMXz6hYIURzLOD+/L6aVATztsKham00ANWmGi13vN0hx2lQMYZffN+gR+QqiCDfMQxBXzrf5a7tJuDiQHLQ==} + peerDependencies: + '@tiptap/core': 3.30.2 + + '@tiptap/extension-text@3.30.2': + resolution: {integrity: sha512-n/iZnirgRmXet6f97kolAnP3j8DsgLSiTbz/KLWc8eBYiFmkjRzkuisOm5xuGdfGIxwpB4x3tlSF4ef4DLnbRg==} + peerDependencies: + '@tiptap/core': 3.30.2 + + '@tiptap/extension-underline@3.30.2': + resolution: {integrity: sha512-SZiTMnvqXcnrtJX+X25ZbYsuDO83haGOVMBD/O+mAWYNYXhaSc5Rkph5czzItxrd+Yyp/vs4PiwD7XTNbfqmpA==} + peerDependencies: + '@tiptap/core': 3.30.2 + + '@tiptap/extensions@3.30.2': + resolution: {integrity: sha512-2LqAHXk26QDsryW+beECxYeBzv5Ylk4GuB3cOmfghS7/G37R2W+Te3TkUK7BT0EWoDryvBT57/5q0DEFIhfZZg==} + peerDependencies: + '@tiptap/core': 3.30.2 + '@tiptap/pm': 3.30.2 + + '@tiptap/pm@3.30.2': + resolution: {integrity: sha512-BJN8tUx4ppFN3R3cV/FJfrJbJkvo1lj4uciq+nwpjwzdRvFzqIuglWf+HLcJ6CwlYpLOHp7ArgkBg4Q5e60Gog==} + + '@tiptap/react@3.30.2': + resolution: {integrity: sha512-7hGaTstpUeTmQ008mCPkjz+GSlChWhucgy+PeX0z93v4+nh7qM5F+0lh+kJ9zo6Os5abO7v36GtgHRZdQI6+FQ==} + peerDependencies: + '@tiptap/core': 3.30.2 + '@tiptap/pm': 3.30.2 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tiptap/starter-kit@3.30.2': + resolution: {integrity: sha512-fJSrhW1CyD4sjYA20evSP4Cp13B/HhbxCdM974K0xpHOVqvCtNU9w2s9hfq9mg2yGoU7MSNHKNYMkJjIi2/Xyw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + baseline-browser-mapping@2.11.18: + resolution: {integrity: sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.1: + resolution: {integrity: sha512-Lr0RvH9H75y9ar8h9Toy6u4lxRSCcxUq+hHcQ26sVWo6BnaQp1gwEZOYqwuYTZhyW7npyKnNLP8oJ2p1/3OZ7g==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + dompurify@3.4.14: + resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==} + + electron-to-chromium@1.5.412: + resolution: {integrity: sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-toolkit@1.51.0: + resolution: {integrity: sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-equals@5.4.1: + resolution: {integrity: sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==} + engines: {node: '>=6.0.0'} + + fastdom@1.0.12: + resolution: {integrity: sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==} + + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + + highlight.js@11.11.2: + resolution: {integrity: sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==} + engines: {node: '>=12.0.0'} + + highlight.js@11.12.0: + resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} + engines: {node: '>=12.0.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + + katex@0.18.4: + resolution: {integrity: sha512-IMPntbRLOU+eu88XDiFKqQ8Akhr9Tv7jDMXqPhjG9SI1JMA4DIgXk4x9k4skJz2NZJXBRbC+2pYBLj9olqcZow==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + + linkifyjs@4.3.3: + resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mermaid@11.17.1: + resolution: {integrity: sha512-G1BP6qU4BRwEGk2SdsxgDk1cSuApimT32Nkb52YRSv+856FrmB8qo28BaNk9Ee8Fvuon3OxpXDJ4L6cD5AykXg==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prosemirror-changeset@2.4.2: + resolution: {integrity: sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==} + + prosemirror-commands@1.7.2: + resolution: {integrity: sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==} + + prosemirror-dropcursor@1.8.3: + resolution: {integrity: sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==} + + prosemirror-gapcursor@1.4.1: + resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-model@1.25.11: + resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + + prosemirror-transform@1.12.0: + resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} + + prosemirror-view@1.42.2: + resolution: {integrity: sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + remark-frontmatter@5.0.0: + resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + rollup@4.62.5: + resolution: {integrity: sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strictdom@1.0.1: + resolution: {integrity: sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.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 + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zustand@5.0.15: + resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.8.0 + tinyexec: 1.3.0 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/types@11.1.2': {} + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + optional: true + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + optional: true + + '@floating-ui/utils@0.2.12': + optional: true + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.4': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mermaid-js/parser@1.2.1': + dependencies: + '@chevrotain/types': 11.1.2 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.5': + optional: true + + '@rollup/rollup-android-arm64@4.62.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.5': + optional: true + + '@rollup/rollup-darwin-x64@4.62.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.5': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.5': + optional: true + + '@tauri-apps/api@2.11.1': {} + + '@tauri-apps/cli-darwin-arm64@2.11.4': + optional: true + + '@tauri-apps/cli-darwin-x64@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli@2.11.4': + 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 + + '@tauri-apps/plugin-dialog@2.7.2': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-opener@2.5.4': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-process@2.3.1': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-updater@2.10.1': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tiptap/core@3.30.2(@tiptap/pm@3.30.2)': + dependencies: + '@tiptap/pm': 3.30.2 + + '@tiptap/extension-blockquote@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/pm': 3.30.2 + + '@tiptap/extension-bold@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + + '@tiptap/extension-bubble-menu@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)': + dependencies: + '@floating-ui/dom': 1.8.0 + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/pm': 3.30.2 + optional: true + + '@tiptap/extension-bullet-list@3.30.2(@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/extension-list': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + + '@tiptap/extension-code-block@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/pm': 3.30.2 + + '@tiptap/extension-code@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + + '@tiptap/extension-document@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + + '@tiptap/extension-dropcursor@3.30.2(@tiptap/extensions@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/extensions': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + + '@tiptap/extension-floating-menu@3.30.2(@floating-ui/dom@1.8.0)(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)': + dependencies: + '@floating-ui/dom': 1.8.0 + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/pm': 3.30.2 + optional: true + + '@tiptap/extension-gapcursor@3.30.2(@tiptap/extensions@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/extensions': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + + '@tiptap/extension-hard-break@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + + '@tiptap/extension-heading@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + + '@tiptap/extension-horizontal-rule@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/pm': 3.30.2 + + '@tiptap/extension-italic@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + + '@tiptap/extension-link@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/pm': 3.30.2 + linkifyjs: 4.3.3 + + '@tiptap/extension-list-item@3.30.2(@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/extension-list': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + + '@tiptap/extension-list-keymap@3.30.2(@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/extension-list': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + + '@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/pm': 3.30.2 + + '@tiptap/extension-ordered-list@3.30.2(@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/extension-list': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + + '@tiptap/extension-paragraph@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + + '@tiptap/extension-placeholder@3.30.2(@tiptap/extensions@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/extensions': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + + '@tiptap/extension-strike@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + + '@tiptap/extension-text@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + + '@tiptap/extension-underline@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + + '@tiptap/extensions@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/pm': 3.30.2 + + '@tiptap/pm@3.30.2': + dependencies: + prosemirror-changeset: 2.4.2 + prosemirror-commands: 1.7.2 + prosemirror-dropcursor: 1.8.3 + prosemirror-gapcursor: 1.4.1 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.11 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + + '@tiptap/react@3.30.2(@floating-ui/dom@1.8.0)(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/pm': 3.30.2 + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/use-sync-external-store': 0.0.6 + fast-equals: 5.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + '@tiptap/extension-floating-menu': 3.30.2(@floating-ui/dom@1.8.0)(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + transitivePeerDependencies: + - '@floating-ui/dom' + + '@tiptap/starter-kit@3.30.2': + dependencies: + '@tiptap/core': 3.30.2(@tiptap/pm@3.30.2) + '@tiptap/extension-blockquote': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + '@tiptap/extension-bold': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) + '@tiptap/extension-bullet-list': 3.30.2(@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)) + '@tiptap/extension-code': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) + '@tiptap/extension-code-block': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + '@tiptap/extension-document': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) + '@tiptap/extension-dropcursor': 3.30.2(@tiptap/extensions@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)) + '@tiptap/extension-gapcursor': 3.30.2(@tiptap/extensions@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)) + '@tiptap/extension-hard-break': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) + '@tiptap/extension-heading': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) + '@tiptap/extension-horizontal-rule': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + '@tiptap/extension-italic': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) + '@tiptap/extension-link': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + '@tiptap/extension-list': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + '@tiptap/extension-list-item': 3.30.2(@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)) + '@tiptap/extension-list-keymap': 3.30.2(@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)) + '@tiptap/extension-ordered-list': 3.30.2(@tiptap/extension-list@3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)) + '@tiptap/extension-paragraph': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) + '@tiptap/extension-strike': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) + '@tiptap/extension-text': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) + '@tiptap/extension-underline': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2)) + '@tiptap/extensions': 3.30.2(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2) + '@tiptap/pm': 3.30.2 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.1': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.1 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/geojson@7946.0.16': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/katex@0.16.8': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/react-dom@19.2.4(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/trusted-types@2.0.7': + optional: true + + '@types/unist@3.0.3': {} + + '@types/use-sync-external-store@0.0.6': {} + + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + '@vitejs/plugin-react@4.7.0(vite@7.3.6)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 7.3.6 + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6)': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6 + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + bail@2.0.2: {} + + baseline-browser-mapping@2.11.18: {} + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.18 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.412 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) + + cac@6.7.14: {} + + caniuse-lite@1.0.30001809: {} + + ccount@2.0.1: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + character-entities@2.0.2: {} + + check-error@2.1.3: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + convert-source-map@2.0.0: {} + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + + csstype@3.2.3: {} + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.1): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.1 + + cytoscape-fcose@2.2.0(cytoscape@3.34.1): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.1 + + cytoscape@3.34.1: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + + dayjs@1.11.23: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + deep-eql@5.0.2: {} + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + dompurify@3.4.14: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + electron-to-chromium@1.5.412: {} + + es-module-lexer@1.7.0: {} + + es-toolkit@1.51.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + escape-string-regexp@5.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + extend@3.0.2: {} + + fast-equals@5.4.1: {} + + fastdom@1.0.12: + dependencies: + strictdom: 1.0.1 + + fault@2.0.1: + dependencies: + format: 0.2.2 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + format@0.2.2: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + hachure-fill@0.5.2: {} + + highlight.js@11.11.2: {} + + highlight.js@11.12.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + import-meta-resolve@4.2.0: {} + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + is-plain-obj@4.1.0: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + katex@0.16.47: + dependencies: + commander: 8.3.0 + + katex@0.18.4: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + + linkifyjs@4.3.3: {} + + lodash-es@4.18.1: {} + + longest-streak@3.1.0: {} + + loupe@3.2.1: {} + + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.5 + devlop: 1.1.0 + highlight.js: 11.11.2 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + markdown-table@3.0.4: {} + + marked@16.4.2: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mermaid@11.17.1: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.4 + '@mermaid-js/parser': 1.2.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.1) + cytoscape-fcose: 2.2.0(cytoscape@3.34.1) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.23 + dompurify: 3.4.14 + es-toolkit: 1.51.0 + fastdom: 1.0.12 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.2 + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.47 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + node-releases@2.0.53: {} + + orderedmap@2.1.1: {} + + package-manager-detector@1.8.0: {} + + path-data-parser@0.1.0: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prosemirror-changeset@2.4.2: + dependencies: + prosemirror-transform: 1.12.0 + + prosemirror-commands@1.7.2: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-dropcursor@1.8.3: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + + prosemirror-gapcursor@1.4.1: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-view: 1.42.2 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-model@1.25.11: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + + prosemirror-tables@1.8.5: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + + prosemirror-transform@1.12.0: + dependencies: + prosemirror-model: 1.25.11 + + prosemirror-view@1.42.2: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-refresh@0.17.0: {} + + react@19.2.8: {} + + remark-frontmatter@5.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-frontmatter: 2.0.1 + micromark-extension-frontmatter: 2.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + robust-predicates@3.0.3: {} + + rollup@4.62.5: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.5 + '@rollup/rollup-android-arm64': 4.62.5 + '@rollup/rollup-darwin-arm64': 4.62.5 + '@rollup/rollup-darwin-x64': 4.62.5 + '@rollup/rollup-freebsd-arm64': 4.62.5 + '@rollup/rollup-freebsd-x64': 4.62.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.5 + '@rollup/rollup-linux-arm-musleabihf': 4.62.5 + '@rollup/rollup-linux-arm64-gnu': 4.62.5 + '@rollup/rollup-linux-arm64-musl': 4.62.5 + '@rollup/rollup-linux-loong64-gnu': 4.62.5 + '@rollup/rollup-linux-loong64-musl': 4.62.5 + '@rollup/rollup-linux-ppc64-gnu': 4.62.5 + '@rollup/rollup-linux-ppc64-musl': 4.62.5 + '@rollup/rollup-linux-riscv64-gnu': 4.62.5 + '@rollup/rollup-linux-riscv64-musl': 4.62.5 + '@rollup/rollup-linux-s390x-gnu': 4.62.5 + '@rollup/rollup-linux-x64-gnu': 4.62.5 + '@rollup/rollup-linux-x64-musl': 4.62.5 + '@rollup/rollup-openbsd-x64': 4.62.5 + '@rollup/rollup-openharmony-arm64': 4.62.5 + '@rollup/rollup-win32-arm64-msvc': 4.62.5 + '@rollup/rollup-win32-ia32-msvc': 4.62.5 + '@rollup/rollup-win32-x64-gnu': 4.62.5 + '@rollup/rollup-win32-x64-msvc': 4.62.5 + fsevents: 2.3.3 + + rope-sequence@1.3.4: {} + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strictdom@1.0.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + stylis@4.4.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + trough@2.2.0: {} + + ts-dedent@2.3.0: {} + + typescript@5.8.3: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + update-browserslist-db@1.3.1(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + + uuid@14.0.2: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite-node@3.2.4: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6: + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.5 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@3.2.7(@types/debug@4.1.13): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6 + vite-node: 3.2.4 + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-keyname@2.2.8: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yallist@3.1.1: {} + + zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): + optionalDependencies: + '@types/react': 19.2.18 + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) + + zwitch@2.0.4: {} diff --git a/public/fonts/HankenGrotesk-Italic-VF.ttf b/public/fonts/HankenGrotesk-Italic-VF.ttf new file mode 100644 index 0000000..eb28513 Binary files /dev/null and b/public/fonts/HankenGrotesk-Italic-VF.ttf differ diff --git a/public/fonts/HankenGrotesk-VF.ttf b/public/fonts/HankenGrotesk-VF.ttf new file mode 100644 index 0000000..65a8f7b Binary files /dev/null and b/public/fonts/HankenGrotesk-VF.ttf differ diff --git a/public/fonts/Literata-Italic-VF.ttf b/public/fonts/Literata-Italic-VF.ttf new file mode 100644 index 0000000..8690209 Binary files /dev/null and b/public/fonts/Literata-Italic-VF.ttf differ diff --git a/public/fonts/Literata-VF.ttf b/public/fonts/Literata-VF.ttf new file mode 100644 index 0000000..0a81b49 Binary files /dev/null and b/public/fonts/Literata-VF.ttf differ diff --git a/src-tauri/.cargo/config.toml b/src-tauri/.cargo/config.toml new file mode 100644 index 0000000..acfec33 --- /dev/null +++ b/src-tauri/.cargo/config.toml @@ -0,0 +1,8 @@ +# tests/no_write_on_open.rs runs against one real git repository on disk and several of its tests +# mutate it, so they have to run one at a time. The file has always said so in a comment; this is +# what makes plain `cargo test` obey it, rather than leaving the suite green only for whoever +# remembers to pass `-- --test-threads=1`. +# +# The other suites use a TempDir each and do not care. Serializing them costs well under a second. +[env] +RUST_TEST_THREADS = "1" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock new file mode 100644 index 0000000..8a20dbc --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,5882 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.4+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "file-id" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "margin-docs" +version = "0.0.1" +dependencies = [ + "ignore", + "notify", + "notify-debouncer-full", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "rusqlite", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-updater", + "tempfile", + "tokio", + "trash", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-debouncer-full" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c02b49179cfebc9932238d04d6079912d26de0379328872846118a0fa0dbb302" +dependencies = [ + "file-id", + "log", + "notify", + "notify-types", + "walkdir", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.20", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni 0.21.1", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.20", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.61.3", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.20", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.20", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "url", + "windows 0.61.3", + "zbus", +] + +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni 0.21.1", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.4+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "trash" +version = "5.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7602e0c7d66ec2d92a8c917219fbc7894039efa2063b9064260110828a356f46" +dependencies = [ + "chrono", + "libc", + "log", + "objc2", + "objc2-foundation", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows 0.56.0", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement 0.60.2", + "windows-interface 0.59.3", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow 1.0.4", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..2ced78d --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "margin-docs" +version = "0.0.1" +description = "A folder of markdown documents" +authors = ["Margin"] +edition = "2021" +license = "MIT" + +[lib] +name = "margin_docs_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +tauri-plugin-opener = "2" +tauri-plugin-dialog = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Gitignore-aware directory walking for the folder tree: a document folder under version control +# should not surface .git or its own ignored build output as if they were documents. +ignore = "0.4" +notify = "8" +notify-debouncer-full = "0.7" +# Deleting a document goes to the OS trash rather than unlinking it outright. +trash = "5" +# rusqlite 0.40 has no separate "fts5" cargo feature to ask for: libsqlite3-sys's bundled build +# compiles SQLITE_ENABLE_FTS5 in unconditionally, so "bundled" alone is what gets full text search. +# margin-calendar does not carry this comment because it has no need for full text search. +rusqlite = { version = "0.40", features = ["bundled"] } +tokio = { version = "1", features = ["sync", "time"] } + +# Spelling is the system's, not ours. NSSpellChecker is the same checker every other Mac app +# corrects into, so a word learned in Mail is not underlined here, and it carries the user's own +# languages without this app shipping a dictionary. These objc2 crates are already in the graph +# through Tauri, so asking for them adds nothing to the build but the features named. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSSpellChecker"] } +objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSRange", "NSTextCheckingResult"] } + +# There is no auto-updater and no process to restart on a phone: the store is the update channel. +# Gated here as well as behind cfg(desktop) in lib.rs so a mobile build does not compile them at all. +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri-plugin-process = "2" +tauri-plugin-updater = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..2e9d41e --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window, on every platform", + "windows": ["main"], + "permissions": ["core:default", "opener:default", "dialog:default"] +} diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json new file mode 100644 index 0000000..b0aa0b6 --- /dev/null +++ b/src-tauri/capabilities/desktop.json @@ -0,0 +1,14 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "desktop", + "description": "Window control, the updater and process restart, none of which a phone has", + "platforms": ["macOS", "windows", "linux"], + "windows": ["main"], + "permissions": [ + "core:window:allow-destroy", + "core:window:allow-start-dragging", + "core:window:allow-toggle-maximize", + "updater:default", + "process:allow-restart" + ] +} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..f90ea2b Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..7f774e0 Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..a989560 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..62fc515 Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..990e79a Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..de000bb Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..2e21fbf Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..160e6c4 Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..fd822a3 Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..cb53690 Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..05e9f43 Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..ae30092 Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..60eb23f Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..43fc441 Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000..f9582f6 Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..7a2bb6c Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000..7bdf70c Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/margin-docs.svg b/src-tauri/icons/margin-docs.svg new file mode 100644 index 0000000..7608e41 --- /dev/null +++ b/src-tauri/icons/margin-docs.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/src-tauri/src/dto.rs b/src-tauri/src/dto.rs new file mode 100644 index 0000000..93bf266 --- /dev/null +++ b/src-tauri/src/dto.rs @@ -0,0 +1,185 @@ +// The IPC contract. Every type here has a matching declaration in src/ipc.ts. Both sides are +// frozen once written: implementation modules add bodies, not fields. +// +// Types only. No `#[tauri::command]` lives here: the commands sit in the modules that implement +// them and are registered in lib.rs. + +use serde::{Deserialize, Serialize}; + +/// One open folder. `id` is derived from the path, so it survives a relaunch and a root can be +/// addressed without the frontend carrying the path around. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RootInfo { + pub id: String, + pub path: String, + /// The folder's own name, which is what the sidebar heading shows. + pub name: String, + pub opened_ms: i64, +} + +/// A node in one root's tree, including the root itself. The whole tree is read in one go, so +/// `children` being empty means a directory is empty, never that it is unexplored. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileNode { + pub path: String, + pub name: String, + /// dir | markdown | text | other + pub kind: String, + /// True for markdown and .txt, the two kinds that open in the editor. A directory is not + /// editable either, so the greyed row in the tree is `kind == "other"` and not `!editable`. + pub editable: bool, + pub modified_ms: i64, + #[serde(default)] + pub children: Vec, +} + +/// `modified_ms` is the timestamp the text was read at. The frontend keeps it and hands it back +/// on write, which is the only way it can tell its buffer apart from a file something else has +/// touched since. Frontmatter is not split out here: the editor parses it, hides it and writes it +/// back, so the backend only ever sees a whole document. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadResult { + pub path: String, + pub text: String, + pub modified_ms: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteResult { + pub path: String, + pub modified_ms: i64, + /// The file moved on from the timestamp the caller expected and nothing was written. Not an + /// error: the document is still open and still unsaved, and the user has to be asked which + /// copy wins. + pub conflict: bool, +} + +/// Where a pasted image landed. `rel_path` is what goes into the markdown link, relative to the +/// document that received the paste; `path` is absolute, which is what the tree needs. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetResult { + pub path: String, + pub rel_path: String, +} + +/// Payload of the `watch-event` event. `root` is a `RootInfo` id. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WatchEvent { + pub root: String, + pub path: String, + /// created | modified | removed | renamed + pub kind: String, + /// Where the file was before a rename, absent on every other kind. + #[serde(default)] + pub old_path: Option, +} + +/// Progress of the SQLite index, which lives in the app data directory and never in a user +/// folder. Also the payload of the `index-progress` event. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IndexStatus { + /// idle | indexing | error + pub phase: String, + pub indexed: u32, + pub total: u32, + /// Epoch milliseconds of the last completed pass. + pub last_indexed: Option, + pub error: Option, + pub message: Option, +} + +impl Default for IndexStatus { + fn default() -> Self { + IndexStatus { + phase: "idle".to_string(), + indexed: 0, + total: 0, + last_indexed: None, + error: None, + message: None, + } + } +} + +/// Half-open offsets into whichever string the hit says they belong to, for highlighting. +/// +/// The unit is a UTF-16 code unit, which is what a JavaScript string is indexed in and what the +/// `slice` that draws the highlight counts. Not bytes, and deliberately not code points either: +/// index.rs works in code points throughout and converts once at the boundary, in `to_utf16`, +/// because the two agree on everything in the BMP and disagree by one per emoji, which is exactly +/// the kind of difference that is invisible until somebody puts one in a filename. +/// +/// `SpellIssue` counts differently on purpose. Its offsets address a ProseMirror document, and +/// ProseMirror counts code points. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MatchRange { + pub start: u32, + pub end: u32, +} + +/// One quick-open result. `ranges` index into `rel_path`, which is also what the row shows, so a +/// match on a folder name can be highlighted where it actually was. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuickOpenHit { + pub path: String, + pub name: String, + pub root: String, + pub rel_path: String, + pub score: i32, + #[serde(default)] + pub ranges: Vec, +} + +/// One full text result. `line` is one-based and counted over the file as it sits on disk, +/// frontmatter included, so jumping to it lands in the right place. `ranges` index into `snippet`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchHit { + pub path: String, + pub root: String, + pub title: String, + pub line: u32, + pub snippet: String, + #[serde(default)] + pub ranges: Vec, +} + +/// A document that links here, shown at the end of the document it points at. Links between +/// documents are relative markdown links, so a backlink is a resolved `](../thing.md)` and +/// nothing more. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Backlink { + pub path: String, + pub title: String, + pub snippet: String, +} + +/// One misspelling in a run of text handed to the checker. +/// +/// `start` and `end` are half-open offsets in *characters*, not bytes and not UTF-16 units, +/// because the other end is JavaScript addressing a ProseMirror document and ProseMirror counts +/// in code points. macspell.rs does the conversion from the UTF-16 ranges AppKit answers in, and +/// it is the only place in the app where that conversion is allowed to happen. +/// +/// A word with no guesses is still an issue: NSSpellChecker regularly flags a typo it has no +/// suggestion for, and dropping it because the menu would be empty is how a checker earns a +/// reputation for missing things. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpellIssue { + pub start: usize, + pub end: usize, + pub word: String, + #[serde(default)] + pub suggestions: Vec, +} diff --git a/src-tauri/src/fs.rs b/src-tauri/src/fs.rs new file mode 100644 index 0000000..9d99787 --- /dev/null +++ b/src-tauri/src/fs.rs @@ -0,0 +1,975 @@ +// Rust owns the filesystem and nothing above it. Every byte that reaches or leaves the disk goes +// through this module: opening a folder, walking it, reading a document, writing one back, sending +// a file to the Trash, dropping a pasted image beside the document that received it, and the +// SQLite index that answers the three questions a plain tree cannot. Markdown is never parsed +// here; that is the bridge's job in TypeScript. +// +// Two promises constrain nearly every function below, and both are the product's rather than the +// implementation's. Opening a folder or a file never writes anything, so nothing here may leave a +// dotfile, a lock, a cache or a sidecar inside a folder the user opened. And every write is +// atomic: a temp file beside the target, flushed, then renamed over it, so a crash or a full disk +// can never leave a half written document where the user's document used to be. + +use std::cmp::Ordering; +use std::collections::HashMap; +use std::ffi::OsString; +use std::fs; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering as Memory}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use ignore::WalkBuilder; +use tauri::{AppHandle, State}; +use tauri_plugin_opener::OpenerExt; + +use crate::dto::{ + AssetResult, Backlink, FileNode, IndexStatus, QuickOpenHit, ReadResult, RootInfo, SearchHit, + WriteResult, +}; +use crate::Roots; + +/// Skipped whatever the folder's own gitignore says, because not one of the four is ever a +/// document and a documents folder that happens to be a checkout should not open with its build +/// output filling the sidebar. +/// +/// The index walks by the same rule, so the sidebar and the search box agree about what a folder +/// holds. +pub(crate) const ALWAYS_SKIPPED: [&str; 4] = [".git", "node_modules", "target", "dist"]; + +/// These two mirror `src/model/doc.ts` and have to keep agreeing with it: the frontend decides +/// from the extension whether a row opens in the editor, and `FileNode.editable` is that same +/// decision made here. +const MARKDOWN_EXTENSIONS: [&str; 5] = ["md", "markdown", "mdown", "mkd", "mkdn"]; +const TEXT_EXTENSIONS: [&str; 2] = ["txt", "text"]; + +/// Where the open folders are remembered between launches, inside the app data directory and never +/// inside a folder the user opened. +const ROOTS_FILE: &str = "roots.json"; + +/// What a pasted image is called when the clipboard suggests nothing usable. +const FALLBACK_ASSET_NAME: &str = "image.png"; + +// Path validation. Every path below arrives as a string from the frontend, and the frontend is a +// webview: a bug in a link resolver, a crafted document, a drag from somewhere unexpected or a +// stale path belonging to a folder that has since been closed can all put an arbitrary string +// here. This is the one place in the app where being wrong damages files the user never opened, so +// the rule is deliberately blunt and every command that takes a path goes through it, reads as +// well as writes. +// +// A path is accepted only when it holds no `..` component at all and, once symlinks have been +// resolved, sits inside a folder that is currently open. Canonicalising first is what makes the +// second half mean anything: without it both `~/notes/../../.ssh/id_rsa` and a symlink pointing at +// /etc read as being inside the root. A path that does not exist yet is resolved against its +// deepest existing ancestor and the remaining components are appended, so creating a file is +// checked exactly as strictly as writing one. With no folder open nothing is inside a root, so +// every path is rejected, which is the right default rather than an inconvenience. + +fn path_string(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +fn ms_since_epoch(time: SystemTime) -> i64 { + time.duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn now_ms() -> i64 { + ms_since_epoch(SystemTime::now()) +} + +pub(crate) fn modified_ms(meta: &fs::Metadata) -> i64 { + meta.modified().map(ms_since_epoch).unwrap_or(0) +} + +/// True for a broken symlink too, which `Path::exists` is not. A name pointing at nothing is still +/// a name that cannot be created. +fn taken(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok() +} + +pub(crate) fn kind_for(path: &Path, is_dir: bool) -> &'static str { + if is_dir { + return "dir"; + } + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + match name.rfind('.') { + Some(dot) if dot > 0 => { + let ext = &name[dot + 1..]; + if MARKDOWN_EXTENSIONS.contains(&ext) { + "markdown" + } else if TEXT_EXTENSIONS.contains(&ext) { + "text" + } else { + "other" + } + } + _ => "other", + } +} + +fn node_from(path: &Path, is_dir: bool, modified: i64) -> FileNode { + let kind = kind_for(path, is_dir); + FileNode { + path: path_string(path), + name: path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path_string(path)), + kind: kind.to_string(), + editable: kind == "markdown" || kind == "text", + modified_ms: modified, + children: Vec::new(), + } +} + +fn node_for(path: &Path) -> Result { + let meta = fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; + Ok(node_from(path, meta.is_dir(), modified_ms(&meta))) +} + +/// A base name and not a path. `file_rename` cannot move anything, so a name carrying a separator +/// is refused rather than quietly turned into a move. +fn check_name(name: &str) -> Result<&str, String> { + let trimmed = name.trim(); + if trimmed.is_empty() || trimmed == "." || trimmed == ".." { + return Err(format!("not a usable name: {name}")); + } + if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains('\0') { + return Err(format!("a name cannot contain a path separator: {name}")); + } + Ok(trimmed) +} + +fn resolve(path: &Path) -> Result { + if !path.is_absolute() { + return Err(format!("path is not absolute: {}", path.display())); + } + if path.components().any(|c| matches!(c, Component::ParentDir)) { + return Err(format!("path contains a parent traversal: {}", path.display())); + } + let mut tail: Vec = Vec::new(); + let mut cursor = path.to_path_buf(); + loop { + if let Ok(base) = fs::canonicalize(&cursor) { + let mut out = base; + for part in tail.iter().rev() { + out.push(part); + } + return Ok(out); + } + let name = cursor + .file_name() + .ok_or_else(|| format!("cannot resolve path: {}", path.display()))? + .to_os_string(); + tail.push(name); + cursor = cursor + .parent() + .ok_or_else(|| format!("cannot resolve path: {}", path.display()))? + .to_path_buf(); + } +} + +/// The gate described above. `root_paths` are the folders the user has actually opened. +pub fn resolve_in_roots(root_paths: &[String], raw: &str) -> Result { + let resolved = resolve(Path::new(raw))?; + for root in root_paths { + let base = match fs::canonicalize(root) { + Ok(base) => base, + Err(_) => continue, + }; + // Component wise, so /notes-old is not read as being inside /notes. + if resolved.starts_with(&base) { + return Ok(resolved); + } + } + Err(format!("path is outside every open folder: {raw}")) +} + +/// The lock is taken and dropped before any filesystem call, so a slow disk never blocks a command +/// that only wants to know which folders are open. +fn open_root_paths(roots: &State<'_, Roots>) -> Result, String> { + let open = roots.0.lock().map_err(|e| e.to_string())?; + Ok(open.iter().map(|root| root.path.clone()).collect()) +} + +fn checked(roots: &State<'_, Roots>, raw: &str) -> Result { + resolve_in_roots(&open_root_paths(roots)?, raw) +} + +/// One lock per document being written, so two saves of one file cannot interleave. +/// +/// Keyed by the resolved path, because `/tmp/notes/a.md` and `/private/tmp/notes/a.md` are one +/// document and two keys would be two locks and no mutual exclusion at all. An entry lives only +/// while somebody holds it: every caller drops the locks nobody is using on the way in, so the map +/// is the size of the writes in flight rather than of every document ever saved. +static WRITE_LOCKS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Separates one temp name from the next inside this process, as the pid and the clock separate +/// this process from any other. +static WRITE_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// Enough tries that a name collision has to be deliberate rather than unlucky. +const TEMP_NAME_TRIES: u32 = 64; + +fn write_lock_for(path: &Path) -> Arc> { + let key = resolve(path).unwrap_or_else(|_| path.to_path_buf()); + let mut locks = match WRITE_LOCKS.lock() { + Ok(locks) => locks, + // The guarded value is `()`, so a writer that panicked left nothing half-built behind. + Err(poisoned) => poisoned.into_inner(), + }; + locks.retain(|_, held| Arc::strong_count(held) > 1); + locks.entry(key).or_default().clone() +} + +/// A name for the temp file that no other write is using and no user is plausibly holding. +/// +/// Beside the target, because a rename is only atomic within one filesystem. Hidden, so it is not +/// mistaken for a document by the tree, by the watcher or by the person looking at the folder. +/// Unique per call, because a name derived from the target alone is a name two concurrent saves +/// both own and neither can safely delete. `.tmp` last so the watcher's transient rule catches it +/// whatever the document happens to be called. +fn temp_path(path: &Path) -> Result { + let dir = path + .parent() + .ok_or_else(|| format!("cannot write {}: no folder to write in", path.display()))?; + let name = path + .file_name() + .ok_or_else(|| format!("cannot write {}: no name to write to", path.display()))? + .to_string_lossy() + .into_owned(); + let pid = std::process::id(); + for _ in 0..TEMP_NAME_TRIES { + let n = WRITE_SEQUENCE.fetch_add(1, Memory::Relaxed); + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let candidate = dir.join(format!(".{name}.{pid}-{n}-{stamp:x}.tmp")); + // A name already on disk is somebody else's, and this call is the only thing allowed to + // delete the name it picks. + if !taken(&candidate) { + return Ok(candidate); + } + } + Err(format!("cannot find a free temp name beside {}", path.display())) +} + +fn fill_temp(path: &Path, tmp: &Path, bytes: &[u8], existed: bool) -> Result<(), String> { + let mut options = fs::OpenOptions::new(); + options.write(true); + if existed { + fs::copy(path, tmp).map_err(|e| e.to_string())?; + options.truncate(true); + } else { + options.create_new(true); + } + let mut file = options.open(tmp).map_err(|e| e.to_string())?; + file.write_all(bytes).map_err(|e| e.to_string())?; + file.sync_all().map_err(|e| e.to_string()) +} + +/// Writes `bytes` to `path` through a temp file beside it and a rename, which is atomic within a +/// filesystem. At no instant does the target hold half a document: it holds every old byte or +/// every new one, whatever happens in between, and that is what makes an autosaving editor safe +/// against a crash or a full disk mid-write. A rename that fails has not happened, so the original +/// is still whole and still where it was. +/// +/// The temp file starts as a copy of the original rather than as an empty file. On macOS +/// `fs::copy` carries permissions, ACLs and extended attributes across, and since the file the +/// user is left with is the temp file, that copy is the only thing stopping a save from quietly +/// dropping a Finder tag or the executable bit. +/// +/// There is no `.bak` rotation, deliberately. This is the user's own markdown in the user's own +/// folder, very often under version control, and the app is already holding the whole source +/// string in memory and refusing to write when the mtime on disk has moved. A backup sibling buys +/// none of that back, and a backup named after the target is a file the user may own themselves, +/// which the rotation would unlink without asking and without the Trash. Nothing is deleted here +/// but the temp file this call created. +/// +/// Writes to one path are serialized. Two saves of one document, which is all a debounced autosave +/// and a Cmd+S landing together are, would otherwise race between two renames and leave the +/// document at neither name. +pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { + let lock = write_lock_for(path); + let _held = match lock.lock() { + Ok(held) => held, + Err(poisoned) => poisoned.into_inner(), + }; + write_through_temp(path, bytes) +} + +fn write_through_temp(path: &Path, bytes: &[u8]) -> Result<(), String> { + let tmp = temp_path(path)?; + let existed = taken(path); + + // Both ends of the rename, before either is touched: the watcher sees the temp file appear and + // the document change as two unrelated events, and either one getting through is the app's own + // save coming back to the frontend as somebody else's edit. + crate::watch::note_self_write(&tmp); + crate::watch::note_self_write(path); + + if let Err(e) = fill_temp(path, &tmp, bytes, existed) { + let _ = fs::remove_file(&tmp); + return Err(e); + } + + // Again, because the suppression is a window that started before the copy and the fsync, and on + // a large document those are most of it. + crate::watch::note_self_write(&tmp); + crate::watch::note_self_write(path); + + match fs::rename(&tmp, path) { + Ok(()) => Ok(()), + Err(e) => { + let _ = fs::remove_file(&tmp); + Err(e.to_string()) + } + } +} + +/// The untitled rule: `untitled.md`, then `untitled-2.md`, and never an overwrite. The suffix goes +/// before the extension so the file keeps opening in the same app as the one it was named after. +pub fn free_path(dir: &Path, name: &str) -> PathBuf { + let first = dir.join(name); + if !taken(&first) { + return first; + } + let as_path = Path::new(name); + let stem = as_path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| name.to_string()); + let ext = as_path.extension().map(|e| e.to_string_lossy().into_owned()); + let joined = |suffix: String| match &ext { + Some(ext) => dir.join(format!("{stem}-{suffix}.{ext}")), + None => dir.join(format!("{stem}-{suffix}")), + }; + for n in 2..10_000u32 { + let candidate = joined(n.to_string()); + if !taken(&candidate) { + return candidate; + } + } + joined(now_ms().to_string()) +} + +fn compare_nodes(a: &FileNode, b: &FileNode) -> Ordering { + let a_dir = a.kind == "dir"; + let b_dir = b.kind == "dir"; + b_dir + .cmp(&a_dir) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + .then_with(|| a.name.cmp(&b.name)) +} + +fn assemble( + path: &Path, + nodes: &mut HashMap, + children: &HashMap>, +) -> Option { + let mut node = nodes.remove(path)?; + if let Some(kids) = children.get(path) { + let mut built: Vec = kids + .iter() + .filter_map(|kid| assemble(kid, nodes, children)) + .collect(); + built.sort_by(compare_nodes); + node.children = built; + } + Some(node) +} + +/// One pass over a folder, returning the root node with everything under it already attached. +/// +/// `show_ignored` turns off gitignore, the hidden file rule and the four always skipped folders in +/// one go, for a settings toggle that lets a user see what the tree is holding back. +pub fn scan_tree(root: &Path, show_ignored: bool) -> Result { + let meta = fs::metadata(root).map_err(|e| format!("{}: {e}", root.display()))?; + if !meta.is_dir() { + return Err(format!("not a folder: {}", root.display())); + } + + let mut builder = WalkBuilder::new(root); + builder + // A symlinked folder pointing back at one of its own ancestors would otherwise walk for + // ever, and a documents folder is exactly where somebody keeps one. + .follow_links(false) + // A .gitignore is worth honouring whether or not the folder is a checkout: the user wrote + // it about these files either way. + .require_git(false) + .standard_filters(!show_ignored); + if !show_ignored { + builder.filter_entry(|entry| { + if entry.depth() == 0 { + return true; + } + if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + return true; + } + !ALWAYS_SKIPPED.contains(&entry.file_name().to_string_lossy().as_ref()) + }); + } + + let mut nodes: HashMap = HashMap::new(); + let mut children: HashMap> = HashMap::new(); + for entry in builder.build() { + // One unreadable entry is one missing row and not a failed tree. A documents folder can + // easily hold something the user cannot stat, and losing the whole sidebar over it would + // be a far worse answer than losing the row. + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + let path = entry.path().to_path_buf(); + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + let modified = entry.metadata().map(|m| modified_ms(&m)).unwrap_or(0); + if entry.depth() > 0 { + if let Some(parent) = path.parent() { + children + .entry(parent.to_path_buf()) + .or_default() + .push(path.clone()); + } + } + nodes.insert(path.clone(), node_from(&path, is_dir, modified)); + } + + assemble(root, &mut nodes, &children).ok_or_else(|| format!("cannot read {}", root.display())) +} + +pub fn read_document(path: &Path) -> Result { + // The mtime is taken before the read rather than after. Read the other way round and a change + // landing between the two would be stamped onto older text, and the next save would overwrite + // it believing it had seen it. + let meta = fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; + if meta.is_dir() { + return Err(format!("not a file: {}", path.display())); + } + let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + Ok(ReadResult { + path: path_string(path), + text, + modified_ms: modified_ms(&meta), + }) +} + +pub fn write_document( + path: &Path, + text: &str, + expected_modified_ms: Option, +) -> Result { + // A file that is gone falls through to the write. Recreating a document somebody deleted under + // the user is not clobbering a change, and refusing would strand the buffer with nowhere to go. + if let (Some(expected), Ok(meta)) = (expected_modified_ms, fs::metadata(path)) { + let current = modified_ms(&meta); + if current != expected { + return Ok(WriteResult { + path: path_string(path), + modified_ms: current, + conflict: true, + }); + } + } + atomic_write(path, text.as_bytes())?; + let meta = fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; + Ok(WriteResult { + path: path_string(path), + modified_ms: modified_ms(&meta), + conflict: false, + }) +} + +pub fn create_file(parent: &Path, name: &str) -> Result { + let name = check_name(name)?; + if !parent.is_dir() { + return Err(format!("not a folder: {}", parent.display())); + } + let target = free_path(parent, name); + // create_new rather than a check and then a create: the whole point of the untitled rule is + // that nothing is ever overwritten, and another process can take the name between the two. + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&target) + .map_err(|e| format!("{}: {e}", target.display()))?; + node_for(&target) +} + +pub fn create_folder(parent: &Path, name: &str) -> Result { + let name = check_name(name)?; + if !parent.is_dir() { + return Err(format!("not a folder: {}", parent.display())); + } + let target = free_path(parent, name); + fs::create_dir(&target).map_err(|e| format!("{}: {e}", target.display()))?; + node_for(&target) +} + +pub fn rename_entry(path: &Path, name: &str) -> Result { + let name = check_name(name)?; + let parent = path + .parent() + .ok_or_else(|| format!("cannot rename {}", path.display()))?; + let target = parent.join(name); + if target == path { + return node_for(path); + } + // On a case insensitive volume a case only rename finds the file being renamed already sitting + // at the target, which is not a collision. + if taken(&target) && fs::canonicalize(&target).ok() != fs::canonicalize(path).ok() { + return Err(format!("already exists: {}", target.display())); + } + fs::rename(path, &target).map_err(|e| format!("{}: {e}", target.display()))?; + node_for(&target) +} + +pub fn move_entry(path: &Path, dest_dir: &Path) -> Result { + if !dest_dir.is_dir() { + return Err(format!("not a folder: {}", dest_dir.display())); + } + if dest_dir.starts_with(path) { + return Err(format!("cannot move {} inside itself", path.display())); + } + if path.parent() == Some(dest_dir) { + // Already there. Going on would hand it a free name and leave two of it. + return node_for(path); + } + let name = path + .file_name() + .ok_or_else(|| format!("cannot move {}", path.display()))? + .to_string_lossy() + .into_owned(); + let target = free_path(dest_dir, &name); + if fs::rename(path, &target).is_ok() { + return node_for(&target); + } + // A rename cannot cross a volume, so the move becomes a copy and a trip to the Trash. Never a + // remove: if anything about this went wrong the original is still recoverable in Finder. + copy_tree(path, &target)?; + trash_entry(path)?; + node_for(&target) +} + +fn copy_tree(src: &Path, dest: &Path) -> Result<(), String> { + let meta = fs::symlink_metadata(src).map_err(|e| format!("{}: {e}", src.display()))?; + if !meta.is_dir() { + return fs::copy(src, dest) + .map(|_| ()) + .map_err(|e| format!("{}: {e}", dest.display())); + } + fs::create_dir(dest).map_err(|e| format!("{}: {e}", dest.display()))?; + for entry in fs::read_dir(src).map_err(|e| format!("{}: {e}", src.display()))? { + let entry = entry.map_err(|e| format!("{}: {e}", src.display()))?; + copy_tree(&entry.path(), &dest.join(entry.file_name()))?; + } + Ok(()) +} + +pub fn duplicate_entry(path: &Path) -> Result { + let parent = path + .parent() + .ok_or_else(|| format!("cannot duplicate {}", path.display()))?; + let name = path + .file_name() + .ok_or_else(|| format!("cannot duplicate {}", path.display()))? + .to_string_lossy() + .into_owned(); + let target = free_path(parent, &name); + copy_tree(path, &target)?; + node_for(&target) +} + +/// Never `fs::remove_file`. These are the user's own documents and this app does not get to be the +/// reason one of them is gone for good. +pub fn trash_entry(path: &Path) -> Result<(), String> { + trash::delete(path).map_err(|e| format!("{}: {e}", path.display())) +} + +pub fn write_asset(doc_path: &Path, bytes: &[u8], name: &str) -> Result { + let dir = doc_path + .parent() + .ok_or_else(|| format!("cannot place an image beside {}", doc_path.display()))?; + // The clipboard suggests the name, so it is a suggestion and not a path: only the last + // component of it is ever used. + let suggested = Path::new(name) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .filter(|n| check_name(n).is_ok()) + .unwrap_or_else(|| FALLBACK_ASSET_NAME.to_string()); + + let assets = dir.join("assets"); + if taken(&assets) { + if !assets.is_dir() { + return Err(format!("not a folder: {}", assets.display())); + } + } else { + fs::create_dir_all(&assets).map_err(|e| format!("{}: {e}", assets.display()))?; + } + + let target = free_path(&assets, &suggested); + atomic_write(&target, bytes)?; + let file = target + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or(suggested); + Ok(AssetResult { + path: path_string(&target), + rel_path: format!("assets/{file}"), + }) +} + +/// A root id is a hash of the path and of nothing else, so the same folder is the same root after +/// a relaunch and the frontend can address one without carrying its path around. FNV-1a rather +/// than the standard hasher, whose output is only promised to be stable within one build. +pub fn root_id_for(path: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in path.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{hash:016x}") +} + +fn roots_file(app: &AppHandle) -> Result { + Ok(crate::library::app_data_dir(app)?.join(ROOTS_FILE)) +} + +fn load_roots(app: &AppHandle) -> Vec { + let Ok(file) = roots_file(app) else { + return Vec::new(); + }; + let Ok(text) = fs::read_to_string(file) else { + return Vec::new(); + }; + serde_json::from_str(&text).unwrap_or_default() +} + +fn save_roots(app: &AppHandle, roots: &[RootInfo]) -> Result<(), String> { + let file = roots_file(app)?; + let text = serde_json::to_string_pretty(roots).map_err(|e| e.to_string())?; + atomic_write(&file, text.as_bytes()) +} + +/// Every folder currently open, in the order they were opened, which is the order the sidebar +/// lists them in. +/// +/// The list outlives a relaunch, so the first call after launch reads it back from the app data +/// directory and fills the managed state from it. A root whose folder has since been deleted, +/// renamed or unmounted is dropped rather than handed back as a row that cannot be expanded. +#[tauri::command] +pub fn roots_list(app: AppHandle, roots: State<'_, Roots>) -> Result, String> { + let mut open = roots.0.lock().map_err(|e| e.to_string())?; + if open.is_empty() { + *open = load_roots(&app); + } + let before = open.len(); + open.retain(|root| Path::new(&root.path).is_dir()); + if open.len() != before { + save_roots(&app, &open)?; + } + Ok(open.clone()) +} + +/// Adds `path` to the open roots and returns it. Idempotent: opening a folder that is already open +/// returns the entry that is already there rather than a second copy of it. +/// +/// `id` is derived from the path and from nothing else, so the same folder is the same root across +/// relaunches and the frontend can address a root without carrying its path around. Opening a +/// folder never writes anything into it, and that includes not creating it: a `path` that is not +/// an existing directory is an error, not a mkdir. +#[tauri::command] +pub fn root_open(app: AppHandle, roots: State<'_, Roots>, path: String) -> Result { + let canonical = fs::canonicalize(&path).map_err(|e| format!("{path}: {e}"))?; + if !canonical.is_dir() { + return Err(format!("not a folder: {}", canonical.display())); + } + let path = path_string(&canonical); + let id = root_id_for(&path); + + let mut open = roots.0.lock().map_err(|e| e.to_string())?; + if open.is_empty() { + *open = load_roots(&app); + } + if let Some(existing) = open.iter().find(|root| root.id == id) { + return Ok(existing.clone()); + } + let info = RootInfo { + id, + name: canonical + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.clone()), + path, + opened_ms: now_ms(), + }; + open.push(info.clone()); + save_roots(&app, &open)?; + // The lock goes before the index hears about the folder: the indexer's first move is to ask + // `Roots` where that root is, and it should not have to wait for this command to return. + drop(open); + // Scanned now rather than at the next rebuild, or a folder just opened would answer nothing at + // all to a search until something else asked for a full pass. + crate::index::scan_root(&app, info.clone()); + Ok(info) +} + +/// Forgets a root and persists the shorter list. Touches nothing inside the folder itself. +/// +/// Stopping the watcher is not done here. The frontend calls `watch_stop` for the same root, which +/// keeps this module from having to know that the watcher exists. +#[tauri::command] +pub fn root_close(app: AppHandle, roots: State<'_, Roots>, root_id: String) -> Result<(), String> { + let mut open = roots.0.lock().map_err(|e| e.to_string())?; + let before = open.len(); + open.retain(|root| root.id != root_id); + if open.len() == before { + return Ok(()); + } + save_roots(&app, &open)?; + drop(open); + // The rows go with the folder. Nothing can be opened from a search result that belongs to a + // folder that is no longer there to open it in. + crate::index::forget_root(&app, &root_id); + Ok(()) +} + +/// The whole tree for one root in a single pass, the root node itself included. Empty `children` +/// therefore means an empty directory, never one that has not been explored yet. +/// +/// Gitignore aware through the `ignore` crate, and `.git` itself is skipped too: a documents folder +/// under version control should not surface its own ignored build output as if it were documents. +/// Everything else is returned, including files the editor cannot open, because the tree greys +/// those rows out rather than hiding them. Children come back sorted directories first and then by +/// name, case insensitively, so the tree does not reshuffle itself between two reads of an +/// unchanged folder. +#[tauri::command(async)] +pub fn tree_read(roots: State<'_, Roots>, root_id: String) -> Result { + let path = roots.path_for(&root_id)?; + scan_tree(Path::new(&path), false) +} + +/// Opens Finder with the file selected, rather than opening the file. +#[tauri::command] +pub fn reveal_in_finder( + app: AppHandle, + roots: State<'_, Roots>, + path: String, +) -> Result<(), String> { + let path = checked(&roots, &path)?; + app.opener() + .reveal_item_in_dir(&path) + .map_err(|e| format!("{}: {e}", path.display())) +} + +/// Hands a file to whatever macOS opens it with. This is the only way a non editable file in the +/// tree can be opened at all, so it has to work for anything, not just for documents. +#[tauri::command] +pub fn open_external(app: AppHandle, roots: State<'_, Roots>, path: String) -> Result<(), String> { + let path = checked(&roots, &path)?; + app.opener() + .open_path(path_string(&path), None::<&str>) + .map_err(|e| format!("{}: {e}", path.display())) +} + +/// Reads a document as UTF-8, and reads nothing else: no metadata is written, no lock is taken and +/// no sidecar appears beside it. +/// +/// `modified_ms` is the file's mtime as it was at the moment of the read. The caller keeps it and +/// hands it back on write, which is the only thing that can tell an unsaved buffer apart from a +/// file another program has touched since. A file that is not valid UTF-8 is an error rather than +/// a lossy conversion, because a lossy read followed by a save would corrupt the user's file. +#[tauri::command(async)] +pub fn file_read(roots: State<'_, Roots>, path: String) -> Result { + read_document(&checked(&roots, &path)?) +} + +/// Writes a document atomically: a temp file in the same directory, flushed and synced, then +/// renamed over the target. The old bytes survive a crash, a full disk and a power cut mid-write. +/// +/// `expected_modified_ms` is the mtime the caller last saw. If the file has moved on from it, +/// nothing is written and the result carries `conflict`, which is not an error: the document is +/// still open, still unsaved, and the user is the one who decides which copy wins. `None` means +/// write regardless, which is what a first save of a new file does. +/// +/// Permissions, ownership and any extended attributes of the original survive the rename, since +/// the file the user ends up with is the temp file and it must not arrive with different bits. +/// +/// The index is told directly rather than through the watcher. `watch::note_self_write` drops the +/// app's own writes out of the watch stream so an autosave does not come back as somebody else's +/// edit, which means the one document the watcher never reports is the one the user is working in. +/// Without this line the only version of it the index would ever hold is the one from before they +/// started typing. +#[tauri::command(async)] +pub fn file_write( + app: AppHandle, + roots: State<'_, Roots>, + path: String, + text: String, + expected_modified_ms: Option, +) -> Result { + let path = checked(&roots, &path)?; + let result = write_document(&path, &text, expected_modified_ms)?; + // A conflict wrote nothing, and whatever moved the file on is an outside change the watcher + // does report. + if !result.conflict { + crate::index::note_write(&app, &path); + } + Ok(result) +} + +/// Creates an empty file inside `parent_path`. `name` is a suggestion: a name already taken gets a +/// suffix, and the node that comes back carries the name that was really used, so the caller never +/// has to guess at it or race another process for it. +#[tauri::command] +pub fn file_create( + roots: State<'_, Roots>, + parent_path: String, + name: String, +) -> Result { + create_file(&checked(&roots, &parent_path)?, &name) +} + +/// Creates an empty directory inside `parent_path`, under the same suggested-name rule as +/// `file_create`. +#[tauri::command] +pub fn file_folder_create( + roots: State<'_, Roots>, + parent_path: String, + name: String, +) -> Result { + create_folder(&checked(&roots, &parent_path)?, &name) +} + +/// Renames a file or folder where it stands. `name` is a base name and not a path: a `name` holding +/// a path separator is an error, because this command cannot move anything and quietly doing so +/// would be worse than refusing. +/// +/// This is the only thing that changes a document's identity, and it happens because the user asked +/// for it. Nothing in this app renames a file on its own, least of all because a heading changed. +#[tauri::command] +pub fn file_rename( + roots: State<'_, Roots>, + path: String, + name: String, +) -> Result { + rename_entry(&checked(&roots, &path)?, &name) +} + +/// Moves a file or folder into `dest_dir`, keeping its name unless that name is taken there. +/// +/// This command moves bytes and nothing else. The relative links a move breaks are rewritten a +/// layer up, in src/linkRewrite.ts, which splices one destination at a time into the file's own +/// text and never hands a document to the serializer, so a file whose links did not move is not +/// written at all. +#[tauri::command] +pub fn file_move( + roots: State<'_, Roots>, + path: String, + dest_dir: String, +) -> Result { + let open = open_root_paths(&roots)?; + let path = resolve_in_roots(&open, &path)?; + let dest_dir = resolve_in_roots(&open, &dest_dir)?; + move_entry(&path, &dest_dir) +} + +/// Copies a file, or a folder and everything under it, beside itself under a free name. The copy is +/// byte for byte: nothing is parsed, normalised or reformatted on the way through. +#[tauri::command(async)] +pub fn file_duplicate(roots: State<'_, Roots>, path: String) -> Result { + duplicate_entry(&checked(&roots, &path)?) +} + +/// Sends a file or folder to the system Trash through the `trash` crate, never `remove_file`. These +/// are the user's own documents and this app does not get to be the reason one of them is gone for +/// good, so a delete is always something Finder can undo. +#[tauri::command(async)] +pub fn file_trash(roots: State<'_, Roots>, path: String) -> Result<(), String> { + trash_entry(&checked(&roots, &path)?) +} + +/// Writes a pasted image into an `assets/` folder beside the document that received the paste, +/// creating that folder when it is not already there. Images are the only thing other than markdown +/// this app ever puts inside a user's folder. +/// +/// `name` is what the clipboard suggested, which is usually `image.png` and usually already taken, +/// so a taken name gets a suffix. `rel_path` in the result is what goes into the markdown link, +/// relative to the document, so the folder stays movable and shareable as a whole. +#[tauri::command(async)] +pub fn asset_write( + roots: State<'_, Roots>, + doc_path: String, + bytes: Vec, + name: String, +) -> Result { + write_asset(&checked(&roots, &doc_path)?, &bytes, &name) +} + +// The SQLite index, which lives in the app data directory and never inside a folder the user +// opened. It is derived state rather than a source of truth: every row is rebuilt from the files on +// disk, so deleting the database costs nothing but the time to walk the open roots again. It is +// kept current from the same debounced batch the watcher already sends the frontend, plus one call +// in `file_write` for the app's own saves, which are the changes that batch deliberately never +// mentions. +// +// Everything below is a handful of lines because the index itself is a module of its own: these are +// the commands, and index.rs is the database. + +/// Rescans every open root from scratch and returns the status the pass started with. Progress +/// arrives on the `index-progress` event, because a full rescan of a large folder outlives any one +/// command. +#[tauri::command(async)] +pub fn index_rebuild(app: AppHandle, roots: State<'_, Roots>) -> Result { + // The roots are read here rather than on the indexer's thread, so the pass covers the folders + // that were open when the user asked for it and not whatever the list has become since. + let open = roots.0.lock().map_err(|e| e.to_string())?.clone(); + crate::index::rebuild(&app, open) +} + +/// Where the index has got to, for the status line. Cheap enough to poll and safe to call before +/// any indexing has ever run. +#[tauri::command] +pub fn index_status(app: AppHandle) -> Result { + crate::index::status(&app) +} + +/// Fuzzy match over paths relative to their root, across every open root, best score first. +/// +/// `ranges` index into `rel_path`, which is also the string the row shows, so a match on a folder +/// name is highlighted where it really was. They are character offsets and not byte offsets, +/// because the other end is JavaScript and highlights by character. +#[tauri::command(async)] +pub fn search_quick_open( + app: AppHandle, + query: String, + limit: u32, +) -> Result, String> { + crate::index::quick_open(&app, &query, limit) +} + +/// Full text search across every open root through FTS5. +/// +/// `line` is one based and counted over the file as it sits on disk, frontmatter included, so +/// jumping to a hit lands on the line the user can see in any other editor. `ranges` index into +/// `snippet`, again by character. +#[tauri::command(async)] +pub fn search_text(app: AppHandle, query: String, limit: u32) -> Result, String> { + crate::index::search(&app, &query, limit) +} + +/// Every document holding a relative markdown link that resolves to `path`. +/// +/// This is a reverse lookup over links that are already in the files. Nothing is written anywhere +/// to make a backlink exist, and a document with no incoming links simply has none. +#[tauri::command(async)] +pub fn backlinks_for(app: AppHandle, path: String) -> Result, String> { + crate::index::backlinks(&app, &path) +} diff --git a/src-tauri/src/index.rs b/src-tauri/src/index.rs new file mode 100644 index 0000000..c06c61f --- /dev/null +++ b/src-tauri/src/index.rs @@ -0,0 +1,1642 @@ +// The search index: one SQLite database, in the app data directory and never inside a folder the +// user opened, holding a row per document, an FTS5 copy of its text and every relative link it +// carries. It answers the three questions a file tree cannot: quick open by path, full text across +// every open root, and the backlinks at the end of a document. +// +// All of it is derived state. Every row is rebuilt from the files on disk, so the database can be +// deleted at any moment and costs nothing but the walk that builds it again. That is why a failure +// to open it is reported rather than fatal, why the schema guard below refuses rather than repairs, +// and why the indexer never writes anything into a user's folder: there is nothing in here worth +// saving and nothing in here that a folder should be made to carry. +// +// One worker thread owns every write. Indexing is asked for from four places that know nothing +// about each other, the rebuild command, opening a folder, the watcher and the app's own saves, and +// three of those are on threads that must not block: a debounce callback holds up the next batch of +// filesystem events while it runs, and a command holds the frontend's promise open. Handing the +// work to a channel keeps all of them cheap, and because a channel is ordered it also keeps a +// deletion that arrived after a creation from being applied before it. +// +// Reading a document to index it is a read and nothing else. The indexer touches far more files +// than the editor ever does, so the promise that opening a folder writes nothing into it matters +// more here than anywhere: no sidecar, no lock, no mtime bumped, nothing. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering as Memory}; +use std::sync::mpsc::{self, Sender}; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use ignore::WalkBuilder; +use rusqlite::{params, params_from_iter, types::Value, Connection, OptionalExtension}; +use tauri::{AppHandle, Emitter, Manager}; + +use crate::dto::{Backlink, IndexStatus, MatchRange, QuickOpenHit, RootInfo, SearchHit, WatchEvent}; +use crate::Roots; + +/// Inside the app data directory, which is the only place this app writes anything of its own. +const DATABASE_FILE: &str = "index.sqlite3"; + +/// Mirrors `INDEX_PROGRESS_EVENT` in src/ipc.ts. +const INDEX_PROGRESS: &str = "index-progress"; + +/// The schema this build understands. A database found above it is refused rather than opened, in +/// `migrate` below. +const VERSION: i32 = 1; + +/// The last completed pass, kept in `meta` so a relaunch does not report a fully built index as one +/// that has never run. +const LAST_INDEXED_KEY: &str = "last_indexed"; + +/// Documents written between two commits. Every write takes the connection lock, so this is also +/// how long a search can be kept waiting during a rebuild: small enough that nobody notices, large +/// enough that a pass is not one transaction per file. +const BATCH: usize = 64; + +/// How often a pass says where it has got to. The event drives a status line, not a progress bar +/// anybody watches closely, and emitting per file would cost more than the indexing. +const PROGRESS_EVERY: u32 = 64; + +/// Longest snippet handed to the frontend, and how much of it is spent on the text before the first +/// match, in characters. +const SNIPPET_MAX: usize = 200; +const SNIPPET_LEAD: usize = 40; + +/// What `highlight()` wraps a match in. Control characters rather than anything typographic, since +/// the marks have to be found again in the text they were put into and a markdown file can contain +/// any printable string at all. +const MARK_START: char = '\u{2}'; +const MARK_END: char = '\u{3}'; + +/// Terms in one full text query. A query is a text box's worth of words; anything past this is +/// somebody pasting a document into it, and every term is another subtree the matcher walks. +const MAX_TERMS: usize = 32; + +/// Hits reported per document. A file can answer on several lines, which is what a find in files is +/// for, but one enormous file must not fill the whole list. +const HITS_PER_DOC: usize = 5; + +/// One row per document, one FTS5 row holding its text, one row per relative link it carries. +/// +/// `docs.path` is absolute and is the identity of a document: the rest of the row is derived from +/// it and is replaced wholesale whenever the file changes. `scanned_at` is what makes a pass able +/// to delete: every row touched by a pass carries that pass's stamp, so the rows left behind at the +/// end are exactly the files that are no longer there. +/// +/// `docs_fts` is keyed by `docs.rowid` rather than by path. An FTS5 table has no index other than +/// its own inverted one, so deleting a row by anything but its rowid means scanning every document +/// in the database, which turns one rescan into quadratic work. Nothing in this app ever VACUUMs, +/// which is the one thing that would renumber those rowids, and the upsert below is written as +/// ON CONFLICT DO UPDATE rather than INSERT OR REPLACE precisely because a REPLACE deletes the row +/// and hands the new one a different rowid. +/// +/// `links` records the href exactly as the file spells it and the absolute path it resolves to, so +/// backlinks are a lookup on `target_path` and nothing has to be resolved at query time. `context` +/// is the line the link sits on, kept here so the backlinks list does not have to go back to the +/// document text for a single line. +const V1: &str = " +CREATE TABLE IF NOT EXISTS docs ( + path TEXT PRIMARY KEY, + root_id TEXT NOT NULL, + rel_path TEXT NOT NULL, + name TEXT NOT NULL, + title TEXT NOT NULL, + mtime_ms INTEGER NOT NULL, + scanned_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS docs_by_root ON docs (root_id); + +CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5( + body, + tokenize = 'unicode61 remove_diacritics 2' +); + +CREATE TABLE IF NOT EXISTS links ( + src_path TEXT NOT NULL, + href TEXT NOT NULL, + target_path TEXT NOT NULL, + context TEXT NOT NULL, + PRIMARY KEY (src_path, href) +); + +CREATE INDEX IF NOT EXISTS links_by_target ON links (target_path); + +CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); +"; + +/// The open database, the last thing it said about itself, and the worker that writes to it. +/// +/// Managed by lib.rs and built by `Default` before there is an app to open anything with, so the +/// connection starts empty and `open` fills it. A connection that is still `None` after setup means +/// the index could not be opened at all, and every command below reports that rather than failing +/// in a way that reads as "search is broken". +#[derive(Default)] +pub struct Index { + conn: Mutex>, + status: Mutex, + /// Set while a full rebuild is queued or running, so pressing rebuild twice does not walk every + /// folder twice. + rebuilding: AtomicBool, + worker: OnceLock>, +} + +/// Work for the indexer thread. Ordered, because a create arriving after a delete for the same path +/// has to be applied in that order. +enum Job { + /// Every open root as they were when the user asked for the rebuild. + Rebuild(Vec), + /// One newly opened root. + Scan(RootInfo), + /// A closed root's rows, by root id. + Forget(String), + /// A path that appeared or changed. A directory is scanned and swept, so a folder dragged in + /// arrives whole and a folder whose contents changed loses the rows for what is no longer in it. + Changed(PathBuf), + /// A path that is gone, with everything under it if it was a folder. + Removed(PathBuf), +} + +// ---------------------------------------------------------------- opening + +/// Opens the database and runs the migration, then starts the worker that owns every write to it. +/// +/// Called from lib.rs's `setup`, which fetches nothing itself: the managed state is picked up here +/// so the call site is one line and cannot get the type wrong. +pub fn open(app: &AppHandle) -> Result<(), String> { + let index = app + .try_state::() + .ok_or_else(|| "the search index state is not managed".to_string())?; + + let file = crate::library::app_data_dir(app)?.join(DATABASE_FILE); + let conn = match connect(&file) { + Ok(conn) => conn, + Err(e) => { + // This is where a database from a newer build lands, and the only way the user ever + // hears about it: the connection is left closed, so `index_status` answers with this + // error and the search box says so instead of quietly returning nothing. + if let Ok(mut status) = index.status.lock() { + status.phase = "error".to_string(); + status.error = Some(e.clone()); + } + return Err(e); + } + }; + + let last = last_indexed(&conn); + *index.conn.lock().map_err(|e| e.to_string())? = Some(conn); + if let Ok(mut status) = index.status.lock() { + status.last_indexed = last; + } + + let (tx, rx) = mpsc::channel::(); + let handle = app.clone(); + std::thread::spawn(move || work(handle, rx)); + index.worker.set(tx).ok(); + Ok(()) +} + +fn connect(file: &Path) -> Result { + let conn = Connection::open(file).map_err(|e| format!("{}: {e}", file.display()))?; + // WAL so a search reads while the indexer writes, and NORMAL because every byte in here is + // derived from a file on disk: the worst a power cut can cost is a rescan. + conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;") + .map_err(|e| format!("{}: {e}", file.display()))?; + migrate(&conn)?; + Ok(conn) +} + +/// Forward only, keyed on `user_version`, and a refusal above it. +/// +/// A database written by a later build has a shape this one does not know. Treating it as the +/// current schema would mean reading columns that have moved and writing rows the later build +/// cannot read back, so it is refused instead, and refusing costs nothing: the caller reports it, +/// the index stays closed, and the later build rebuilds it from the files whenever it next runs. +fn migrate(conn: &Connection) -> Result<(), String> { + let found: i32 = conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .map_err(|e| e.to_string())?; + if found > VERSION { + return Err(format!( + "this search index is at schema {found}, which is newer than this build understands ({VERSION}). Quit, delete {DATABASE_FILE} from the app data folder and it will be built again." + )); + } + if found == VERSION { + return Ok(()); + } + let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?; + if found < 1 { + conn.execute_batch(V1).map_err(|e| e.to_string())?; + } + conn.execute_batch(&format!("PRAGMA user_version = {VERSION};")) + .map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string()) +} + +fn last_indexed(conn: &Connection) -> Option { + conn.query_row( + "SELECT value FROM meta WHERE key = ?1", + params![LAST_INDEXED_KEY], + |row| row.get::<_, String>(0), + ) + .optional() + .ok() + .flatten() + .and_then(|raw| raw.parse::().ok()) +} + +// ---------------------------------------------------------------- asking for work + +/// Queues a full rebuild and returns the status the pass starts with. +/// +/// The status is moved to `indexing` here rather than on the worker, so the value handed back to +/// the frontend already says a pass is running: `useIndex` applies the return value on top of its +/// own optimistic `indexing`, and answering with the previous idle status would put the status line +/// back to "ready" until the first progress event arrived. +pub fn rebuild(app: &AppHandle, roots: Vec) -> Result { + let index = state(app)?; + if index.rebuilding.swap(true, Memory::SeqCst) { + // A pass is already queued or running. Walking every folder a second time would only slow + // down the first one. + return status_of(&index); + } + let mut status = status_of(&index)?; + status.phase = "indexing".to_string(); + status.indexed = 0; + status.total = 0; + status.error = None; + publish(app, &index, status.clone()); + + if index + .worker + .get() + .map(|tx| tx.send(Job::Rebuild(roots)).is_err()) + .unwrap_or(true) + { + index.rebuilding.store(false, Memory::SeqCst); + return Err("the search index is not open".to_string()); + } + Ok(status) +} + +pub fn status(app: &AppHandle) -> Result { + let index = state(app)?; + status_of(&index) +} + +/// A folder the user has just opened, indexed without waiting for the next full rebuild. +pub fn scan_root(app: &AppHandle, root: RootInfo) { + send(app, Job::Scan(root)); +} + +/// A folder the user has closed. Its rows go with it: they cannot be searched into any more, and +/// leaving them would put paths from a folder that is not open in front of the user. +pub fn forget_root(app: &AppHandle, root_id: &str) { + send(app, Job::Forget(root_id.to_string())); +} + +/// A document this app wrote itself. +/// +/// The watcher never reports these. `watch::note_self_write` filters the app's own writes out of +/// the stream so autosave does not fight the watcher, which is right for the tree and wrong for the +/// index: without this call the only version of a document the index would ever hold is the one +/// from before the user started typing in it. +pub fn note_write(app: &AppHandle, path: &Path) { + send(app, Job::Changed(path.to_path_buf())); +} + +/// One debounced batch from the watcher, which is every change the index hears about that this app +/// did not make itself. +pub fn note_watch_events(app: &AppHandle, events: &[WatchEvent]) { + for event in events { + match event.kind.as_str() { + "removed" => send(app, Job::Removed(PathBuf::from(&event.path))), + "renamed" => { + if let Some(old) = &event.old_path { + send(app, Job::Removed(PathBuf::from(old))); + } + send(app, Job::Changed(PathBuf::from(&event.path))); + } + // created, modified, and the rescan the watcher reports as a modification of the root + // itself, which lands here as a directory and is scanned and swept like any other. + _ => send(app, Job::Changed(PathBuf::from(&event.path))), + } + } +} + +fn send(app: &AppHandle, job: Job) { + let Some(index) = app.try_state::() else { + return; + }; + // No worker means the database never opened. Dropping the job is the whole of the degradation: + // the app is an editor with an empty search box until it is restarted. + if let Some(tx) = index.worker.get() { + tx.send(job).ok(); + } +} + +fn state(app: &AppHandle) -> Result, String> { + app.try_state::() + .ok_or_else(|| "the search index state is not managed".to_string()) +} + +fn status_of(index: &Index) -> Result { + index + .status + .lock() + .map(|status| status.clone()) + .map_err(|e| e.to_string()) +} + +fn publish(app: &AppHandle, index: &Index, status: IndexStatus) { + if let Ok(mut held) = index.status.lock() { + *held = status.clone(); + } + app.emit(INDEX_PROGRESS, &status).ok(); +} + +// ---------------------------------------------------------------- the worker + +fn work(app: AppHandle, jobs: mpsc::Receiver) { + // Strictly increasing within this process and still a wall clock, so a pass started after a + // relaunch is above every stamp the last run left behind and its sweep can see them. + let mut pass = 0i64; + let mut next = || { + let now = now_ms(); + pass = if now > pass { now } else { pass + 1 }; + pass + }; + + for job in jobs { + let Ok(index) = state(&app) else { continue }; + let outcome = match job { + Job::Rebuild(roots) => { + let done = rebuild_pass(&app, &index, &roots, next()); + index.rebuilding.store(false, Memory::SeqCst); + done + } + Job::Scan(root) => scan_pass(&app, &index, std::slice::from_ref(&root), next()), + Job::Forget(root_id) => with_conn(&index, |conn| forget_root_rows(conn, &root_id)), + Job::Changed(path) => changed(&app, &index, &path, next()), + Job::Removed(path) => with_conn(&index, |conn| remove_under(conn, &path)), + }; + if let Err(e) = outcome { + eprintln!("search index: {e}"); + } + } +} + +/// A rebuild is a scan of every open root plus the one thing a scan cannot do: drop the rows of a +/// root that is no longer open at all. A root that was closed is already forgotten as it closes, +/// but a crash between the two leaves rows nothing else would ever collect. +fn rebuild_pass( + app: &AppHandle, + index: &Index, + roots: &[RootInfo], + pass: i64, +) -> Result<(), String> { + let ids: Vec = roots.iter().map(|root| root.id.clone()).collect(); + with_conn(index, |conn| forget_roots_except(conn, &ids))?; + scan_pass(app, index, roots, pass) +} + +/// One pass over a set of roots: walk them all first so the total is known before the first file is +/// read, then index in batches, then delete whatever the pass did not touch. +fn scan_pass( + app: &AppHandle, + index: &Index, + roots: &[RootInfo], + pass: i64, +) -> Result<(), String> { + let mut status = status_of(index)?; + status.phase = "indexing".to_string(); + status.indexed = 0; + status.total = 0; + status.error = None; + publish(app, index, status.clone()); + + let plan: Vec<(&RootInfo, Vec)> = roots + .iter() + .map(|root| (root, documents_under(Path::new(&root.path)))) + .collect(); + + status.total = plan.iter().map(|(_, files)| files.len() as u32).sum(); + publish(app, index, status.clone()); + + for (root, files) in &plan { + for chunk in files.chunks(BATCH) { + index_chunk(index, root, chunk, pass)?; + status.indexed += chunk.len() as u32; + if status.indexed % PROGRESS_EVERY == 0 { + publish(app, index, status.clone()); + } + } + with_conn(index, |conn| sweep_root(conn, &root.id, pass))?; + } + + let finished = now_ms(); + with_conn(index, |conn| remember(conn, LAST_INDEXED_KEY, finished))?; + status.phase = "idle".to_string(); + status.last_indexed = Some(finished); + publish(app, index, status); + Ok(()) +} + +/// A path the watcher, or this app's own save, says has changed. +/// +/// A directory is walked and then swept, which is what makes a folder dragged in arrive whole, a +/// folder whose contents were rearranged come out right, and the watcher's own "the kernel dropped +/// events, here is the root" report rescan everything under it. +fn changed(app: &AppHandle, index: &Index, path: &Path, pass: i64) -> Result<(), String> { + let roots = open_roots(app); + let Some(root) = owning_root(&roots, path) else { + // A change under a folder that is not open. Nothing to index it into. + return Ok(()); + }; + let Ok(meta) = fs::symlink_metadata(path) else { + // Gone again between the event and here, which a debounce window makes perfectly ordinary. + return with_conn(index, |conn| remove_under(conn, path)); + }; + if !meta.is_dir() { + if !is_document(path) { + return Ok(()); + } + return with_conn(index, |conn| index_document(conn, &root, path, pass)); + } + + // Walked before the connection is taken, since a folder dropped into a root can be large and a + // search should not wait behind a directory walk. + let files = documents_under(path); + for chunk in files.chunks(BATCH) { + index_chunk(index, &root, chunk, pass)?; + } + // Swept after the scan has committed rather than inside it: SQLite has one transaction per + // connection, and the sweep opens its own. + with_conn(index, |conn| sweep_under(conn, path, pass)) +} + +/// One batch of documents, in one transaction, holding the connection for exactly that long. A pass +/// over a large folder is otherwise either one transaction that blocks every search until it ends +/// or one transaction per file, which is a commit per file. +fn index_chunk( + index: &Index, + root: &RootInfo, + chunk: &[PathBuf], + pass: i64, +) -> Result<(), String> { + let held = lock(index)?; + let conn = connection(&held)?; + let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?; + for file in chunk { + if let Err(e) = index_document(conn, root, file, pass) { + // One unreadable document is one missing row, never a failed pass. A folder that size + // will have something in it nobody can stat. + eprintln!("search index: {e}"); + } + } + tx.commit().map_err(|e| e.to_string()) +} + +fn with_conn(index: &Index, f: impl FnOnce(&Connection) -> Result) -> Result { + let held = lock(index)?; + f(connection(&held)?) +} + +fn lock(index: &Index) -> Result>, String> { + index.conn.lock().map_err(|e| e.to_string()) +} + +fn connection<'a>(held: &'a MutexGuard<'_, Option>) -> Result<&'a Connection, String> { + held.as_ref() + .ok_or_else(|| "the search index is not open".to_string()) +} + +fn open_roots(app: &AppHandle) -> Vec { + let Some(roots) = app.try_state::() else { + return Vec::new(); + }; + let Ok(open) = roots.0.lock() else { + return Vec::new(); + }; + open.clone() +} + +/// The open root a path belongs to, the deepest one when roots are nested inside each other. +/// Component wise, so `/notes-old` is not read as being inside `/notes`. +fn owning_root(roots: &[RootInfo], path: &Path) -> Option { + roots + .iter() + .filter(|root| path.starts_with(&root.path)) + .max_by_key(|root| root.path.len()) + .cloned() +} + +// ---------------------------------------------------------------- walking and reading + +/// Every document under `dir`, walked by the same rules `fs::scan_tree` walks the sidebar by, so +/// the index and the tree agree about what a folder holds. A file the tree hides is a file nobody +/// can open from a search result either. +fn documents_under(dir: &Path) -> Vec { + let mut builder = WalkBuilder::new(dir); + builder + .follow_links(false) + .require_git(false) + .standard_filters(true); + builder.filter_entry(|entry| { + if entry.depth() == 0 { + return true; + } + if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + return true; + } + !crate::fs::ALWAYS_SKIPPED.contains(&entry.file_name().to_string_lossy().as_ref()) + }); + + let mut out = Vec::new(); + for entry in builder.build() { + let Ok(entry) = entry else { continue }; + if entry.file_type().map(|t| t.is_dir()).unwrap_or(true) { + continue; + } + let path = entry.path(); + if is_document(path) { + out.push(path.to_path_buf()); + } + } + out +} + +/// Markdown and plain text, decided exactly as the tree decides whether a row opens in the editor. +fn is_document(path: &Path) -> bool { + matches!(crate::fs::kind_for(path, false), "markdown" | "text") +} + +/// One document into the three tables, or one stat if the file has not moved since the last pass. +/// +/// The mtime shortcut is what makes a rescan of an unchanged folder cost a walk rather than a read +/// of every file in it. It is also the reason a pass leaves no trace on disk: a file that has not +/// changed is never opened at all. +fn index_document( + conn: &Connection, + root: &RootInfo, + path: &Path, + pass: i64, +) -> Result<(), String> { + let key = path.to_string_lossy().into_owned(); + let meta = fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; + let mtime = crate::fs::modified_ms(&meta); + + let known: Option = conn + .query_row("SELECT mtime_ms FROM docs WHERE path = ?1", params![key], |row| { + row.get(0) + }) + .optional() + .map_err(|e| e.to_string())?; + if known == Some(mtime) { + conn.execute( + "UPDATE docs SET scanned_at = ?2, root_id = ?3 WHERE path = ?1", + params![key, pass, root.id], + ) + .map_err(|e| e.to_string())?; + return Ok(()); + } + + // A file that is not UTF-8 is indexed with no text rather than skipped. Its path is still worth + // finding in quick open, and refusing the whole row would make it invisible instead. + let body = fs::read_to_string(path).unwrap_or_default(); + let title = title_for(path, &body); + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| key.clone()); + let rel = relative_to(&root.path, path); + + let rowid: i64 = conn + .query_row( + "INSERT INTO docs (path, root_id, rel_path, name, title, mtime_ms, scanned_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(path) DO UPDATE SET + root_id = excluded.root_id, + rel_path = excluded.rel_path, + name = excluded.name, + title = excluded.title, + mtime_ms = excluded.mtime_ms, + scanned_at = excluded.scanned_at + RETURNING rowid", + params![key, root.id, rel, name, title, mtime, pass], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + + conn.execute("DELETE FROM docs_fts WHERE rowid = ?1", params![rowid]) + .map_err(|e| e.to_string())?; + conn.execute( + "INSERT INTO docs_fts (rowid, body) VALUES (?1, ?2)", + params![rowid, body], + ) + .map_err(|e| e.to_string())?; + + conn.execute("DELETE FROM links WHERE src_path = ?1", params![key]) + .map_err(|e| e.to_string())?; + for link in links_in(&key, &body) { + conn.execute( + "INSERT OR IGNORE INTO links (src_path, href, target_path, context) + VALUES (?1, ?2, ?3, ?4)", + params![key, link.href, link.target, link.context], + ) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// The path as the row shows it: relative to its root, with the root's own separator convention. +fn relative_to(root: &str, path: &Path) -> String { + let full = path.to_string_lossy().into_owned(); + match full.strip_prefix(root) { + Some(rest) => rest.trim_start_matches('/').to_string(), + None => full, + } +} + +// ---------------------------------------------------------------- deleting + +/// Rows for a path and, if it was a folder, everything that was under it. +/// +/// The subtree is a range over the primary key rather than a LIKE, because a folder called +/// `50% done` would turn a LIKE pattern into a wildcard that matches far more than itself. `/` is +/// 0x2f and `0` is 0x30, so everything beginning with the prefix and a separator sorts between them. +fn remove_under(conn: &Connection, path: &Path) -> Result<(), String> { + let key = path.to_string_lossy().into_owned(); + let from = format!("{key}/"); + let to = format!("{key}0"); + let doomed = doomed_paths( + conn, + "SELECT rowid, path FROM docs WHERE path = ?1 OR (path > ?2 AND path < ?3)", + vec![Value::from(key), Value::from(from), Value::from(to)], + )?; + delete_docs(conn, &doomed) +} + +fn forget_root_rows(conn: &Connection, root_id: &str) -> Result<(), String> { + let doomed = doomed_paths( + conn, + "SELECT rowid, path FROM docs WHERE root_id = ?1", + vec![Value::from(root_id.to_string())], + )?; + delete_docs(conn, &doomed) +} + +fn forget_roots_except(conn: &Connection, keep: &[String]) -> Result<(), String> { + let holes = placeholders(keep.len().max(1)); + let args: Vec = if keep.is_empty() { + // No folder open at all, so no row belongs to anything. The impossible id keeps the SQL one + // shape rather than two. + vec![Value::from(String::new())] + } else { + keep.iter().map(|id| Value::from(id.clone())).collect() + }; + let doomed = doomed_paths( + conn, + &format!("SELECT rowid, path FROM docs WHERE root_id NOT IN ({holes})"), + args, + )?; + delete_docs(conn, &doomed) +} + +/// Whatever this pass did not touch under `dir` is no longer there. +fn sweep_under(conn: &Connection, dir: &Path, pass: i64) -> Result<(), String> { + let key = dir.to_string_lossy().into_owned(); + let from = format!("{key}/"); + let to = format!("{key}0"); + let doomed = doomed_paths( + conn, + "SELECT rowid, path FROM docs WHERE scanned_at < ?1 AND (path > ?2 AND path < ?3)", + vec![Value::from(pass), Value::from(from), Value::from(to)], + )?; + delete_docs(conn, &doomed) +} + +fn sweep_root(conn: &Connection, root_id: &str, pass: i64) -> Result<(), String> { + let doomed = doomed_paths( + conn, + "SELECT rowid, path FROM docs WHERE root_id = ?1 AND scanned_at < ?2", + vec![Value::from(root_id.to_string()), Value::from(pass)], + )?; + delete_docs(conn, &doomed) +} + +fn doomed_paths( + conn: &Connection, + sql: &str, + args: Vec, +) -> Result, String> { + let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params_from_iter(args), |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| e.to_string())?; + let mut out = Vec::new(); + for row in rows { + out.push(row.map_err(|e| e.to_string())?); + } + Ok(out) +} + +/// The text and the links go first: a `docs` row is what says they exist, so losing it first would +/// leave both behind with nothing pointing at them. +/// +/// Links out of a deleted document go. Links into one stay exactly where they are, in the documents +/// that hold them: a link to a file somebody has just deleted is still a link, and it works again +/// the moment the file comes back. +fn delete_docs(conn: &Connection, doomed: &[(i64, String)]) -> Result<(), String> { + if doomed.is_empty() { + return Ok(()); + } + let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?; + for (rowid, path) in doomed { + conn.execute("DELETE FROM docs_fts WHERE rowid = ?1", params![rowid]) + .map_err(|e| e.to_string())?; + conn.execute("DELETE FROM links WHERE src_path = ?1", params![path]) + .map_err(|e| e.to_string())?; + conn.execute("DELETE FROM docs WHERE path = ?1", params![path]) + .map_err(|e| e.to_string())?; + } + tx.commit().map_err(|e| e.to_string()) +} + +fn remember(conn: &Connection, key: &str, value: i64) -> Result<(), String> { + conn.execute( + "INSERT INTO meta (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value.to_string()], + ) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +fn placeholders(n: usize) -> String { + std::iter::repeat_n("?", n).collect::>().join(", ") +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +// ---------------------------------------------------------------- quick open + +/// Fuzzy match over every indexed path, best score first. +pub fn quick_open(app: &AppHandle, query: &str, limit: u32) -> Result, String> { + let index = state(app)?; + let ids: Vec = open_roots(app).into_iter().map(|root| root.id).collect(); + // Whitespace is dropped rather than treated as a separator: a quick open query is one + // subsequence, and a space in the middle of it is somebody typing "getting started" at a path + // that spells it `getting-started`. + let needle: Vec = query + .chars() + .filter(|c| !c.is_whitespace()) + .flat_map(|c| c.to_lowercase()) + .collect(); + if needle.is_empty() || ids.is_empty() || limit == 0 { + return Ok(Vec::new()); + } + + // Scoped so the connection is handed back before the sort: the ordering is this side's work and + // there is no reason for a rebuild to wait behind it. + let mut hits: Vec = { + let held = lock(&index)?; + let conn = connection(&held)?; + let mut stmt = conn + .prepare("SELECT path, root_id, rel_path, name FROM docs") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }) + .map_err(|e| e.to_string())?; + + let mut hits: Vec = Vec::new(); + let mut haystack: Vec = Vec::new(); + for row in rows { + let (path, root, rel, name) = row.map_err(|e| e.to_string())?; + if !ids.iter().any(|id| id == &root) { + continue; + } + haystack.clear(); + haystack.extend(rel.chars()); + let Some(found) = fuzzy(&needle, &haystack) else { + continue; + }; + hits.push(QuickOpenHit { + path, + name, + root, + score: found.score, + ranges: to_utf16(&rel, found.ranges), + rel_path: rel, + }); + } + hits + }; + + // Ties go to the shorter path, then to the alphabet, so an unchanged folder lists in the same + // order every time it is searched. + hits.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| a.rel_path.chars().count().cmp(&b.rel_path.chars().count())) + .then_with(|| a.rel_path.cmp(&b.rel_path)) + }); + hits.truncate(limit as usize); + Ok(hits) +} + +struct Found { + score: i32, + ranges: Vec, +} + +const SCORE_MATCH: i32 = 16; +const SCORE_CONSECUTIVE: i32 = 20; +/// The first character, or the one after a separator: what somebody typing a path is aiming at. +const BONUS_SEGMENT: i32 = 20; +const BONUS_WORD: i32 = 14; +const BONUS_CAMEL: i32 = 12; +const BONUS_DOT: i32 = 10; +/// Every character of the last segment, so a match in the filename beats the same match in a folder +/// name above it. +const BONUS_FILENAME: i32 = 6; +const GAP_LEADING: i32 = -2; +const GAP_INNER: i32 = -4; +/// Half of the floor, so adding a bonus to it can never wrap round into a good score. +const SCORE_MIN: i32 = i32::MIN / 2; + +/// The scoring is fzy's: one table of the best score ending in a match at each position, one of the +/// best score reachable by that position at all, filled left to right and then walked backwards to +/// recover which characters were used. It is worth the table rather than a greedy pass because +/// greedy takes the first `e` in `keyboard-reference.md` and never finds the run that spells +/// `refe`, which is exactly the query somebody types. +fn fuzzy(needle: &[char], haystack: &[char]) -> Option { + if needle.is_empty() || haystack.is_empty() || needle.len() > haystack.len() { + return None; + } + // Most paths are not a match at all, and a subsequence check answers that in one pass instead + // of filling a table to find out. + let mut at = 0; + for c in haystack { + if lower(*c) == needle[at] { + at += 1; + if at == needle.len() { + break; + } + } + } + if at < needle.len() { + return None; + } + + let n = needle.len(); + let m = haystack.len(); + let bonuses = bonuses_for(haystack); + let mut best = vec![SCORE_MIN; n * m]; + let mut reach = vec![SCORE_MIN; n * m]; + + for i in 0..n { + let mut prev_reach = SCORE_MIN; + // Skipping characters before the first match is cheaper than skipping them in the middle of + // one: a query is usually the tail of a path, and rarely a hole punched through a word. + let gap = if i == 0 { GAP_LEADING } else { GAP_INNER }; + for j in 0..m { + let cell = i * m + j; + if needle[i] == lower(haystack[j]) { + let score = if i == 0 { + GAP_LEADING * j as i32 + bonuses[j] + SCORE_MATCH + } else if j == 0 { + SCORE_MIN + } else { + let after_gap = reach[(i - 1) * m + j - 1] + bonuses[j]; + let after_run = best[(i - 1) * m + j - 1] + SCORE_CONSECUTIVE; + after_gap.max(after_run) + SCORE_MATCH + }; + best[cell] = score; + reach[cell] = score.max(prev_reach + gap); + } else { + best[cell] = SCORE_MIN; + reach[cell] = prev_reach + gap; + } + prev_reach = reach[cell]; + } + } + + let score = reach[(n - 1) * m + m - 1]; + if score <= SCORE_MIN { + return None; + } + + let mut positions = vec![0usize; n]; + let mut j = m; + let mut required = false; + for i in (0..n).rev() { + while j > 0 { + j -= 1; + let cell = i * m + j; + if best[cell] != SCORE_MIN && (required || best[cell] == reach[cell]) { + required = i > 0 + && j > 0 + && best[cell] == best[(i - 1) * m + j - 1] + SCORE_CONSECUTIVE + SCORE_MATCH; + positions[i] = j; + break; + } + } + } + + Some(Found { + score, + ranges: runs(&positions), + }) +} + +fn lower(c: char) -> char { + c.to_lowercase().next().unwrap_or(c) +} + +fn bonuses_for(haystack: &[char]) -> Vec { + let filename_from = haystack + .iter() + .rposition(|c| *c == '/') + .map(|at| at + 1) + .unwrap_or(0); + haystack + .iter() + .enumerate() + .map(|(j, c)| { + let boundary = if j == 0 { + BONUS_SEGMENT + } else { + match haystack[j - 1] { + '/' => BONUS_SEGMENT, + '-' | '_' | ' ' => BONUS_WORD, + '.' => BONUS_DOT, + prev if prev.is_lowercase() && c.is_uppercase() => BONUS_CAMEL, + _ => 0, + } + }; + boundary + if j >= filename_from { BONUS_FILENAME } else { 0 } + }) + .collect() +} + +/// Retimes character ranges into the units the other end counts in. +/// +/// Everything above works in code points, which is what `chars()` gives and what makes the fuzzy +/// matcher and the snippet window readable. JavaScript strings are UTF-16, and `slice` on one +/// counts UTF-16 code units, so a range measured in code points lands one position early for every +/// character past the BMP that precedes it: an emoji in a filename, and the highlight in the quick +/// open row is off by one from there on. +/// +/// This is the last thing done to a range before it crosses the boundary, and it is the reason the +/// doc comments on `MatchRange` say what unit they are in. Note that `SpellIssue` deliberately does +/// not go through here: its offsets address a ProseMirror document, and ProseMirror counts code +/// points, so the two DTOs want genuinely different units and neither is wrong. +fn to_utf16(text: &str, ranges: Vec) -> Vec { + if text.is_ascii() { + return ranges; + } + // One pass over the string building code point index to UTF-16 index, since the ranges are few + // and the string is short but walking it once per boundary would still be quadratic. + let mut widths: Vec = Vec::with_capacity(text.chars().count() + 1); + let mut at: u32 = 0; + widths.push(0); + for c in text.chars() { + at += c.len_utf16() as u32; + widths.push(at); + } + let last = *widths.last().unwrap_or(&0); + ranges + .into_iter() + .map(|range| MatchRange { + start: *widths.get(range.start as usize).unwrap_or(&last), + end: *widths.get(range.end as usize).unwrap_or(&last), + }) + .collect() +} + +/// Matched positions, which are always ascending, folded into half-open ranges so a run of five +/// characters is one highlight rather than five. +fn runs(positions: &[usize]) -> Vec { + let mut out: Vec = Vec::new(); + for at in positions { + let at = *at as u32; + match out.last_mut() { + Some(last) if last.end == at => last.end = at + 1, + _ => out.push(MatchRange { + start: at, + end: at + 1, + }), + } + } + out +} + +// ---------------------------------------------------------------- full text + +/// FTS5 over every open root, with the line and the characters of each match. +pub fn search(app: &AppHandle, query: &str, limit: u32) -> Result, String> { + let index = state(app)?; + let ids: Vec = open_roots(app).into_iter().map(|root| root.id).collect(); + let expression = match_expression(query); + if expression.is_empty() || ids.is_empty() || limit == 0 { + return Ok(Vec::new()); + } + + let sql = format!( + "SELECT d.path, d.root_id, d.title, highlight(docs_fts, 0, char(2), char(3)) + FROM docs_fts JOIN docs d ON d.rowid = docs_fts.rowid + WHERE docs_fts MATCH ? AND d.root_id IN ({}) + ORDER BY bm25(docs_fts) + LIMIT ?", + placeholders(ids.len()) + ); + let mut args: Vec = vec![Value::from(expression)]; + args.extend(ids.into_iter().map(Value::from)); + args.push(Value::from(limit as i64)); + + let held = lock(&index)?; + let conn = connection(&held)?; + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params_from_iter(args), |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }) + .map_err(|e| e.to_string())?; + + let mut hits: Vec = Vec::new(); + for row in rows { + let (path, root, title, marked) = row.map_err(|e| e.to_string())?; + for (line, snippet, ranges) in marked_lines(&marked, HITS_PER_DOC) { + hits.push(SearchHit { + path: path.clone(), + root: root.clone(), + title: title.clone(), + line, + ranges: to_utf16(&snippet, ranges), + snippet, + }); + if hits.len() >= limit as usize { + return Ok(hits); + } + } + } + Ok(hits) +} + +/// The query as FTS5 will read it: every run of letters and digits, quoted. +/// +/// The text box hands over whatever somebody typed, and an unpaired quote or a bare `NOT` is a +/// syntax error the user would read as the search being broken rather than as their query being +/// unusual. Splitting on everything that is not alphanumeric means no character the user typed can +/// reach the parser as syntax: what is left inside the quotes cannot contain a quote to close them +/// with, and `AND` inside quotes is the word and not the operator. +/// +/// The last term is a prefix. A find in files runs while somebody is still typing, and a query that +/// only ever matches whole words answers nothing until the last letter of the last word lands. +fn match_expression(query: &str) -> String { + let terms: Vec<&str> = query + .split(|c: char| !c.is_alphanumeric()) + .filter(|term| !term.is_empty()) + .take(MAX_TERMS) + .collect(); + let last = terms.len().saturating_sub(1); + terms + .iter() + .enumerate() + .map(|(at, term)| { + if at == last { + format!("\"{term}\"*") + } else { + format!("\"{term}\"") + } + }) + .collect::>() + .join(" ") +} + +/// The lines a match landed on, at most `most` of them. +/// +/// `marked` is the document with `highlight()`'s marks around every match, so the line number is a +/// count of the newlines before the first mark and the ranges are the marks' own positions with the +/// marks taken back out. The line is one based and counted over the file as it sits on disk, +/// frontmatter included, because that is the line the user sees in any other editor. +fn marked_lines(marked: &str, most: usize) -> Vec<(u32, String, Vec)> { + let mut out = Vec::new(); + for (line, raw) in (1u32..).zip(marked.split('\n')) { + if raw.contains(MARK_START) { + let (snippet, ranges) = snippet_of(raw); + out.push((line, snippet, ranges)); + if out.len() >= most { + break; + } + } + } + if out.is_empty() { + // A hit with nothing marked should not happen, since a document only matches by holding a + // term. Answering with the first line is still better than dropping a real hit. + let first = marked.split('\n').next().unwrap_or_default(); + out.push((1, clip(first, SNIPPET_MAX), Vec::new())); + } + out +} + +/// One marked line as the frontend shows it: trimmed, windowed around the first match, and with +/// character ranges into what is left. +fn snippet_of(line: &str) -> (String, Vec) { + let mut text: Vec = Vec::new(); + let mut ranges: Vec<(usize, usize)> = Vec::new(); + let mut open: Option = None; + for c in line.chars() { + match c { + MARK_START => open = Some(text.len()), + MARK_END => { + if let Some(start) = open.take() { + ranges.push((start, text.len())); + } + } + '\r' => {} + _ => text.push(c), + } + } + + let lead = text.iter().take_while(|c| c.is_whitespace()).count(); + let tail = text.len() - text.iter().rev().take_while(|c| c.is_whitespace()).count(); + let first = ranges.first().map(|(start, _)| *start).unwrap_or(lead); + // Enough of the line to see where the match is, starting a little before it when the line is + // too long to show whole. + let mut from = lead.max(first.saturating_sub(SNIPPET_LEAD)); + if tail.saturating_sub(from) < SNIPPET_MAX { + from = tail.saturating_sub(SNIPPET_MAX).max(lead); + } + let to = tail.min(from + SNIPPET_MAX); + + let mut out = String::new(); + let mut shift = from; + if from > lead { + out.push('…'); + shift -= 1; + } + out.extend(text[from..to].iter()); + if to < tail { + out.push('…'); + } + + let width = out.chars().count() as u32; + let ranges = ranges + .into_iter() + .filter(|(start, end)| *end > from && *start < to) + .map(|(start, end)| MatchRange { + start: (start.max(from) - shift) as u32, + end: ((end.min(to) - shift) as u32).min(width), + }) + .filter(|range| range.end > range.start) + .collect(); + (out, ranges) +} + +fn clip(text: &str, max: usize) -> String { + let trimmed = text.trim(); + if trimmed.chars().count() <= max { + return trimmed.to_string(); + } + let mut out: String = trimmed.chars().take(max).collect(); + out.push('…'); + out +} + +// ---------------------------------------------------------------- backlinks + +/// Every document holding a relative link that resolves to `path`. +/// +/// A reverse lookup and nothing else: the rows were written when the linking documents were +/// indexed, so a backlink appears because somebody wrote a link, never because anything was +/// recorded on this side. A document that links to itself is left out, since listing the document +/// somebody is reading among the documents that point at it is noise rather than a backlink. +pub fn backlinks(app: &AppHandle, path: &str) -> Result, String> { + let index = state(app)?; + let target = normalized(path); + let held = lock(&index)?; + let conn = connection(&held)?; + let mut stmt = conn + .prepare( + "SELECT d.path, d.title, l.context + FROM links l JOIN docs d ON d.path = l.src_path + WHERE l.target_path = ?1 AND l.src_path <> ?1 + ORDER BY d.title, d.path", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params![target], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .map_err(|e| e.to_string())?; + + let mut out: Vec = Vec::new(); + for row in rows { + let (path, title, context) = row.map_err(|e| e.to_string())?; + // Two links from one document to the same file are one backlink. + if out.iter().any(|found| found.path == path) { + continue; + } + out.push(Backlink { + path, + title, + snippet: context, + }); + } + Ok(out) +} + +// ---------------------------------------------------------------- reading a document + +/// The document's title: its first heading, or its filename without the extension. +/// +/// The heading and the filename are unrelated in this product. Nothing renames a file because a +/// heading changed and nothing rewrites a heading because a file moved, so the rule is only ever +/// this way round: a heading if there is one, the stem if there is not. +fn title_for(path: &Path, body: &str) -> String { + heading_in(body).unwrap_or_else(|| { + path.file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string_lossy().into_owned()) + }) +} + +fn heading_in(body: &str) -> Option { + let mut fence: Option = None; + let mut frontmatter = false; + let mut previous: Option<&str> = None; + + for (n, line) in body.lines().enumerate() { + // A leading `---` opens frontmatter, and everything to its close is metadata rather than + // text. Skipping it is not tidiness: `title: Notes` followed by the closing `---` is a + // setext heading to anything reading line by line, and the title of every document with + // frontmatter would be its second line of YAML. + if n == 0 && line.trim_end() == "---" { + frontmatter = true; + continue; + } + if frontmatter { + let trimmed = line.trim_end(); + if trimmed == "---" || trimmed == "..." { + frontmatter = false; + } + continue; + } + match (fence, fence_char(line)) { + (Some(open), Some(found)) if open == found => fence = None, + (Some(_), _) => {} + (None, Some(found)) => fence = Some(found), + (None, None) => { + if let Some(text) = atx_heading(line) { + return Some(text); + } + if let Some(text) = previous { + if is_setext_rule(line) { + return Some(text.trim().to_string()); + } + } + previous = if line.trim().is_empty() { + None + } else { + Some(line) + }; + continue; + } + } + previous = None; + } + None +} + +fn fence_char(line: &str) -> Option { + let trimmed = line.trim_start(); + if line.len() - trimmed.len() > 3 { + return None; + } + ['`', '~'] + .into_iter() + .find(|marker| trimmed.starts_with(&marker.to_string().repeat(3))) +} + +fn atx_heading(line: &str) -> Option { + let trimmed = line.trim_start(); + if line.len() - trimmed.len() > 3 { + return None; + } + let hashes = trimmed.chars().take_while(|c| *c == '#').count(); + if hashes == 0 || hashes > 6 { + return None; + } + let rest = &trimmed[hashes..]; + if !rest.is_empty() && !rest.starts_with(char::is_whitespace) { + return None; + } + let text = rest.trim().trim_end_matches('#').trim(); + // `#` on its own is a heading with nothing in it, which is no more a title than an empty line. + if text.is_empty() { + None + } else { + Some(text.to_string()) + } +} + +fn is_setext_rule(line: &str) -> bool { + let trimmed = line.trim_start(); + if line.len() - trimmed.len() > 3 { + return false; + } + let body = trimmed.trim_end(); + !body.is_empty() + && (body.chars().all(|c| c == '=') || body.chars().all(|c| c == '-')) +} + +struct Link { + href: String, + target: String, + context: String, +} + +/// Every relative markdown link in a document, with what it resolves to. +/// +/// Line by line, so the line a link sits on is the context the backlinks list shows and so a link +/// inside a fenced code block is not read as a link at all. A code sample showing what a link looks +/// like should not put a backlink in front of anybody. +fn links_in(from: &str, body: &str) -> Vec { + let mut out = Vec::new(); + let mut fence: Option = None; + for line in body.lines() { + match (fence, fence_char(line)) { + (Some(open), Some(found)) if open == found => { + fence = None; + continue; + } + (Some(_), _) => continue, + (None, Some(found)) => { + fence = Some(found); + continue; + } + (None, None) => {} + } + for href in hrefs_in(line) { + if let Some(target) = resolve_relative(from, &href) { + out.push(Link { + href, + target, + context: clip(line, SNIPPET_MAX), + }); + } + } + } + out +} + +/// The destinations of every link on one line: `](dest)`, `]()` and a reference definition's +/// `[label]: dest`. +fn hrefs_in(line: &str) -> Vec { + let chars: Vec = line.chars().collect(); + let mut out = Vec::new(); + + if let Some(dest) = reference_definition(&chars) { + out.push(dest); + } + + let mut i = 0; + while i + 1 < chars.len() { + if chars[i] != ']' || chars[i + 1] != '(' { + i += 1; + continue; + } + let mut j = i + 2; + while j < chars.len() && chars[j].is_whitespace() { + j += 1; + } + if chars.get(j) == Some(&'<') { + let mut dest = String::new(); + let mut k = j + 1; + while k < chars.len() && chars[k] != '>' { + dest.push(chars[k]); + k += 1; + } + if k < chars.len() && !dest.is_empty() { + out.push(dest); + } + i = k + 1; + continue; + } + let mut dest = String::new(); + let mut depth = 0i32; + let mut k = j; + while k < chars.len() { + let c = chars[k]; + if c == '\\' && k + 1 < chars.len() { + dest.push(chars[k + 1]); + k += 2; + continue; + } + if c.is_whitespace() { + break; + } + if c == '(' { + depth += 1; + } + if c == ')' { + if depth == 0 { + break; + } + depth -= 1; + } + dest.push(c); + k += 1; + } + if !dest.is_empty() { + out.push(dest); + } + i = k.max(i + 2); + } + out +} + +/// The destination of a link reference definition, if this line is one. +/// +/// The label alone is not enough to tell. A line of ordinary prose that happens to open with a +/// bracketed word and a colon looks identical to a definition for its first few characters, and +/// real documents contain them: `[snippet]: an outer loop that propagates the best result` is a +/// sentence, and reading `an` out of it as a destination puts a link in the index to a file that +/// was never mentioned. What separates the two is what follows the destination, so the rule +/// CommonMark states is the rule enforced here: a definition is a label, a destination, an +/// optional title, and then nothing else on the line. +/// +/// A title that continues onto the next line is a definition this scanner will not recognise. That +/// is the conservative direction to be wrong in: a link missed is a backlink that does not appear, +/// and a link invented is a backlink that is not true. +fn reference_definition(chars: &[char]) -> Option { + let start = chars.iter().take_while(|c| c.is_whitespace()).count(); + if start > 3 || chars.get(start) != Some(&'[') { + return None; + } + let close = (start + 1..chars.len()).find(|at| chars[*at] == ']')?; + if chars.get(close + 1) != Some(&':') { + return None; + } + + let after: Vec = chars[close + 2..] + .iter() + .copied() + .skip_while(|c| c.is_whitespace()) + .collect(); + let width = after.iter().take_while(|c| !c.is_whitespace()).count(); + let dest: String = after[..width].iter().collect(); + let dest = dest.trim_start_matches('<').trim_end_matches('>').to_string(); + if dest.is_empty() || !only_a_title(&after[width..]) { + return None; + } + Some(dest) +} + +/// Whether what follows a definition's destination is an optional title and nothing more. +/// +/// The three delimiter pairs are the ones CommonMark allows. Anything else, including a second +/// word, means the line was prose that merely started like a definition. +fn only_a_title(rest: &[char]) -> bool { + let rest: Vec = rest + .iter() + .copied() + .skip_while(|c| c.is_whitespace()) + .collect(); + let trimmed = rest + .iter() + .rposition(|c| !c.is_whitespace()) + .map(|end| &rest[..=end]) + .unwrap_or(&[]); + if trimmed.is_empty() { + return true; + } + let closer = match trimmed[0] { + '"' => '"', + '\'' => '\'', + '(' => ')', + _ => return false, + }; + trimmed.len() >= 2 && trimmed[trimmed.len() - 1] == closer +} + +/// `href` as it sits in a document, resolved against the document that holds it. +/// +/// This mirrors `resolveRelative` in src/links.ts line for line, and it has to: a backlink exists +/// when following the link would land on the document, so the two sides agreeing about what a link +/// means is the whole of the feature. A bare fragment, an empty href and anything carrying a scheme +/// are not paths. A percent escape is decoded, so `./my%20notes.md` and `./my notes.md` are one +/// file rather than two. +fn resolve_relative(from_file: &str, href: &str) -> Option { + if href.is_empty() || href.starts_with('#') || has_scheme(href) { + return None; + } + let head = href.split('#').next().unwrap_or(href); + let head = head.split('?').next().unwrap_or(head); + let target = percent_decode(head); + if target.is_empty() { + return None; + } + let joined = if target.starts_with('/') { + target + } else { + format!("{}/{}", dir_name(from_file), target) + }; + Some(normalized(&joined)) +} + +/// `.` and `..` resolved away, exactly as the frontend resolves them: lexically, without asking the +/// filesystem, so a link means the same thing whether or not the file it names is there. +fn normalized(path: &str) -> String { + let mut out: Vec<&str> = Vec::new(); + for part in path.split('/') { + match part { + "" | "." => {} + ".." => { + out.pop(); + } + part => out.push(part), + } + } + format!("/{}", out.join("/")) +} + +fn dir_name(path: &str) -> &str { + match path.rfind('/') { + Some(0) | None => "/", + Some(at) => &path[..at], + } +} + +fn has_scheme(href: &str) -> bool { + let mut chars = href.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() => {} + _ => return false, + } + for c in chars { + if c == ':' { + return true; + } + if !(c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-') { + return false; + } + } + false +} + +/// `decodeURIComponent`, including how it fails: an escape that is not two hex digits, or bytes +/// that are not UTF-8, throws on the frontend and is caught there, leaving the href exactly as it +/// was. Decoding half of it here would resolve to a path the other side never would. +fn percent_decode(text: &str) -> String { + if !text.contains('%') { + return text.to_string(); + } + let raw = text.as_bytes(); + let mut out: Vec = Vec::with_capacity(raw.len()); + let mut i = 0; + while i < raw.len() { + if raw[i] != b'%' { + out.push(raw[i]); + i += 1; + continue; + } + let Some(hex) = raw.get(i + 1..i + 3) else { + return text.to_string(); + }; + let Ok(hex) = std::str::from_utf8(hex) else { + return text.to_string(); + }; + let Ok(byte) = u8::from_str_radix(hex, 16) else { + return text.to_string(); + }; + out.push(byte); + i += 3; + } + String::from_utf8(out).unwrap_or_else(|_| text.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..27b2602 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,258 @@ +pub mod dto; +pub mod fs; +pub mod index; +mod library; +#[cfg(target_os = "macos")] +mod macspell; +pub mod spell; +pub mod watch; + +use std::sync::Mutex; + +use crate::dto::RootInfo; + +#[cfg(desktop)] +use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder}; +#[cfg(desktop)] +use tauri::{Emitter, Runtime}; + +/// The open folders, in the order they were opened. +/// +/// This lives here rather than in either module because both need it and neither owns the other: +/// `fs` puts roots in and takes them out, `watch` only ever turns an id back into a path. It is the +/// in-memory copy of the list; persisting it across a relaunch is `fs`'s business. +#[derive(Default)] +pub struct Roots(pub Mutex>); + +impl Roots { + /// The absolute path of an open root. Every command that takes a `rootId` needs this before it + /// can touch anything, and an id that is not open is an error rather than an empty result. + pub fn path_for(&self, id: &str) -> Result { + let roots = self.0.lock().map_err(|e| e.to_string())?; + roots + .iter() + .find(|root| root.id == id) + .map(|root| root.path.clone()) + .ok_or_else(|| format!("no such root: {id}")) + } +} + +#[cfg(desktop)] +fn build_menu(handle: &tauri::AppHandle) -> tauri::Result> { + let menu = Menu::default(handle)?; + + let open_folder = MenuItemBuilder::with_id("open-folder", "Open Folder…") + .accelerator("CmdOrCtrl+O") + .build(handle)?; + let new_doc = MenuItemBuilder::with_id("new-doc", "New Document") + .accelerator("CmdOrCtrl+N") + .build(handle)?; + let new_folder = MenuItemBuilder::with_id("new-folder", "New Folder").build(handle)?; + let quick_open = MenuItemBuilder::with_id("quick-open", "Quick Open…") + .accelerator("CmdOrCtrl+P") + .build(handle)?; + let command_palette = MenuItemBuilder::with_id("command-palette", "Command Palette…") + .accelerator("CmdOrCtrl+K") + .build(handle)?; + let save = MenuItemBuilder::with_id("save", "Save") + .accelerator("CmdOrCtrl+S") + .build(handle)?; + let close_folder = MenuItemBuilder::with_id("close-folder", "Close Folder").build(handle)?; + let check_updates = + MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?; + let settings = MenuItemBuilder::with_id("settings", "Settings…") + .accelerator("CmdOrCtrl+,") + .build(handle)?; + let find = MenuItemBuilder::with_id("find", "Find…") + .accelerator("CmdOrCtrl+F") + .build(handle)?; + let find_in_files = MenuItemBuilder::with_id("find-in-files", "Find in Files…") + .accelerator("CmdOrCtrl+Shift+F") + .build(handle)?; + let report_issue = + MenuItemBuilder::with_id("report-issue", "Report an Issue…").build(handle)?; + + let submenus: Vec<_> = menu + .items()? + .into_iter() + .filter_map(|item| match item { + MenuItemKind::Submenu(submenu) => Some(submenu), + _ => None, + }) + .collect(); + + let find_submenu = |name: &str| { + submenus + .iter() + .find(|submenu| submenu.text().map(|t| t == name).unwrap_or(false)) + .cloned() + }; + + match find_submenu("File") { + Some(submenu) => { + submenu.prepend_items(&[ + &open_folder, + &new_doc, + &new_folder, + &PredefinedMenuItem::separator(handle)?, + &quick_open, + &command_palette, + &PredefinedMenuItem::separator(handle)?, + &save, + &PredefinedMenuItem::separator(handle)?, + &close_folder, + &PredefinedMenuItem::separator(handle)?, + ])?; + } + None => { + let submenu = SubmenuBuilder::new(handle, "File") + .item(&open_folder) + .item(&new_doc) + .item(&new_folder) + .item(&PredefinedMenuItem::separator(handle)?) + .item(&quick_open) + .item(&command_palette) + .item(&PredefinedMenuItem::separator(handle)?) + .item(&save) + .item(&PredefinedMenuItem::separator(handle)?) + .item(&close_folder) + .build()?; + menu.insert(&submenu, 1)?; + } + } + + if let Some(edit) = find_submenu("Edit") { + edit.append_items(&[ + &PredefinedMenuItem::separator(handle)?, + &find, + &find_in_files, + ])?; + } + + if let Some(help) = find_submenu("Help") { + help.append_items(&[&report_issue])?; + } + + #[cfg(target_os = "macos")] + { + if let Some(app_submenu) = submenus.first() { + app_submenu.insert(&check_updates, 1)?; + app_submenu.insert(&settings, 3)?; + app_submenu.insert(&PredefinedMenuItem::separator(handle)?, 4)?; + } + if let Some(view) = find_submenu("View") { + let toggle_sidebar = MenuItemBuilder::with_id("toggle-sidebar", "Toggle Sidebar") + .accelerator("CmdOrCtrl+\\") + .build(handle)?; + view.prepend_items(&[&toggle_sidebar, &PredefinedMenuItem::separator(handle)?])?; + } + } + + #[cfg(not(target_os = "macos"))] + { + if let Some(file) = find_submenu("File") { + file.append_items(&[&PredefinedMenuItem::separator(handle)?, &check_updates])?; + } + if let Some(edit) = find_submenu("Edit") { + edit.append_items(&[&settings])?; + } + } + + Ok(menu) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + let context = tauri::generate_context!(); + + #[cfg_attr(mobile, allow(unused_mut))] + let mut builder = tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) + .manage(Roots::default()) + .manage(watch::Watchers::default()) + .manage(index::Index::default()); + + #[cfg(desktop)] + { + builder = builder.plugin(tauri_plugin_process::init()); + if context.config().plugins.0.contains_key("updater") { + builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); + } + } + + builder = builder.setup(|app| { + if let Err(e) = library::app_data_dir(app.handle()) { + eprintln!("failed to prepare app data dir: {e}"); + } + // The index is opened here rather than lazily on the first search, because opening it is + // where a schema migration runs and a migration that fails should say so at launch rather + // than the first time somebody presses Cmd+P. A failure is not fatal: the app is a text + // editor with a broken search box, which is worth far more than a window that will not + // open. + if let Err(e) = index::open(app.handle()) { + eprintln!("failed to open the search index: {e}"); + } + Ok(()) + }); + + #[cfg(desktop)] + { + builder = builder + .menu(|handle| build_menu(handle)) + .on_menu_event(|app, event| { + if matches!( + event.id().0.as_str(), + "open-folder" + | "new-doc" + | "new-folder" + | "save" + | "close-folder" + | "settings" + | "find" + | "find-in-files" + | "quick-open" + | "command-palette" + | "toggle-sidebar" + | "check-updates" + | "report-issue" + ) { + app.emit("menu-action", event.id().0.as_str()).ok(); + } + }); + } + + // The whole command surface, in the order dto.rs describes it. Registering a command is this + // file's job alone: a module adds a body, never a line here. + builder + .invoke_handler(tauri::generate_handler![ + fs::roots_list, + fs::root_open, + fs::root_close, + fs::tree_read, + fs::reveal_in_finder, + fs::open_external, + fs::file_read, + fs::file_write, + fs::file_create, + fs::file_folder_create, + fs::file_rename, + fs::file_move, + fs::file_duplicate, + fs::file_trash, + fs::asset_write, + watch::watch_start, + watch::watch_stop, + fs::index_rebuild, + fs::index_status, + fs::search_quick_open, + fs::search_text, + fs::backlinks_for, + spell::spell_check, + spell::spell_learn, + spell::spell_unlearn, + spell::spell_available, + ]) + .run(context) + .expect("error while running Margin Docs"); +} diff --git a/src-tauri/src/library.rs b/src-tauri/src/library.rs new file mode 100644 index 0000000..4a41745 --- /dev/null +++ b/src-tauri/src/library.rs @@ -0,0 +1,9 @@ +use std::fs; +use std::path::PathBuf; +use tauri::Manager; + +pub fn app_data_dir(app: &tauri::AppHandle) -> Result { + let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} diff --git a/src-tauri/src/macspell.rs b/src-tauri/src/macspell.rs new file mode 100644 index 0000000..72352b7 --- /dev/null +++ b/src-tauri/src/macspell.rs @@ -0,0 +1,162 @@ +// The one file in this app that talks to AppKit, and the whole of spelling on macOS. +// +// Spelling is NSSpellChecker's rather than this app's. It is the same shared checker Mail, Notes +// and TextEdit correct into, so a word learned anywhere on the machine is a word this editor does +// not underline, the user's own configured languages come along for free, and nothing here ships a +// dictionary or holds an opinion about English. There is no custom word list beside it either: +// learning a word teaches it to the system, which is where every other Mac app puts it. +// +// Three things make this more than a one line binding. +// +// Offsets are the first. AppKit answers in NSRange, which counts UTF-16 code units, and the caller +// is a ProseMirror document, which counts code points. The two agree exactly until the paragraph +// holds an emoji or anything else outside the basic plane, and from that character onwards every +// later offset in the run is out by one per astral character. An underline drawn from a UTF-16 +// offset onto a code point document sits under the wrong word, and a suggestion applied at that +// offset replaces the wrong characters, which is a silent edit to the user's file. `utf16_to_codepoint` +// is the whole of the fix, and this file is the only place in the app allowed to do that conversion +// so that there is exactly one thing to keep right. +// +// Which results to keep is the second. The checker is asked for Spelling and Link together and +// only the spelling results are returned. Link earns its place in the request because it makes the +// checker treat a URL as one span: without it `https://github.com/some-repo` is a run of tokens +// none of which are in any dictionary, and a paragraph carrying a link comes back with half of it +// underlined. +// +// The main thread is the third, and the answer is that none of this needs it. objc2 asks for a +// `MainThreadMarker` on exactly the panel accessors of NSSpellChecker (`spellingPanel`, +// `accessoryView`, `substitutionsPanel`), which this file never touches. The checking and learning +// calls carry no such requirement, and since each of them round trips to the system spell service +// over XPC, the caller deliberately runs them off the main thread. + +use objc2::rc::autoreleasepool; +use objc2_app_kit::NSSpellChecker; +use objc2_foundation::{NSRange, NSString, NSTextCheckingType}; + +use crate::dto::SpellIssue; + +/// A context menu is a menu, not a dictionary page. The checker will happily offer thirty guesses +/// and the ones past the first few are noise the user has to read past to reach Learn Spelling. +const MAX_SUGGESTIONS: usize = 5; + +/// Zero as the spell document tag, everywhere below. A tag buys a per-document session the checker +/// remembers ignored words against, and this app has no Ignore: a word is either learned for good +/// or it stays underlined, so there is no session to allocate. +const NO_DOCUMENT: isize = 0; + +/// UTF-16 offset to code point offset, one entry per code unit of `text` plus a terminal entry, so +/// both ends of a half-open range are a lookup and neither is a special case. +/// +/// A character outside the basic plane occupies two code units and one code point, so both of its +/// units map to the same code point index. An NSRange landing in the middle of a surrogate pair, +/// which the checker will not produce, therefore resolves to the start of that character rather +/// than to a position that does not exist. +fn utf16_to_codepoint(text: &str, utf16_len: usize) -> Vec { + let mut map = Vec::with_capacity(utf16_len + 1); + let mut cp = 0; + for ch in text.chars() { + for _ in 0..ch.len_utf16() { + map.push(cp); + } + cp += 1; + } + map.push(cp); + map +} + +/// Every misspelling in one run of text, with half-open offsets in characters counted from the +/// start of that run. +/// +/// The run is not split into words here. NSSpellChecker does that better than any rule this app +/// could write: it knows about contractions, hyphenation, proper nouns, capitalisation and +/// whichever languages the user has turned on, and it decides where a word begins in each of them. +pub fn check(text: &str) -> Vec { + autoreleasepool(|_| { + let checker = NSSpellChecker::sharedSpellChecker(); + let ns = NSString::from_str(text); + let len = ns.length(); + let results = unsafe { + checker.checkString_range_types_options_inSpellDocumentWithTag_orthography_wordCount( + &ns, + NSRange { + location: 0, + length: len, + }, + (NSTextCheckingType::Spelling | NSTextCheckingType::Link).bits(), + None, + NO_DOCUMENT, + None, + std::ptr::null_mut(), + ) + }; + + let map = utf16_to_codepoint(text, len); + let chars: Vec = text.chars().collect(); + let mut issues = Vec::new(); + for result in results.iter() { + // Link results were asked for so the checker would recognise a URL as one span, not so + // that anything would be reported about them. + if result.resultType() != NSTextCheckingType::Spelling { + continue; + } + let range = result.range(); + // Clamped to the length the map was built from. A range past the end would index out + // of it and panic, and a panic here takes down a command the frontend runs on every + // keystroke. + let start = map[range.location.min(len)]; + let end = map[range.location.saturating_add(range.length).min(len)]; + + // The word comes back out of `text` rather than from the checker, so the string the + // frontend matches against is byte for byte the one it sent. + let word: String = chars[start..end].iter().collect(); + + // Asked in the checker's own coordinates, because this range indexes into `ns`. + let mut suggestions = Vec::new(); + if let Some(guesses) = checker.guessesForWordRange_inString_language_inSpellDocumentWithTag( + range, + &ns, + None, + NO_DOCUMENT, + ) { + for guess in guesses.iter() { + suggestions.push(guess.to_string()); + if suggestions.len() >= MAX_SUGGESTIONS { + break; + } + } + } + + // Reported even with nothing to suggest. NSSpellChecker regularly flags a typo it has + // no guess for, and dropping those because the menu would have no replacements in it + // is how a checker earns a reputation for missing things. + issues.push(SpellIssue { + start, + end, + word, + suggestions, + }); + } + issues + }) +} + +/// Teaches `word` to the system, for every app on this machine and not only for this one. +/// +/// That is not a shortcut, it is what a checker borrowed from the OS does: `learnWord:` hands the +/// word to the system spell service, exactly where the "Learn Spelling" item in Mail or Pages puts +/// it, and every app on the machine stops underlining it from then on. This app deliberately keeps +/// no private word list beside that, because a second dictionary the rest of the system cannot see +/// is a word the user has to teach twice. +pub fn learn(word: &str) { + autoreleasepool(|_| { + NSSpellChecker::sharedSpellChecker().learnWord(&NSString::from_str(word)); + }) +} + +/// Undoes a `learn`, for a word taught by a slip of the hand. Also system wide, and the checker +/// treats unlearning a word it was never taught as nothing to do rather than as an error. +pub fn unlearn(word: &str) { + autoreleasepool(|_| { + NSSpellChecker::sharedSpellChecker().unlearnWord(&NSString::from_str(word)); + }) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..99cf835 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + margin_docs_lib::run() +} diff --git a/src-tauri/src/spell.rs b/src-tauri/src/spell.rs new file mode 100644 index 0000000..5b29fcf --- /dev/null +++ b/src-tauri/src/spell.rs @@ -0,0 +1,95 @@ +// The four spelling commands, and the only place in this crate that knows whether the machine has +// a checker at all. +// +// Everything real happens in macspell.rs, which is compiled on macOS alone. This module exists so +// that the frontend gets the same four commands on every platform: it picks a checker at compile +// time and each command below has one body rather than a cfg in the middle of it. +// +// A run with no misspellings and a build with no checker both answer with an empty list, and that +// is deliberate. Returning an error from `spell_check` on a platform without NSSpellChecker would +// put a permanent failure toast in front of a user whose actual situation is "this build cannot +// check spelling", which is not a failure and is not something they can act on. `spell_available` +// is where that fact belongs, because it is the one answer the UI can do something with: it hides +// the underlines and the menu rather than offering a menu that does nothing. +// +// There is no state here and no dictionary file. The system holds the learned words, so there is +// nothing for this module to load at launch, nothing to keep in sync and nothing to migrate. + +use crate::dto::SpellIssue; + +#[cfg(target_os = "macos")] +use crate::macspell as checker; +#[cfg(not(target_os = "macos"))] +use self::no_checker as checker; + +/// Spelling on a platform this app has no system checker for: every call succeeds and does +/// nothing. The alternative is a cfg inside each of the three commands that touch a checker, and +/// three chances to get the non-macOS answer subtly different from each other. +#[cfg(not(target_os = "macos"))] +mod no_checker { + use crate::dto::SpellIssue; + + pub fn check(_text: &str) -> Vec { + Vec::new() + } + + pub fn learn(_word: &str) {} + + pub fn unlearn(_word: &str) {} +} + +/// Every misspelling in one run of text. +/// +/// Offsets are half-open and counted in characters from the start of the run that was passed in, +/// never from the start of a document. The checker is told about a paragraph and answers about that +/// paragraph; it has no idea a document exists, which is what leaves the caller free to send a +/// paragraph, a visible screenful or one sentence, and to add its own base offset afterwards. +/// +/// Runs off the main thread. The call reaches the system spell service over XPC and a long +/// paragraph is enough work that a window held still for the length of it would be visible, which +/// matters more here than elsewhere because this is called while the user is typing. +#[tauri::command(async)] +pub fn spell_check(text: String) -> Result, String> { + Ok(checker::check(&text)) +} + +/// Teaches a word to the system dictionary, for every app on the machine and not only for this +/// one. +/// +/// That is the honest behaviour of a checker borrowed from the OS, and it is exactly what the +/// "Learn Spelling" item in every other Mac app does. This app ships no dictionary of its own and +/// keeps no private word list, so there is nowhere else for the word to go and nothing that would +/// need teaching twice. +/// +/// A blank word is nothing to learn rather than an error: the frontend takes the word from +/// whatever the user right clicked, and an empty selection is a mis-click, not a failure worth a +/// toast. +#[tauri::command(async)] +pub fn spell_learn(word: String) -> Result<(), String> { + let word = word.trim(); + if word.is_empty() { + return Ok(()); + } + checker::learn(word); + Ok(()) +} + +/// Undoes a `spell_learn`, for a word taught by a slip of the hand. System wide in the same way, +/// and unlearning a word that was never learned is nothing to do rather than an error. +#[tauri::command(async)] +pub fn spell_unlearn(word: String) -> Result<(), String> { + let word = word.trim(); + if word.is_empty() { + return Ok(()); + } + checker::unlearn(word); + Ok(()) +} + +/// Whether this build has a checker behind it. Answered from the target rather than by asking +/// AppKit anything: NSSpellChecker is part of macOS itself, so on a build that has it there is no +/// failure mode where it is absent, and on any other build there is nothing to ask. +#[tauri::command] +pub fn spell_available() -> Result { + Ok(cfg!(target_os = "macos")) +} diff --git a/src-tauri/src/watch.rs b/src-tauri/src/watch.rs new file mode 100644 index 0000000..8d2a205 --- /dev/null +++ b/src-tauri/src/watch.rs @@ -0,0 +1,463 @@ +// One filesystem watcher per open root. Changes never come back as a return value: each debounced +// batch is emitted as a `watch-event`, so a file another program touched reaches the frontend the +// same way whether anything asked for it or not. +// +// Debounced because one logical change is a burst of raw events. A git checkout rewrites a hundred +// files, another editor's atomic save is a create, a rename and a remove for what the user thinks +// of as one save, and a folder copied in arrives file by file. Reacting to raw events would reload +// the open document several times over for a single save somewhere else. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; +use std::time::{Duration, Instant, SystemTime}; + +use notify::event::{ModifyKind, RenameMode}; +use notify::{EventKind, RecommendedWatcher, RecursiveMode}; +use notify_debouncer_full::{ + new_debouncer_opt, DebounceEventResult, DebouncedEvent, Debouncer, NoCache, +}; +use tauri::{AppHandle, Emitter, Manager, State}; + +use crate::dto::WatchEvent; +use crate::Roots; + +/// Mirrors `WATCH_EVENT` in src/ipc.ts. +const WATCH_EVENT: &str = "watch-event"; + +/// How long a burst of raw events for one path is allowed to settle before it is reported. +/// +/// Long enough that an atomic save arrives as one batch rather than as its create, rename and +/// remove parts, short enough that a file changed by another program shows up while the user is +/// still looking at the window that changed it. +const DEBOUNCE: Duration = Duration::from_millis(300); + +/// How long a path stays on the self-written list. +/// +/// The event for a write cannot reach the callback sooner than `DEBOUNCE` after the write finishes, +/// and the debouncer's tick is a further `DEBOUNCE / 4`, so nothing under about 375ms would suppress +/// anything at all. The rest is headroom for the write itself: an fsync on a large document on a +/// busy disk can take a good fraction of a second, and the path is registered before the write +/// starts, not after. Two seconds leaves room for that several times over, and the cost of +/// overshooting is bounded and mild. +/// +/// What that cost is: an external change to a file the app itself wrote less than two seconds ago is +/// dropped. That is the right answer anyway. The only way to be inside that window is for the user +/// to be typing in that document right now, and a reload mid-keystroke would throw away their +/// unsaved text to show them somebody else's. The next save catches it regardless, because +/// `file_write` compares mtimes and reports a conflict. Erring the other way is not symmetric: a +/// leaked echo of the app's own autosave reloads the editor under the cursor on every save, which +/// makes the app unusable rather than briefly out of date. +/// +/// Entries expire on time and are not consumed on the first match, because one atomic save can +/// produce several debounced events for the same path and suppressing only the first would defeat +/// the whole thing. +const SELF_WRITE_WINDOW: Duration = Duration::from_millis(2_000); + +/// How long after a change was raised a file may have been born and still count as created by it. +/// +/// Covers the write landing, the backend noticing and the timestamp's own granularity. Too tight +/// and a new file is reported as a modification of a file the tree has never heard of; too loose +/// and editing a file made moments ago is reported as making it again. +const BIRTH_SLACK: Duration = Duration::from_millis(250); + +/// Paths this app wrote, and when. +/// +/// A global rather than managed state because the commands that write files take a path and nothing +/// else: their signatures are the frozen contract, so there is no `State` for them to reach the +/// watcher through. Keyed by the path with its directory resolved, since that is the only form both +/// sides can agree on. +static SELF_WRITES: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Records that this app is about to write `path`, so the watcher drops the event that comes back. +/// +/// Call it immediately before every write, for every path the write touches. An atomic save touches +/// two, the temp file and the target it is renamed over, and each end raises its own events, so +/// registering only the target lets the temp file's half through on its own. `fs::atomic_write` is +/// the one caller, and every write in the app goes through it. +/// +/// Cheap, so a caller unsure whether a path will really be written should register it anyway. The +/// entry expires on its own and registering a path that is never written costs one map slot for two +/// seconds. +pub fn note_self_write>(path: P) { + let key = resolve(path.as_ref()); + let now = Instant::now(); + if let Ok(mut writes) = SELF_WRITES.lock() { + writes.retain(|_, at| now.duration_since(*at) < SELF_WRITE_WINDOW); + writes.insert(key, now); + } +} + +/// The live watchers, keyed by root id. +/// +/// Dropping a debouncer stops its thread, so both `watch_stop` and closing a folder come down to a +/// remove from this map and nothing else. The map is the only place a watcher is held: a watcher +/// that is not in here is not running. +#[derive(Default)] +pub struct Watchers(pub Mutex>>); + +/// Starts watching one open root, recursively. Idempotent: starting a watch that is already running +/// is a no-op rather than a second watcher on the same folder. +/// +/// Every debounced change is emitted as one `watch-event` carrying the root id, so the frontend can +/// tell which tree to patch without matching path prefixes, and no path is ever the subject of more +/// than one event per batch. +/// +/// A rename is two events on macOS and not one: a `removed` for the name that went and a `created` +/// or `modified` for the name that arrived. FSEvents describes the two ends as unrelated changes and +/// nothing here can prove otherwise, so `old_path` stays empty and a frontend that wants to follow a +/// renamed document has to pair them up itself, or rely on `file_rename` for the renames it made. +/// `created` and `modified` are likewise a hint rather than a promise, since the only thing +/// separating them is how recently the file was born: both mean the row should be inserted or +/// refreshed. `removed` is exact, because it is a fact about the disk read at the moment of +/// emitting. +/// +/// The app's own writes are filtered out, on the strength of the paths `note_self_write` was told +/// about. The frontend cannot do this itself: by the time it hears about a change it has already +/// been handed a path and a reason to reload, and the mtime it holds cannot tell it apart from a +/// write another program made in the same second. +/// +/// The search index reads the same batch, which makes that filter its blind spot: the one document +/// this stream never mentions is the one the user is typing in, because that is the one this app +/// keeps saving. `fs::file_write` tells the index about its own saves for exactly that reason. +/// +/// The root going away takes the watcher with it. A folder deleted or moved out from under a +/// running watch emits one `removed` for the root path and then the watcher is dropped, since a +/// watch on a path that no longer exists reports nothing and would sit in the map forever. +#[tauri::command] +pub fn watch_start( + app: AppHandle, + roots: State<'_, Roots>, + watchers: State<'_, Watchers>, + root_id: String, +) -> Result<(), String> { + let root_path = roots.path_for(&root_id)?; + + let mut live = watchers.0.lock().map_err(|e| e.to_string())?; + if live.contains_key(&root_id) { + return Ok(()); + } + + let handle = app.clone(); + let dead_root = root_path.clone(); + let dead_id = root_id.clone(); + let debouncer = spawn_watcher(root_id.clone(), root_path, move |events| { + for event in &events { + handle.emit(WATCH_EVENT, event).ok(); + } + // The index reads the same batch the frontend does, so a file another program wrote is + // searchable at the same moment the tree learns about it. It goes here rather than inside + // `spawn_watcher` because that function is also what the tests drive, with a real folder and + // no app at all to hold an index. + crate::index::note_watch_events(&handle, &events); + if events + .iter() + .any(|event| event.kind == "removed" && event.path == dead_root) + { + reap(handle.clone(), dead_id.clone()); + } + })?; + + live.insert(root_id, debouncer); + Ok(()) +} + +/// Stops the watcher for one root and drops it. Stopping a watch that is not running is a no-op, so +/// the frontend can close a folder and stop its watcher without having to remember whether it ever +/// started one. +#[tauri::command] +pub fn watch_stop(watchers: State<'_, Watchers>, root_id: String) -> Result<(), String> { + let mut live = watchers.0.lock().map_err(|e| e.to_string())?; + live.remove(&root_id); + Ok(()) +} + +/// Watches `root_path` recursively and hands each debounced batch to `sink` as `watch-event` +/// payloads. The watch runs until the returned debouncer is dropped. +/// +/// `watch_start` is a thin wrapper over this: everything above the Tauri event lives here so the +/// tests can drive a real watcher over a real folder without an app to emit into. +pub fn spawn_watcher( + root_id: String, + root_path: String, + sink: F, +) -> Result, String> +where + F: Fn(Vec) + Send + 'static, +{ + let root = PathBuf::from(&root_path); + if !root.is_dir() { + return Err(format!("not a folder: {root_path}")); + } + let canonical = std::fs::canonicalize(&root).map_err(|e| e.to_string())?; + + let watched = canonical.clone(); + let handler = move |result: DebounceEventResult| { + let batch = match result { + Ok(batch) => batch, + Err(errors) => { + for error in errors { + eprintln!("watch error under {}: {error}", watched.display()); + } + return; + } + }; + let events = watch_events(&batch, &root_id, &watched, &root); + if !events.is_empty() { + sink(events); + } + }; + + // Deliberately without the file id cache the crate would otherwise pick. Its whole job is to + // recognise the two halves of a rename by inode, and on macOS it does more harm than good: it + // decides the halves belong together, folds the old name's events into the new name's queue, + // and then throws away the rename event that carried the old name, because FSEvents claims the + // old name was created. What comes out is a modification of the new path and no word at all + // that the old path is gone, which leaves a row in the tree for a file that no longer exists. + // With no cache the two halves stay separate and both ends get reported. + let mut debouncer: Debouncer = new_debouncer_opt( + DEBOUNCE, + None, + handler, + NoCache::new(), + notify::Config::default(), + ) + .map_err(|e| e.to_string())?; + + debouncer + .watch(&canonical, RecursiveMode::Recursive) + .map_err(|e| e.to_string())?; + Ok(debouncer) +} + +/// Drops a root's watcher from another thread. +/// +/// The call site is inside the debouncer's own callback, and dropping a debouncer from the thread it +/// is calling you on is asking for a join on yourself. One short-lived thread is the whole fix. +fn reap(app: AppHandle, root_id: String) { + std::thread::spawn(move || { + if let Some(watchers) = app.try_state::() { + if let Ok(mut live) = watchers.0.lock() { + live.remove(&root_id); + } + } + }); +} + +/// One debounced batch turned into the events the frontend sees: classified, filtered and reduced +/// to at most one event per path. +fn watch_events( + batch: &[DebouncedEvent], + root_id: &str, + canonical_root: &Path, + root_path: &Path, +) -> Vec { + let mut events: Vec = Vec::new(); + let mut index: HashMap = HashMap::new(); + + for event in batch { + // The kernel dropped events under load and the backend is telling us so. Nothing in the + // batch describes what was missed, so the honest answer is to report the root as changed + // and let the frontend read the tree again. + let next = if event.need_rescan() { + WatchEvent { + root: root_id.to_string(), + path: root_path.to_string_lossy().into_owned(), + kind: "modified".to_string(), + old_path: None, + } + } else { + let Some((kind, path, old_path)) = classify(event) else { + continue; + }; + // The root itself is exempt from the transient rule: a folder called `.notes` is a + // perfectly good root, and its own removal is the one event nothing under it can + // describe. + if was_self_written(&path) + || old_path.as_deref().is_some_and(was_self_written) + || (path.as_path() != canonical_root && is_transient(&path)) + || is_hidden_below(&path, canonical_root) + { + continue; + } + WatchEvent { + root: root_id.to_string(), + path: rebase(&path, canonical_root, root_path), + kind: kind.to_string(), + old_path: old_path.map(|path| rebase(&path, canonical_root, root_path)), + } + }; + + merge(&mut events, &mut index, next); + } + + // The root itself going away is the one change nothing under it can describe. macOS does report + // it as an event on the watched path, but a folder moved rather than emptied is a single rename + // this side may never see, so the state of the folder is checked rather than waited for. + if !canonical_root.exists() { + merge( + &mut events, + &mut index, + WatchEvent { + root: root_id.to_string(), + path: root_path.to_string_lossy().into_owned(), + kind: "removed".to_string(), + old_path: None, + }, + ); + } + + events +} + +fn merge(events: &mut Vec, index: &mut HashMap, next: WatchEvent) { + match index.get(&next.path) { + // A later `modified` says nothing a create or a rename in the same batch has not already + // said, and would lose that event's `old_path`. Anything else supersedes. + Some(_) if next.kind == "modified" => {} + Some(&at) => events[at] = next, + None => { + index.insert(next.path.clone(), events.len()); + events.push(next); + } + } +} + +/// The kind, the path it happened to and, for a rename, where the file was before. +/// +/// Almost nothing here comes from the event's own kind, and that is deliberate. FSEvents does not +/// describe a change, it describes a file: every event it reports for a path carries the union of +/// everything that has ever happened to that path, so `ItemCreated` is set on the modification of a +/// file that was created last week and on the deletion of one created a second ago. Trusting it +/// would report every save as a create and, once the debouncer has folded a create and a remove +/// together, every deletion as a modification. +/// +/// So the file itself is asked instead. The event says which path changed, which is the one thing +/// FSEvents is reliable about, and a stat at the moment of emitting says what it changed into. That +/// is also fresher than the flags: by the time a batch comes out it is at least a debounce window +/// old, and what is on disk now is what the frontend is about to go and read. +fn classify(event: &DebouncedEvent) -> Option<(&'static str, PathBuf, Option)> { + let first = event.paths.first()?.clone(); + + // The one thing a stat cannot answer afterwards is where a file used to be, so a rename the + // debouncer managed to stitch back together is read from the event. macOS never gets here: it + // reports the two ends of a rename as unrelated events and the pairing is left to the inode + // cache this watcher deliberately does without. A backend that names both ends itself, which + // inotify does through the rename cookie, still arrives whole. + if let EventKind::Modify(ModifyKind::Name(RenameMode::Both)) = &event.kind { + let to = event.paths.get(1)?.clone(); + if is_transient(&to) || !to.exists() { + return Some(("removed", first, None)); + } + if is_transient(&first) { + // Another editor saving the way this one does: a temp file renamed over the target. + // Reporting the temp name as the document's previous name would have the frontend go + // looking for a tree row that never existed. + return Some((appearance(event, &to), to, None)); + } + return Some(("renamed", to, Some(first))); + } + + if matches!(&event.kind, EventKind::Access(_) | EventKind::Other) { + return None; + } + + Some((appearance(event, &first), first, None)) +} + +/// What is at the path now: `created`, `modified` or `removed`. +/// +/// Created and modified are told apart by the file's birth time, since nothing else survives to +/// here. A file born within a slack of when the event was raised was born by the change the event +/// describes; anything older was only touched by it. The slack covers the gap between the write +/// landing and the backend seeing it, and erring towards `created` is the cheaper mistake: both +/// kinds mean the same thing to a tree that inserts or refreshes a row, and only `removed` means +/// something a frontend must not get wrong. +fn appearance(event: &DebouncedEvent, path: &Path) -> &'static str { + let Ok(meta) = std::fs::symlink_metadata(path) else { + return "removed"; + }; + let Ok(born) = meta.created() else { + return "modified"; + }; + let happened = SystemTime::now().checked_sub(event.time.elapsed()); + match (born.checked_add(BIRTH_SLACK), happened) { + (Some(fresh_until), Some(happened)) if fresh_until >= happened => "created", + _ => "modified", + } +} + +/// A name no tree row will ever carry: an editor's lock file, swap file or backup, or the temp file +/// half of somebody's atomic save. +/// +/// Every path in a batch goes through this, not only the two ends of a rename the debouncer managed +/// to stitch together. macOS never reports a rename whole, so the branch in `classify` that used to +/// be the only caller never ran there, and the temp file of every save in the folder was reported to +/// the frontend as a document appearing and then vanishing. +/// +/// This app's own temp file, `...tmp`, is caught twice over, by the leading dot and by +/// the extension. That is on purpose: `note_self_write` already covers it, and a save that somehow +/// outran its two second window should still not put a temp name in front of the user. +fn is_transient(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + name.starts_with('.') + || name.ends_with('~') + || name.ends_with(".tmp") + || name.ends_with(".swp") + || name.ends_with(".swx") +} + +/// Whether the path sits under a dot-directory or is a dotfile, counted from the root down. +/// +/// The tree hides those, so reporting them would be reporting changes to rows that do not exist: +/// `.git` alone would fire hundreds of times for one checkout. Counted from the root and not from +/// `/` because a root may perfectly well be a folder inside `~/.config`, and that folder's contents +/// are not hidden from anybody. +fn is_hidden_below(path: &Path, root: &Path) -> bool { + let Ok(rel) = path.strip_prefix(root) else { + return false; + }; + rel.components() + .any(|part| part.as_os_str().to_string_lossy().starts_with('.')) +} + +/// The path as the frontend knows it: under the root exactly as `Roots` spells it. +/// +/// FSEvents reports resolved paths, so a root opened as `/tmp/notes` comes back as +/// `/private/tmp/notes` and every path the frontend holds would fail to match. +fn rebase(path: &Path, canonical_root: &Path, root_path: &Path) -> String { + match path.strip_prefix(canonical_root) { + Ok(rel) if rel.as_os_str().is_empty() => root_path.to_string_lossy().into_owned(), + Ok(rel) => root_path.join(rel).to_string_lossy().into_owned(), + Err(_) => path.to_string_lossy().into_owned(), + } +} + +/// A path with its directory resolved through any symlink, which is the form event paths arrive in. +/// +/// The directory and not the path itself, because the file about to be written may not exist yet and +/// there is nothing to canonicalise. macOS alone makes this necessary: `/tmp` and `/var` are +/// symlinks into `/private`, so a document under either would never match the event describing it. +fn resolve(path: &Path) -> PathBuf { + let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else { + return path.to_path_buf(); + }; + match std::fs::canonicalize(parent) { + Ok(dir) => dir.join(name), + Err(_) => path.to_path_buf(), + } +} + +/// Event paths arrive already resolved, because the watch is placed on the canonicalised root, so +/// they can be looked up as they are. +fn was_self_written(path: &Path) -> bool { + let Ok(writes) = SELF_WRITES.lock() else { + return false; + }; + writes + .get(path) + .is_some_and(|at| Instant::now().duration_since(*at) < SELF_WRITE_WINDOW) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..4da20ad --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Margin Docs", + "version": "0.0.1", + "identifier": "studio.margin.docs", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://localhost:1440", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "", + "width": 1360, + "height": 900, + "minWidth": 880, + "minHeight": 600, + "titleBarStyle": "Overlay" + } + ], + "security": { + "csp": "default-src 'self'; img-src 'self' data: blob: asset: http://asset.localhost; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; connect-src 'self' ipc: http://ipc.localhost" + } + }, + "bundle": { + "active": true, + "targets": [ + "app", + "dmg" + ], + "category": "Productivity", + "shortDescription": "A folder of markdown documents", + "longDescription": "Margin Docs is an editor for a folder of markdown documents.", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "macOS": { + "minimumSystemVersion": "10.15" + } + } +} diff --git a/src-tauri/tauri.release.conf.json b/src-tauri/tauri.release.conf.json new file mode 100644 index 0000000..e6e5ff5 --- /dev/null +++ b/src-tauri/tauri.release.conf.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "createUpdaterArtifacts": true + }, + "plugins": { + "updater": { + "pubkey": "REPLACE_WITH_TAURI_SIGNER_PUBKEY", + "endpoints": [ + "https://github.com/priyanshujain/margin-docs/releases/latest/download/latest.json" + ] + } + } +} diff --git a/src-tauri/tests/fs.rs b/src-tauri/tests/fs.rs new file mode 100644 index 0000000..815417d --- /dev/null +++ b/src-tauri/tests/fs.rs @@ -0,0 +1,597 @@ +// The filesystem layer is the only part of this app that can lose somebody's work, so the tests +// here are about the promises rather than the plumbing: a failed write leaves the old file whole, +// a conflict writes nothing at all, a name is never taken from a file that already has it, a path +// from the frontend cannot reach outside the folders the user opened, and a delete is always +// something Finder can undo. + +use std::collections::BTreeSet; +use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use margin_docs_lib::dto::FileNode; +use margin_docs_lib::fs::{ + atomic_write, create_file, create_folder, duplicate_entry, free_path, move_entry, read_document, + rename_entry, resolve_in_roots, root_id_for, scan_tree, trash_entry, write_asset, + write_document, +}; +use tempfile::TempDir; + +fn root() -> TempDir { + TempDir::new().expect("a temp dir") +} + +fn write(path: &Path, text: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, text).unwrap(); +} + +fn names(node: &FileNode) -> Vec { + node.children.iter().map(|c| c.name.clone()).collect() +} + +fn find<'a>(node: &'a FileNode, name: &str) -> Option<&'a FileNode> { + node.children.iter().find(|c| c.name == name) +} + +fn flat(node: &FileNode, into: &mut Vec) { + into.push(node.name.clone()); + for child in &node.children { + flat(child, into); + } +} + +#[test] +fn a_failed_write_leaves_the_original_whole() { + let dir = root(); + let doc = dir.path().join("doc.md"); + write(&doc, "the original"); + + // A folder that cannot be written to makes the write fail at its first step, which is the + // moment the original is most at risk. The temp name cannot be blocked from outside any more, + // since it is unique per call, so the folder is what gets taken away instead. + let open = fs::metadata(dir.path()).unwrap().permissions(); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o500)).unwrap(); + + let result = atomic_write(&doc, b"the replacement"); + + fs::set_permissions(dir.path(), open).unwrap(); + assert!(result.is_err()); + assert_eq!(fs::read_to_string(&doc).unwrap(), "the original"); +} + +/// A `.bak` or a `.tmp` named after a document is a file somebody may have written on purpose, and +/// a save of the document it is named after is not permission to unlink it. +#[test] +fn a_write_leaves_the_users_own_bak_and_tmp_siblings_alone() { + let dir = root(); + let doc = dir.path().join("doc.md"); + let bak = dir.path().join("doc.md.bak"); + let tmp = dir.path().join("doc.md.tmp"); + write(&doc, "one"); + write(&bak, "a revision the author kept on purpose"); + write(&tmp, "a scratch file the author kept on purpose"); + + atomic_write(&doc, b"two").unwrap(); + + assert_eq!(fs::read_to_string(&doc).unwrap(), "two"); + assert_eq!( + fs::read_to_string(&bak).unwrap(), + "a revision the author kept on purpose", + "the save deleted a backup the user owned" + ); + assert_eq!( + fs::read_to_string(&tmp).unwrap(), + "a scratch file the author kept on purpose", + "the save deleted a scratch file the user owned" + ); +} + +/// Nothing named after the document may appear beside it, not even for the length of one save. +/// Anything else watching the folder, git included, sees whatever is there while the write runs, and +/// a crash halfway through strands it for good. +#[test] +fn a_save_puts_nothing_document_shaped_beside_the_document() { + let dir = root(); + let doc = dir.path().join("doc.md"); + write(&doc, "one"); + + let watched = dir.path().to_path_buf(); + let (stop_tx, stop_rx) = mpsc::channel::<()>(); + let poller = std::thread::spawn(move || { + let mut seen: BTreeSet = BTreeSet::new(); + while stop_rx.try_recv().is_err() { + if let Ok(entries) = fs::read_dir(&watched) { + for entry in entries.flatten() { + seen.insert(entry.file_name().to_string_lossy().into_owned()); + } + } + } + seen + }); + + // Big enough that the copy, the write and the fsync are a window a poller can see into. + atomic_write(&doc, &vec![b'z'; 32 * 1024 * 1024]).unwrap(); + stop_tx.send(()).ok(); + let seen = poller.join().unwrap(); + + let strays: Vec<&String> = seen + .iter() + .filter(|name| name.as_str() != "doc.md") + .filter(|name| !(name.starts_with(".doc.md.") && name.ends_with(".tmp"))) + .collect(); + assert!(strays.is_empty(), "a save put these beside the document: {strays:?}"); + assert!( + seen.iter().any(|name| name.starts_with(".doc.md.")), + "the poller never caught the temp file, so this proved nothing: {seen:?}" + ); + + let left: Vec = fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(left, vec!["doc.md".to_string()]); +} + +/// A debounced autosave and a Cmd+S both in flight are two saves of one document. Naming the temp +/// file after the target made them race on one path, and the loser took the document with it. +#[test] +fn concurrent_saves_of_one_document_all_land_and_none_loses_it() { + let dir = root(); + let doc = dir.path().join("doc.md"); + write(&doc, "the original"); + + let payloads: Vec = (0..4) + .map(|n| format!("save number {n}\n{}\n", "z".repeat(1024 * 1024))) + .collect(); + let writers: Vec<_> = payloads + .iter() + .cloned() + .map(|text| { + let doc = doc.clone(); + std::thread::spawn(move || atomic_write(&doc, text.as_bytes())) + }) + .collect(); + for writer in writers { + writer + .join() + .expect("a writer thread") + .expect("every concurrent save succeeds"); + } + + let landed = fs::read_to_string(&doc).expect("the document is still there"); + assert!( + payloads.contains(&landed), + "the document holds neither writer's text" + ); + let left: Vec = fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(left, vec!["doc.md".to_string()]); +} + +#[test] +fn a_write_leaves_no_temp_and_no_backup_behind() { + let dir = root(); + let doc = dir.path().join("doc.md"); + write(&doc, "one"); + + atomic_write(&doc, b"two").unwrap(); + + assert_eq!(fs::read_to_string(&doc).unwrap(), "two"); + let mut left: Vec = fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + left.sort(); + assert_eq!(left, vec!["doc.md".to_string()]); +} + +#[test] +fn a_write_keeps_the_permissions_the_file_had() { + let dir = root(); + let doc = dir.path().join("doc.md"); + write(&doc, "one"); + fs::set_permissions(&doc, fs::Permissions::from_mode(0o600)).unwrap(); + + atomic_write(&doc, b"two").unwrap(); + + let mode = fs::metadata(&doc).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); +} + +#[test] +fn a_conflict_writes_nothing() { + let dir = root(); + let doc = dir.path().join("doc.md"); + write(&doc, "what is on disk"); + let seen = read_document(&doc).unwrap(); + + let result = write_document(&doc, "what the buffer holds", Some(seen.modified_ms - 5_000)) + .expect("a conflict is a result and not an error"); + + assert!(result.conflict); + assert_eq!(fs::read_to_string(&doc).unwrap(), "what is on disk"); +} + +#[test] +fn the_timestamp_the_caller_saw_lets_the_write_through() { + let dir = root(); + let doc = dir.path().join("doc.md"); + write(&doc, "one"); + let seen = read_document(&doc).unwrap(); + + let result = write_document(&doc, "two", Some(seen.modified_ms)).unwrap(); + + assert!(!result.conflict); + assert_eq!(fs::read_to_string(&doc).unwrap(), "two"); +} + +#[test] +fn reading_a_document_writes_nothing() { + let dir = root(); + let doc = dir.path().join("doc.md"); + write(&doc, "# Heading\n"); + let before = fs::metadata(&doc).unwrap().modified().unwrap(); + + let read = read_document(&doc).unwrap(); + + assert_eq!(read.text, "# Heading\n"); + assert_eq!(fs::metadata(&doc).unwrap().modified().unwrap(), before); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1); +} + +#[test] +fn a_document_that_is_not_utf8_is_an_error_and_not_a_lossy_read() { + let dir = root(); + let doc = dir.path().join("doc.md"); + fs::write(&doc, [0xff, 0xfe, 0x00, 0x41]).unwrap(); + + assert!(read_document(&doc).is_err()); +} + +#[test] +fn untitled_naming_does_not_collide() { + let dir = root(); + + let first = create_file(dir.path(), "untitled.md").unwrap(); + let second = create_file(dir.path(), "untitled.md").unwrap(); + let third = create_file(dir.path(), "untitled.md").unwrap(); + + assert_eq!(first.name, "untitled.md"); + assert_eq!(second.name, "untitled-2.md"); + assert_eq!(third.name, "untitled-3.md"); + assert!(dir.path().join("untitled.md").exists()); + assert!(dir.path().join("untitled-2.md").exists()); + assert!(dir.path().join("untitled-3.md").exists()); + assert!(first.editable); + assert_eq!(first.kind, "markdown"); +} + +#[test] +fn a_free_name_never_lands_on_a_file_that_is_there() { + let dir = root(); + write(&dir.path().join("note.md"), "one"); + write(&dir.path().join("note-2.md"), "two"); + + assert_eq!(free_path(dir.path(), "note.md"), dir.path().join("note-3.md")); +} + +#[test] +fn a_new_folder_follows_the_same_rule() { + let dir = root(); + + let first = create_folder(dir.path(), "untitled").unwrap(); + let second = create_folder(dir.path(), "untitled").unwrap(); + + assert_eq!(first.name, "untitled"); + assert_eq!(first.kind, "dir"); + assert!(!first.editable); + assert_eq!(second.name, "untitled-2"); +} + +#[test] +fn the_tree_skips_node_modules_and_the_rest() { + let dir = root(); + write(&dir.path().join("note.md"), "note"); + write(&dir.path().join("node_modules/left-pad/index.js"), "js"); + write(&dir.path().join(".git/config"), "config"); + write(&dir.path().join("target/debug/thing"), "binary"); + write(&dir.path().join("dist/bundle.js"), "bundle"); + + let tree = scan_tree(dir.path(), false).unwrap(); + + assert_eq!(names(&tree), vec!["note.md".to_string()]); +} + +#[test] +fn the_tree_respects_a_gitignore() { + let dir = root(); + write(&dir.path().join(".gitignore"), "drafts/\nsecret.md\n"); + write(&dir.path().join("note.md"), "note"); + write(&dir.path().join("secret.md"), "secret"); + write(&dir.path().join("drafts/half.md"), "half"); + + let tree = scan_tree(dir.path(), false).unwrap(); + + assert_eq!(names(&tree), vec!["note.md".to_string()]); +} + +#[test] +fn showing_ignored_files_brings_them_back() { + let dir = root(); + write(&dir.path().join(".gitignore"), "secret.md\n"); + write(&dir.path().join("note.md"), "note"); + write(&dir.path().join("secret.md"), "secret"); + write(&dir.path().join("node_modules/left-pad/index.js"), "js"); + + let tree = scan_tree(dir.path(), true).unwrap(); + let mut all = Vec::new(); + flat(&tree, &mut all); + + assert!(all.contains(&"secret.md".to_string())); + assert!(all.contains(&"node_modules".to_string())); +} + +#[test] +fn the_tree_classifies_every_kind_and_sorts_folders_first() { + let dir = root(); + write(&dir.path().join("zebra.md"), "md"); + write(&dir.path().join("Apple.txt"), "txt"); + write(&dir.path().join("photo.png"), "png"); + write(&dir.path().join("beta/inner.markdown"), "md"); + + let tree = scan_tree(dir.path(), false).unwrap(); + + assert_eq!( + names(&tree), + vec![ + "beta".to_string(), + "Apple.txt".to_string(), + "photo.png".to_string(), + "zebra.md".to_string(), + ] + ); + assert_eq!(find(&tree, "zebra.md").unwrap().kind, "markdown"); + assert!(find(&tree, "Apple.txt").unwrap().editable); + assert_eq!(find(&tree, "Apple.txt").unwrap().kind, "text"); + assert_eq!(find(&tree, "photo.png").unwrap().kind, "other"); + assert!(!find(&tree, "photo.png").unwrap().editable); + assert_eq!(find(&tree, "beta").unwrap().children.len(), 1); +} + +#[test] +fn a_symlink_loop_does_not_hang_the_scan() { + let dir = root(); + write(&dir.path().join("note.md"), "note"); + fs::create_dir(dir.path().join("inner")).unwrap(); + symlink(dir.path(), dir.path().join("inner/loop")).unwrap(); + + let tree = scan_tree(dir.path(), false).unwrap(); + let mut all = Vec::new(); + flat(&tree, &mut all); + + assert!(all.contains(&"note.md".to_string())); +} + +#[test] +fn path_validation_rejects_an_escape() { + let dir = root(); + let roots = vec![dir.path().to_string_lossy().into_owned()]; + + let escape = dir.path().join("../escaped.md"); + assert!(resolve_in_roots(&roots, &escape.to_string_lossy()).is_err()); + assert!(resolve_in_roots(&roots, "/etc/hosts").is_err()); + assert!(resolve_in_roots(&roots, "notes/relative.md").is_err()); + assert!(resolve_in_roots(&[], &dir.path().join("note.md").to_string_lossy()).is_err()); +} + +#[test] +fn path_validation_rejects_a_symlink_pointing_out_of_the_root() { + let inside = root(); + let outside = root(); + let secret = outside.path().join("secret.md"); + write(&secret, "secret"); + symlink(&secret, inside.path().join("link.md")).unwrap(); + let roots = vec![inside.path().to_string_lossy().into_owned()]; + + let link = inside.path().join("link.md"); + assert!(resolve_in_roots(&roots, &link.to_string_lossy()).is_err()); +} + +#[test] +fn path_validation_does_not_treat_a_sibling_as_a_child() { + let dir = root(); + fs::create_dir(dir.path().join("notes")).unwrap(); + fs::create_dir(dir.path().join("notes-old")).unwrap(); + write(&dir.path().join("notes-old/note.md"), "note"); + let roots = vec![dir.path().join("notes").to_string_lossy().into_owned()]; + + let sibling = dir.path().join("notes-old/note.md"); + assert!(resolve_in_roots(&roots, &sibling.to_string_lossy()).is_err()); +} + +#[test] +fn path_validation_accepts_what_is_really_inside() { + let dir = root(); + let doc = dir.path().join("folder/note.md"); + write(&doc, "note"); + let roots = vec![dir.path().to_string_lossy().into_owned()]; + + let resolved = resolve_in_roots(&roots, &doc.to_string_lossy()).unwrap(); + assert_eq!(resolved, fs::canonicalize(&doc).unwrap()); + + // A file being created has no canonical path of its own, and it still has to be checked. + let unborn = dir.path().join("folder/new.md"); + let resolved = resolve_in_roots(&roots, &unborn.to_string_lossy()).unwrap(); + assert_eq!( + resolved, + fs::canonicalize(dir.path().join("folder")).unwrap().join("new.md") + ); +} + +#[test] +fn trash_does_not_hard_delete() { + let dir = root(); + let unique = format!( + "margin-docs-trash-test-{}.md", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let doc = dir.path().join(&unique); + write(&doc, "recoverable"); + + trash_entry(&doc).unwrap(); + + assert!(!doc.exists(), "the file is gone from where it was"); + let trashed = PathBuf::from(std::env::var("HOME").unwrap()) + .join(".Trash") + .join(&unique); + assert!(trashed.exists(), "and it is sitting in the Trash instead"); + + // Best effort, because the contents of the Trash are not always readable or writable by a + // process that is not Finder. The test above is the assertion; this is only tidying up. + let _ = fs::remove_file(&trashed); +} + +#[test] +fn a_rename_will_not_move() { + let dir = root(); + let doc = dir.path().join("note.md"); + write(&doc, "note"); + + assert!(rename_entry(&doc, "../elsewhere.md").is_err()); + assert!(rename_entry(&doc, "sub/other.md").is_err()); + assert!(rename_entry(&doc, " ").is_err()); + assert!(doc.exists()); + + let renamed = rename_entry(&doc, "other.md").unwrap(); + assert_eq!(renamed.name, "other.md"); + assert!(dir.path().join("other.md").exists()); + assert!(!doc.exists()); +} + +#[test] +fn a_rename_onto_a_name_that_is_taken_is_refused() { + let dir = root(); + write(&dir.path().join("one.md"), "one"); + write(&dir.path().join("two.md"), "two"); + + assert!(rename_entry(&dir.path().join("one.md"), "two.md").is_err()); + assert_eq!(fs::read_to_string(dir.path().join("two.md")).unwrap(), "two"); +} + +#[test] +fn a_move_into_the_folder_it_is_already_in_does_nothing() { + let dir = root(); + let doc = dir.path().join("note.md"); + write(&doc, "note"); + + let node = move_entry(&doc, dir.path()).unwrap(); + + assert_eq!(node.name, "note.md"); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1); +} + +#[test] +fn a_move_will_not_put_a_folder_inside_itself() { + let dir = root(); + let outer = dir.path().join("outer"); + let inner = outer.join("inner"); + fs::create_dir_all(&inner).unwrap(); + + assert!(move_entry(&outer, &inner).is_err()); + assert!(move_entry(&outer, &outer).is_err()); +} + +#[test] +fn a_move_keeps_the_name_unless_it_is_taken() { + let dir = root(); + let from = dir.path().join("from"); + let to = dir.path().join("to"); + fs::create_dir_all(&from).unwrap(); + fs::create_dir_all(&to).unwrap(); + write(&from.join("note.md"), "moved"); + write(&to.join("note.md"), "already here"); + + let node = move_entry(&from.join("note.md"), &to).unwrap(); + + assert_eq!(node.name, "note-2.md"); + assert_eq!(fs::read_to_string(to.join("note.md")).unwrap(), "already here"); + assert_eq!(fs::read_to_string(to.join("note-2.md")).unwrap(), "moved"); +} + +#[test] +fn duplicating_copies_a_folder_and_everything_under_it() { + let dir = root(); + write(&dir.path().join("project/note.md"), "note"); + write(&dir.path().join("project/deep/inner.md"), "inner"); + + let node = duplicate_entry(&dir.path().join("project")).unwrap(); + + assert_eq!(node.name, "project-2"); + assert_eq!( + fs::read_to_string(dir.path().join("project-2/deep/inner.md")).unwrap(), + "inner" + ); + assert_eq!(fs::read_to_string(dir.path().join("project/note.md")).unwrap(), "note"); +} + +#[test] +fn duplicating_a_document_is_byte_for_byte() { + let dir = root(); + let doc = dir.path().join("note.md"); + write(&doc, "---\ntitle: x\n---\n\n# ragged heading\n"); + + let node = duplicate_entry(&doc).unwrap(); + + assert_eq!(node.name, "note-2.md"); + assert_eq!( + fs::read_to_string(dir.path().join("note-2.md")).unwrap(), + "---\ntitle: x\n---\n\n# ragged heading\n" + ); +} + +#[test] +fn a_pasted_image_lands_in_assets_beside_the_document() { + let dir = root(); + let doc = dir.path().join("folder/note.md"); + write(&doc, "note"); + + let first = write_asset(&doc, &[0x89, 0x50, 0x4e, 0x47], "image.png").unwrap(); + let second = write_asset(&doc, &[0x89, 0x50, 0x4e, 0x47], "image.png").unwrap(); + + assert_eq!(first.rel_path, "assets/image.png"); + assert_eq!(second.rel_path, "assets/image-2.png"); + assert_eq!( + fs::read(dir.path().join("folder/assets/image.png")).unwrap(), + vec![0x89, 0x50, 0x4e, 0x47] + ); + assert!(Path::new(&first.path).is_absolute()); +} + +#[test] +fn a_suggested_asset_name_is_a_name_and_not_a_path() { + let dir = root(); + let doc = dir.path().join("note.md"); + write(&doc, "note"); + + let result = write_asset(&doc, &[1, 2, 3], "../../../evil.png").unwrap(); + + assert_eq!(result.rel_path, "assets/evil.png"); + assert!(dir.path().join("assets/evil.png").exists()); +} + +#[test] +fn a_root_id_is_the_same_folder_every_time() { + assert_eq!(root_id_for("/Users/x/notes"), root_id_for("/Users/x/notes")); + assert_ne!(root_id_for("/Users/x/notes"), root_id_for("/Users/x/other")); + assert_eq!(root_id_for("/Users/x/notes").len(), 16); +} diff --git a/src-tauri/tests/fts5.rs b/src-tauri/tests/fts5.rs new file mode 100644 index 0000000..6ba43b7 --- /dev/null +++ b/src-tauri/tests/fts5.rs @@ -0,0 +1,18 @@ +// Full text search is load bearing for the index in M3, and it arrives through libsqlite3-sys's +// bundled build rather than through a cargo feature we can see in Cargo.toml. A dependency bump +// could drop it silently, so the assumption is asserted rather than assumed. + +#[test] +fn fts5_is_compiled_in() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE VIRTUAL TABLE probe USING fts5(path, body); + INSERT INTO probe(path, body) VALUES ('a.md', 'the quick brown fox'); + INSERT INTO probe(path, body) VALUES ('b.md', 'lazy dog sleeping');", + ) + .expect("fts5 virtual table must be creatable"); + let hit: String = conn + .query_row("SELECT path FROM probe WHERE probe MATCH 'brown'", [], |r| r.get(0)) + .unwrap(); + assert_eq!(hit, "a.md"); +} diff --git a/src-tauri/tests/no_write_on_open.rs b/src-tauri/tests/no_write_on_open.rs new file mode 100644 index 0000000..94e7d19 --- /dev/null +++ b/src-tauri/tests/no_write_on_open.rs @@ -0,0 +1,1574 @@ +// Does the editor touch files the user has not edited? +// +// Every test here runs against a real git repository on disk (a docs folder with subfolders, an +// assets folder, a .gitignore and a vendored node_modules) rather than against a TempDir, because +// the promise being checked is a promise about the user's own folder: the evidence that nothing was +// touched is `git status` being empty, plus a byte-level snapshot of every path under the root +// including .git itself. +// +// Run single threaded. The tests share one folder and several of them mutate it. +// +// cargo test --test no_write_on_open -- --test-threads=1 --nocapture + +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use margin_docs_lib::dto::{FileNode, WatchEvent}; +use margin_docs_lib::fs::{ + read_document, resolve_in_roots, root_id_for, scan_tree, write_document, +}; +use margin_docs_lib::watch::spawn_watcher; + +const REPO: &str = "/private/tmp/margin-notouch/notes-repo"; + +// ---------------------------------------------------------------- fixture + +fn repo() -> PathBuf { + let path = PathBuf::from(REPO); + assert!( + path.join(".git").is_dir(), + "the fixture repo is missing: {REPO}" + ); + path +} + +fn git(args: &[&str]) -> String { + let out = Command::new("git") + .args(args) + .current_dir(REPO) + .output() + .expect("git runs"); + let mut text = String::from_utf8_lossy(&out.stdout).into_owned(); + text.push_str(&String::from_utf8_lossy(&out.stderr)); + text +} + +/// Back to the committed state, then one warm `git status` so the index's stat cache is already +/// refreshed and a later `git status` is not itself the thing that wrote to .git. +fn pristine() -> PathBuf { + let root = repo(); + git(&["reset", "--hard", "-q"]); + git(&["clean", "-fdq"]); + let status = git(&["status", "--porcelain"]); + assert!( + status.is_empty(), + "the fixture repo did not start clean:\n{status}" + ); + root +} + +fn git_status() -> String { + git(&["status", "--porcelain"]) +} + +// ---------------------------------------------------------------- snapshots + +#[derive(Clone, PartialEq, Eq, Debug)] +struct Stamp { + kind: &'static str, + len: u64, + mtime: (i64, i64), + ctime: (i64, i64), + ino: u64, + mode: u32, + nlink: u64, + /// Content hash, for everything outside node_modules. `None` for directories and for the + /// 11,000 junk files, where size plus both timestamps plus the inode is already conclusive. + hash: Option, +} + +type Snapshot = BTreeMap; + +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in bytes { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// Every path under `root`, dotfiles, .git and node_modules included. Deliberately not the `ignore` +/// crate: the point is to see everything the app might have touched, including what it hides. +fn snapshot(root: &Path) -> Snapshot { + let mut out = Snapshot::new(); + walk(root, root, &mut out); + out +} + +fn walk(root: &Path, dir: &Path, out: &mut Snapshot) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let rel = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .into_owned(); + let Ok(meta) = fs::symlink_metadata(&path) else { + continue; + }; + let kind = if meta.is_dir() { + "dir" + } else if meta.is_symlink() { + "link" + } else { + "file" + }; + let cheap = rel.starts_with("node_modules/") || rel.starts_with(".git/"); + let hash = if kind == "file" && !cheap { + fs::read(&path).ok().map(|bytes| fnv1a(&bytes)) + } else { + None + }; + out.insert( + rel, + Stamp { + kind, + len: meta.len(), + mtime: (meta.mtime(), meta.mtime_nsec()), + ctime: (meta.ctime(), meta.ctime_nsec()), + ino: meta.ino(), + mode: meta.mode(), + nlink: meta.nlink(), + hash, + }, + ); + if meta.is_dir() { + walk(root, &path, out); + } + } +} + +#[derive(Default, Debug)] +struct Diff { + added: Vec, + removed: Vec, + changed: Vec, +} + +impl Diff { + fn is_empty(&self) -> bool { + self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty() + } +} + +fn diff(before: &Snapshot, after: &Snapshot) -> Diff { + let mut out = Diff::default(); + for (path, stamp) in after { + match before.get(path) { + None => out.added.push(path.clone()), + Some(was) if was != stamp => out.changed.push(format!( + "{path}\n was {was:?}\n now {stamp:?}" + )), + Some(_) => {} + } + } + for path in before.keys() { + if !after.contains_key(path) { + out.removed.push(path.clone()); + } + } + out +} + +fn assert_untouched(label: &str, before: &Snapshot, after: &Snapshot) { + let d = diff(before, after); + assert!( + d.is_empty(), + "{label} touched the folder\n added: {:?}\n removed: {:?}\n changed:\n {}", + d.added, + d.removed, + d.changed.join("\n ") + ); + println!(" [{label}] {} paths, none touched", before.len()); +} + +fn assert_clean(label: &str) { + let status = git_status(); + println!(" [{label}] git status --porcelain:\n{}", quoted(&status)); + assert!(status.is_empty(), "{label} left git dirty:\n{status}"); +} + +fn quoted(status: &str) -> String { + if status.is_empty() { + " ".to_string() + } else { + status + .lines() + .map(|line| format!(" {line}")) + .collect::>() + .join("\n") + } +} + +// ---------------------------------------------------------------- tree helpers + +fn flatten(node: &FileNode, into: &mut Vec) { + into.push(node.path.clone()); + for child in &node.children { + flatten(child, into); + } +} + +fn all_paths(node: &FileNode) -> Vec { + let mut out = Vec::new(); + flatten(node, &mut out); + out +} + +fn child_names(node: &FileNode) -> Vec { + node.children.iter().map(|c| c.name.clone()).collect() +} + +/// What `file_read` does, minus the Tauri `State`: the same path gate, then the same read. +fn file_read(root: &Path, path: &str) -> Result { + let roots = vec![root.to_string_lossy().into_owned()]; + let checked = resolve_in_roots(&roots, path)?; + read_document(&checked).map(|r| r.text) +} + +// ================================================================ 1. scanning + +#[test] +fn scanning_the_tree_writes_nothing() { + let root = pristine(); + + let before = snapshot(&root); + let started = Instant::now(); + let tree = scan_tree(&root, false).expect("a tree"); + let took = started.elapsed(); + let after = snapshot(&root); + + println!("scan_tree: {} rows in {took:?}", all_paths(&tree).len()); + println!("root children: {:?}", child_names(&tree)); + assert_untouched("scan_tree", &before, &after); + assert_clean("scan_tree"); + + // The root id is derived from the path, never persisted into the folder. + println!("root id: {}", root_id_for(&root.to_string_lossy())); +} + +// ================================================================ 2. reading + +#[test] +fn reading_every_document_writes_nothing() { + let root = pristine(); + let tree = scan_tree(&root, false).expect("a tree"); + + let mut docs: Vec = Vec::new(); + collect_editable(&tree, &mut docs); + assert!(docs.len() >= 10, "expected a realistic docs folder, got {docs:?}"); + + let before = snapshot(&root); + let mut bytes = 0usize; + for path in &docs { + let text = file_read(&root, path).unwrap_or_else(|e| panic!("read {path}: {e}")); + bytes += text.len(); + } + let after = snapshot(&root); + + println!("read {} documents, {bytes} bytes", docs.len()); + assert_untouched("file_read x N", &before, &after); + assert_clean("file_read x N"); +} + +fn collect_editable(node: &FileNode, into: &mut Vec) { + if node.editable { + into.push(node.path.clone()); + } + for child in &node.children { + collect_editable(child, into); + } +} + +// ================================================================ 3. open then close + +#[test] +fn opening_a_document_and_closing_it_writes_nothing() { + let root = pristine(); + let doc = root.join("docs/architecture.md"); + let path = doc.to_string_lossy().into_owned(); + + let before = snapshot(&root); + + // Open: the store's `open` calls `loadDocument`, which is one `file_read` and nothing else. + let text = file_read(&root, &path).expect("the document"); + assert!(!text.is_empty()); + // Close: `useDocument.close()` flushes only when the buffer is dirty, and it is not. Nothing + // reaches the backend at all, so there is nothing to call here. That absence is the test. + let after = snapshot(&root); + + assert_untouched("open then close", &before, &after); + assert_clean("open then close"); +} + +// ================================================================ 4. the watcher + +#[test] +fn the_watcher_does_not_cause_a_write() { + let root = pristine(); + let (tx, rx) = mpsc::channel::(); + + let before = snapshot(&root); + let watcher = spawn_watcher( + "root-1".to_string(), + root.to_string_lossy().into_owned(), + move |events| { + for event in events { + tx.send(event).ok(); + } + }, + ) + .expect("a watcher"); + + // Long enough for a watcher that was going to write something to have written it: FSEvents + // establishes its stream, the debouncer ticks every 75ms, and 3s is ten times its window. + std::thread::sleep(Duration::from_secs(3)); + + // And a full read pass while the watch is live, which is the shape of real use. + let tree = scan_tree(&root, false).expect("a tree"); + let mut docs = Vec::new(); + collect_editable(&tree, &mut docs); + for path in &docs { + file_read(&root, path).expect("a read"); + } + std::thread::sleep(Duration::from_secs(2)); + + let after = snapshot(&root); + drop(watcher); + + let events: Vec = rx.try_iter().collect(); + println!( + "watcher ran 5s over {} paths and emitted {} events: {:?}", + before.len(), + events.len(), + events + .iter() + .map(|e| format!("{} {}", e.kind, e.path)) + .collect::>() + ); + assert!( + events.is_empty(), + "the watcher reported changes when nothing changed" + ); + assert_untouched("watcher idle + reads", &before, &after); + assert_clean("watcher idle + reads"); +} + +// ================================================================ 5. node_modules + +#[test] +fn node_modules_is_skipped_rather_than_walked() { + let root = pristine(); + let junk = root.join("node_modules"); + let junk_files = snapshot(&junk).len(); + assert!(junk_files > 5_000, "the fixture needs a real node_modules"); + + let started = Instant::now(); + let skipped = scan_tree(&root, false).expect("a tree"); + let skipped_ms = started.elapsed(); + let skipped_rows = all_paths(&skipped).len(); + + let started = Instant::now(); + let walked = scan_tree(&root, true).expect("a tree"); + let walked_ms = started.elapsed(); + let walked_rows = all_paths(&walked).len(); + + let started = Instant::now(); + let alone = scan_tree(&junk, true).expect("a tree"); + let alone_ms = started.elapsed(); + + println!("node_modules holds {junk_files} entries"); + println!("scan_tree(show_ignored=false): {skipped_rows} rows in {skipped_ms:?}"); + println!("scan_tree(show_ignored=true): {walked_rows} rows in {walked_ms:?}"); + println!( + "scan_tree(node_modules alone): {} rows in {alone_ms:?}", + all_paths(&alone).len() + ); + + assert!( + !child_names(&skipped).contains(&"node_modules".to_string()), + "node_modules is in the tree: {:?}", + child_names(&skipped) + ); + assert!( + !all_paths(&skipped) + .iter() + .any(|p| p.contains("node_modules")), + "something under node_modules reached the tree" + ); + assert!( + skipped_rows < 40, + "the skipped scan returned {skipped_rows} rows, so it walked more than the documents" + ); + assert!( + child_names(&walked).contains(&"node_modules".to_string()), + "show_ignored did not turn the skip off" + ); + assert!( + walked_rows > 10_000, + "show_ignored=true only found {walked_rows} rows" + ); + // Skipping is not merely a filter over a walk that happened anyway. + assert!( + skipped_ms * 10 < walked_ms, + "skipping node_modules ({skipped_ms:?}) was not much faster than walking it ({walked_ms:?})" + ); + // And walking it, when asked to, is not absurd either. + assert!( + walked_ms < Duration::from_secs(20), + "walking node_modules took {walked_ms:?}" + ); + + let after = snapshot(&root); + assert_eq!(after.len(), snapshot(&root).len()); + assert_clean("three scans"); +} + +// ================================================================ 6. one write, one file + +#[test] +fn a_write_to_one_file_leaves_every_other_file_alone() { + let root = pristine(); + let target = root.join("docs/architecture.md"); + + let read = read_document(&target).expect("the document"); + let before = snapshot(&root); + + let result = write_document(&target, &format!("{}\n\nAn edit.\n", read.text), Some(read.modified_ms)) + .expect("the write"); + assert!(!result.conflict); + + let after = snapshot(&root); + let d = diff(&before, &after); + + println!("added: {:?}", d.added); + println!("removed: {:?}", d.removed); + println!( + "changed: {:?}", + d.changed + .iter() + .map(|c| c.lines().next().unwrap_or_default().to_string()) + .collect::>() + ); + + let status = git_status(); + println!("git status --porcelain after one write:\n{}", quoted(&status)); + + // .git moves because git status re-reads it; that is git's doing, not the editor's. + let touched: Vec<&String> = d + .changed + .iter() + .filter(|c| !c.starts_with(".git/") && !c.starts_with(".git\n")) + .collect(); + assert!(d.added.is_empty(), "the write added files: {:?}", d.added); + assert!( + d.removed.is_empty(), + "the write removed files: {:?}", + d.removed + ); + assert_eq!( + touched.len(), + 2, + "expected only docs/architecture.md and its parent dir to move, got:\n{}", + d.changed.join("\n") + ); + assert_eq!( + status.lines().collect::>(), + vec![" M docs/architecture.md"], + "one edit should be one line of git status" + ); +} + +// ================================================================ 7. the .bak + +#[test] +fn a_write_leaves_no_bak_behind_once_it_has_finished() { + let root = pristine(); + let target = root.join("docs/guides/mobile.md"); + let read = read_document(&target).expect("the document"); + + write_document(&target, &format!("{}\nedited\n", read.text), Some(read.modified_ms)) + .expect("the write"); + + let leftovers: Vec = snapshot(&root) + .keys() + .filter(|p| !p.starts_with(".git/")) + .filter(|p| p.ends_with(".bak") || p.ends_with(".tmp")) + .cloned() + .collect(); + println!("*.bak and *.tmp under the root after a completed write: {leftovers:?}"); + + // The two the fixture committed on purpose are the user's own and must still be there. + assert!(leftovers.contains(&"docs/design.md.bak".to_string())); + assert!(leftovers.contains(&"docs/conventions.md.tmp".to_string())); + assert_eq!( + leftovers.len(), + 2, + "the write left a backup or temp file behind: {leftovers:?}" + ); + println!("git status --porcelain:\n{}", quoted(&git_status())); +} + +/// Nothing visible may appear inside the user's folder while a write is running, because git, +/// Dropbox, a backup tool and the app's own watcher are all looking at that folder too. +#[test] +fn a_save_puts_nothing_visible_in_the_folder_even_for_an_instant() { + let root = pristine(); + let target = root.join("docs/big.md"); + // Big enough that the copy-then-fsync window is wide enough to observe. Untracked, and removed + // at the end of the test. + let payload = "x".repeat(48 * 1024 * 1024); + fs::write(&target, &payload).expect("the fixture document"); + + // This test used to assert the opposite. It was written to prove the bug that a save put a + // `docs/big.md.bak` and a `docs/big.md.tmp` inside the user's folder, where git, a backup tool + // or another editor would see them, and where a crash stranded them. The `.bak` is gone and the + // temp file is now hidden and uniquely named, so the same window is now the proof of the fix. + let docs = root.join("docs"); + let before: Vec = fs::read_dir(&docs) + .expect("the docs folder") + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(); + let sightings = Arc::new(Mutex::new(Vec::::new())); + + let watching = sightings.clone(); + let (stop_tx, stop_rx) = mpsc::channel::<()>(); + let poller = std::thread::spawn(move || { + while stop_rx.try_recv().is_err() { + let Ok(entries) = fs::read_dir(&docs) else { continue }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if name == "big.md" { + continue; + } + let mut seen = watching.lock().unwrap(); + if !seen.contains(&name) { + seen.push(name); + } + } + } + }); + + let second = format!("{payload}y"); + write_document(&target, &second, None).expect("the write"); + stop_tx.send(()).ok(); + poller.join().ok(); + + let seen = sightings.lock().unwrap().clone(); + println!("seen inside docs/ during one save: {seen:?}"); + println!("git status --porcelain after the save:\n{}", quoted(&git_status())); + + fs::remove_file(&target).ok(); + + // Diff against what was there before rather than an allowlist, because two of the files that + // survive a save are the planted user owned `design.md.bak` and `conventions.md.tmp`, and + // hardcoding those would mean this test stopped noticing if a save started creating them. + let from_the_save: Vec<&String> = seen.iter().filter(|name| !before.contains(*name)).collect(); + + assert!( + from_the_save.iter().all(|name| name.starts_with('.')), + "a save must not put a visible file in the user's folder, even for an instant: {from_the_save:?}" + ); + assert!( + !from_the_save.iter().any(|name| name.ends_with(".bak")), + "the .bak rotation is gone and must stay gone: {from_the_save:?}" + ); + assert!( + from_the_save.iter().all(|name| !name.starts_with(".big.md.tmp")), + "the temp name must carry the pid and a stamp so two saves cannot collide: {from_the_save:?}" + ); +} + +/// The same window, but seen through the app's own watcher: does a `.bak` path reach the frontend? +#[test] +fn the_watcher_reports_the_bak_and_tmp_to_the_frontend() { + let root = pristine(); + let (tx, rx) = mpsc::channel::(); + let watcher = spawn_watcher( + "root-1".to_string(), + root.to_string_lossy().into_owned(), + move |events| { + for event in events { + tx.send(event).ok(); + } + }, + ) + .expect("a watcher"); + std::thread::sleep(Duration::from_secs(2)); + while rx.try_recv().is_ok() {} + + let target = root.join("docs/design.md"); + let read = read_document(&target).expect("the document"); + write_document(&target, &format!("{}\nedited\n", read.text), Some(read.modified_ms)) + .expect("the write"); + + std::thread::sleep(Duration::from_secs(3)); + drop(watcher); + + let events: Vec = rx + .try_iter() + .map(|e| format!("{} {}", e.kind, e.path)) + .collect(); + println!("watch events for one save of docs/design.md:"); + for event in &events { + println!(" {event}"); + } + println!("git status --porcelain:\n{}", quoted(&git_status())); + + // Not an assertion about what is right, just a record of what the frontend is handed. + let noise: Vec<&String> = events + .iter() + .filter(|e| e.ends_with(".bak") || e.ends_with(".tmp")) + .collect(); + println!("of which temp/backup paths: {noise:?}"); +} + +// ================================================================ 8. collateral + +/// A write to `x.md` unlinks `x.md.bak` and `x.md.tmp` if the user happens to have files by those +/// names. Neither is a file the user edited, and neither goes to the Trash. +#[test] +fn a_write_deletes_a_sibling_bak_the_user_owns() { + let root = pristine(); + + let bak = root.join("docs/design.md.bak"); + let tmp = root.join("docs/conventions.md.tmp"); + let bak_text = fs::read_to_string(&bak).expect("the user's own backup"); + let tmp_text = fs::read_to_string(&tmp).expect("the user's own scratch file"); + println!("before: docs/design.md.bak = {bak_text:?}"); + println!("before: docs/conventions.md.tmp = {tmp_text:?}"); + println!("both are tracked: {}", git(&["ls-files", "docs/"]).replace('\n', " ")); + + let design = root.join("docs/design.md"); + let read = read_document(&design).expect("the document"); + write_document(&design, &format!("{}\nedited\n", read.text), Some(read.modified_ms)) + .expect("the write"); + + let conventions = root.join("docs/conventions.md"); + let read = read_document(&conventions).expect("the document"); + write_document( + &conventions, + &format!("{}\nedited\n", read.text), + Some(read.modified_ms), + ) + .expect("the write"); + + let status = git_status(); + println!("git status --porcelain after editing design.md and conventions.md:\n{}", quoted(&status)); + + let bak_gone = !bak.exists(); + let tmp_gone = !tmp.exists(); + println!("docs/design.md.bak still there: {}", !bak_gone); + println!("docs/conventions.md.tmp still there: {}", !tmp_gone); + + assert!( + !bak_gone && !tmp_gone, + "editing design.md and conventions.md deleted files the user never opened:\n{status}" + ); +} + +// ================================================================ 9. is it really skipped? + +/// Timing says node_modules is skipped rather than filtered after the fact. Taking away the right +/// to read it says so without a stopwatch: a walk that entered it would come back with rows from +/// it, or with an error, and this one comes back with neither. +#[test] +fn node_modules_is_not_entered_at_all() { + let root = pristine(); + let junk = root.join("node_modules"); + + let locked = fs::Permissions::from_mode(0o000); + let open = fs::metadata(&junk).expect("node_modules").permissions(); + fs::set_permissions(&junk, locked).expect("lock node_modules"); + + let started = Instant::now(); + let tree = scan_tree(&root, false); + let took = started.elapsed(); + + fs::set_permissions(&junk, open).expect("unlock node_modules"); + + let tree = tree.expect("an unreadable node_modules must not fail the scan"); + let rows = all_paths(&tree); + println!("scan with node_modules at mode 000: {} rows in {took:?}", rows.len()); + println!("root children: {:?}", child_names(&tree)); + assert!(!rows.iter().any(|p| p.contains("node_modules"))); + assert_eq!(rows.len(), 21); + assert_clean("scan with node_modules locked"); +} + +use std::os::unix::fs::PermissionsExt; + +// ================================================================ 10. atime + +/// A read is not a write, but it does bump the access time on some volumes. Recorded rather than +/// asserted: the question is whether anything about the file the user can see has changed, and +/// mtime, ctime, size and bytes are what that means. +#[test] +fn what_a_read_actually_changes() { + let root = pristine(); + let doc = root.join("docs/architecture.md"); + + let before = fs::metadata(&doc).expect("the document"); + std::thread::sleep(Duration::from_millis(50)); + let _ = file_read(&root, &doc.to_string_lossy()).expect("the read"); + let after = fs::metadata(&doc).expect("the document"); + + println!( + "atime {}.{:09} -> {}.{:09} ({})", + before.atime(), + before.atime_nsec(), + after.atime(), + after.atime_nsec(), + if (before.atime(), before.atime_nsec()) == (after.atime(), after.atime_nsec()) { + "unchanged" + } else { + "bumped by the read" + } + ); + assert_eq!(before.mtime_nsec(), after.mtime_nsec()); + assert_eq!(before.ctime_nsec(), after.ctime_nsec()); + assert_eq!(before.len(), after.len()); + assert_clean("one read"); +} + +// ================================================================ 11. two saves at once + +/// The temp file and the backup are named after the target and nothing else, so two writes to one +/// document race on the same two paths, and the loser of the race can take the document with it. +/// A debounced autosave landing while Cmd+S is in flight is exactly that shape. +#[test] +fn concurrent_saves_of_one_document() { + const TRIALS: usize = 15; + const PAYLOAD: usize = 2 * 1024 * 1024; + + let mut worst: Vec = Vec::new(); + for writers in [2usize, 3, 4, 8] { + let mut lost = 0; + let mut torn = 0; + let mut leftovers: Vec = Vec::new(); + let mut first_status = String::new(); + + for _ in 0..TRIALS { + let root = pristine(); + let doc = root.join("docs/guides/setup.md"); + let seen = read_document(&doc).expect("the document").modified_ms; + + // Every save carries the same expected mtime, because in the app they all come from the + // same read: the conflict guard lets all of them through. + let payloads: Vec = (0..writers) + .map(|n| format!("save number {n}\n{}\n", "z".repeat(PAYLOAD))) + .collect(); + let handles: Vec<_> = payloads + .iter() + .cloned() + .map(|text| { + let doc = doc.clone(); + std::thread::spawn(move || write_document(&doc, &text, Some(seen)).err()) + }) + .collect(); + let _errors: Vec = handles + .into_iter() + .filter_map(|h| h.join().expect("a thread")) + .collect(); + + match fs::read_to_string(&doc).ok() { + None => { + lost += 1; + if first_status.is_empty() { + first_status = git_status(); + } + } + Some(text) if !payloads.contains(&text) => torn += 1, + Some(_) => {} + } + for path in snapshot(&root).keys() { + if path.starts_with(".git/") { + continue; + } + let owned = path == "docs/conventions.md.tmp" || path == "docs/design.md.bak"; + if (path.ends_with(".bak") || path.ends_with(".tmp")) && !owned { + leftovers.push(path.clone()); + } + } + } + + println!( + "{writers} concurrent saves x {TRIALS} trials: {lost} destroyed the document, {torn} left it torn, leftovers {leftovers:?}" + ); + if !first_status.is_empty() { + println!(" git status --porcelain from a trial that lost it:\n{}", quoted(&first_status)); + } + if lost > 0 || torn > 0 || !leftovers.is_empty() { + worst.push(format!( + "{writers} writers: {lost} destroyed, {torn} torn, leftovers {leftovers:?}" + )); + } + } + + pristine(); + assert!( + worst.is_empty(), + "concurrent saves of one document are not safe:\n {}", + worst.join("\n ") + ); +} + +/// Four writers is more overlap than the app is likely to produce. Two is not: an autosave whose +/// write is still in flight and a Cmd+S is two. This walks the second save's start across the +/// first save's whole duration looking for the interleaving, rather than hoping to land on it. +/// It has not found one here, which bounds how likely the two-writer case is without making it +/// safe: the destructive window is between two adjacent renames and is sub-microsecond wide. +#[test] +fn two_concurrent_saves_across_the_whole_window() { + const PAYLOAD: usize = 8 * 1024 * 1024; + let mut losses = Vec::new(); + + for delay_us in (0..40_000u64).step_by(500) { + let root = pristine(); + let doc = root.join("docs/guides/setup.md"); + let seen = read_document(&doc).expect("the document").modified_ms; + + let big = format!("the autosave\n{}\n", "z".repeat(PAYLOAD)); + let small = "the Cmd+S\n".to_string(); + + let first = { + let doc = doc.clone(); + std::thread::spawn(move || write_document(&doc, &big, Some(seen)).err()) + }; + let second = { + let doc = doc.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_micros(delay_us)); + write_document(&doc, &small, Some(seen)).err() + }) + }; + let a = first.join().expect("a thread"); + let b = second.join().expect("a thread"); + + if !doc.exists() { + losses.push(format!( + "second save started {delay_us}us in: document destroyed; errors {a:?} / {b:?}; git status {:?}", + git_status().trim().to_string() + )); + if losses.len() >= 3 { + break; + } + } + } + + for loss in &losses { + println!(" {loss}"); + } + println!("{} of the offsets tried destroyed the document", losses.len()); + pristine(); + assert!(losses.is_empty(), "two concurrent saves destroyed the document"); +} + +// ================================================================================================ +// Re-verification. Everything below was added to break the four fixes, not to confirm them. +// ================================================================================================ + +/// Every path under `dir` right now, as a sorted list of names. Used by the pollers, so it has to +/// be as cheap as a `read_dir` and nothing more. +fn names_in(dir: &Path) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + let mut out: Vec = entries + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + out.sort(); + out +} + +fn stamp_of(path: &Path) -> Option { + let meta = fs::symlink_metadata(path).ok()?; + Some(Stamp { + kind: if meta.is_dir() { "dir" } else { "file" }, + len: meta.len(), + mtime: (meta.mtime(), meta.mtime_nsec()), + ctime: (meta.ctime(), meta.ctime_nsec()), + ino: meta.ino(), + mode: meta.mode(), + nlink: meta.nlink(), + hash: fs::read(path).ok().map(|b| fnv1a(&b)), + }) +} + +/// Files a user might plausibly own that share a stem with a document the tests save. The last two +/// are the shape the app's own temp file takes, planted deliberately: a save that reasons about +/// "its" temp file by name rather than by having created it would unlink these. +const PLANTED: [&str; 8] = [ + "docs/architecture.md.bak", + "docs/architecture.md.tmp", + "docs/guides/setup.md.bak", + "docs/guides/setup.md.tmp", + "docs/guides/mobile.md~", + "docs/design.md.bak.bak", + "docs/.architecture.md.tmp", + "docs/.architecture.md.4242-0-1a2b3c4d.tmp", +]; + +fn plant(root: &Path) -> BTreeMap { + let mut out = BTreeMap::new(); + for rel in PLANTED { + let path = root.join(rel); + fs::write(&path, format!("the user's own file: {rel}\n")).expect("plant"); + out.insert(rel.to_string(), stamp_of(&path).expect("a stamp")); + } + out +} + +// ---------------------------------------------------------------- 12. the user's own siblings + +/// Claim 1. A save must not touch a `.bak` or `.tmp` sibling the user owns, and must not touch one +/// that happens to be shaped exactly like the app's own temp file either. +#[test] +fn saving_leaves_every_user_owned_sibling_byte_for_byte() { + let root = pristine(); + + // The two the fixture commits, so a deletion is a line of `git status` and not just an absence. + let tracked = ["docs/design.md.bak", "docs/conventions.md.tmp"]; + let tracked_before: Vec> = + tracked.iter().map(|r| stamp_of(&root.join(r))).collect(); + let planted_before = plant(&root); + + let tree = scan_tree(&root, false).expect("a tree"); + let mut docs = Vec::new(); + collect_editable(&tree, &mut docs); + assert!(docs.len() >= 10, "expected a realistic docs folder: {docs:?}"); + + // Three rounds, because a rotation that keeps one generation only shows up on the second save. + for round in 0..3 { + for path in &docs { + let read = read_document(Path::new(path)).expect("the document"); + write_document( + Path::new(path), + &format!("{}\nround {round}\n", read.text), + Some(read.modified_ms), + ) + .expect("the write"); + } + } + + let status = git_status(); + println!("git status --porcelain after 3 saves of all {} documents:\n{}", docs.len(), quoted(&status)); + + let mut damaged = Vec::new(); + for (rel, was) in &planted_before { + match stamp_of(&root.join(rel)) { + None => damaged.push(format!("{rel}: GONE")), + Some(now) if &now != was => { + damaged.push(format!("{rel}: changed\n was {was:?}\n now {now:?}")) + } + Some(_) => println!(" untouched: {rel}"), + } + } + for (rel, was) in tracked.iter().zip(tracked_before.iter()) { + let now = stamp_of(&root.join(rel)); + if &now != was { + damaged.push(format!("{rel}: changed\n was {was:?}\n now {now:?}")); + } else { + println!(" untouched: {rel}"); + } + } + + // A deletion of a tracked file is a ` D` line. Nothing but ` M` on documents is allowed. + let unexpected: Vec<&str> = status + .lines() + .filter(|line| !line.starts_with(" M ") && !line.starts_with("?? ")) + .collect(); + + for rel in PLANTED { + fs::remove_file(root.join(rel)).ok(); + } + pristine(); + + assert!(damaged.is_empty(), "a save touched files the user owns:\n {}", damaged.join("\n ")); + assert!( + unexpected.is_empty(), + "git status shows something other than modified documents: {unexpected:?}" + ); +} + +// ---------------------------------------------------------------- 13. what is there mid save + +/// Claim 2. Poll the folder as hard as a thread can while a save runs, and record every name that +/// ever appears. Then say whether what appeared is hidden, unique per save, and gone afterwards. +#[test] +fn every_path_that_appears_in_the_folder_during_a_save() { + let root = pristine(); + let dir = root.join("docs"); + let target = dir.join("big.md"); + // Big enough that the copy and the fsync are wide open to a poller. Untracked; removed below. + let payload = "x".repeat(64 * 1024 * 1024); + fs::write(&target, &payload).expect("the fixture document"); + + let quiet = names_in(&dir); + println!("docs/ before any save: {quiet:?}"); + + let mut per_save: Vec> = Vec::new(); + for save in 0..3 { + let seen = Arc::new(Mutex::new(Vec::::new())); + let watching = seen.clone(); + let polling = dir.clone(); + let baseline = quiet.clone(); + let (stop_tx, stop_rx) = mpsc::channel::<()>(); + let poller = std::thread::spawn(move || { + let mut passes = 0u64; + while stop_rx.try_recv().is_err() { + for name in names_in(&polling) { + if !baseline.contains(&name) { + let mut got = watching.lock().unwrap(); + if !got.contains(&name) { + got.push(name); + } + } + } + passes += 1; + } + passes + }); + + let text = format!("{payload}\nsave {save}\n"); + write_document(&target, &text, None).expect("the write"); + stop_tx.send(()).ok(); + let passes = poller.join().expect("the poller"); + + let mut got = seen.lock().unwrap().clone(); + got.retain(|n| n != "big.md"); + println!("save {save}: {passes} polling passes, appeared in docs/: {got:?}"); + per_save.push(got); + + let after = names_in(&dir); + let residue: Vec<&String> = after.iter().filter(|n| !quiet.contains(n) && *n != "big.md").collect(); + assert!(residue.is_empty(), "save {save} left something in docs/: {residue:?}"); + } + + println!("git status --porcelain after three saves:\n{}", quoted(&git_status())); + fs::remove_file(&target).ok(); + + let all: Vec<&String> = per_save.iter().flatten().collect(); + assert!(!all.is_empty(), "the poller never caught the write in flight; the test proves nothing"); + for name in &all { + assert!(name.starts_with('.'), "the save created a visible file: {name}"); + assert!(name.ends_with(".tmp"), "the save created a name the watcher's transient rule misses: {name}"); + assert!( + name.contains(&format!(".{}", std::process::id())), + "the temp name does not carry the pid, so two processes could collide: {name}" + ); + } + let unique: std::collections::BTreeSet<&&String> = all.iter().collect(); + assert_eq!(unique.len(), all.len(), "two saves used the same temp name: {all:?}"); + pristine(); +} + +/// Claim 2, the failure path. The folder is made unwritable while the save is in flight, so the +/// rename cannot land. What is left behind, and is the document still whole? +/// +/// The clamp is aimed the same way the kill below is: it waits for the save's own temp file to +/// appear and takes the write bit off the folder the instant it sees it, so the failure lands +/// between the temp file and the rename rather than wherever a sleep happened to fall. +#[test] +fn what_a_failed_save_leaves_behind() { + let root = pristine(); + let dir = root.join("docs"); + let target = dir.join("big.md"); + let payload = "x".repeat(96 * 1024 * 1024); + let open = fs::metadata(&dir).expect("docs").permissions(); + + let mut attempt = 0; + let (outcome, before, before_hash, residue) = loop { + attempt += 1; + fs::set_permissions(&dir, open.clone()).ok(); + fs::write(&target, &payload).expect("the fixture document"); + let before = fs::read(&target).expect("the document"); + let before_hash = fnv1a(&before); + let quiet = names_in(&dir); + + let locking = dir.clone(); + let clamp = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(30); + while Instant::now() < deadline { + if names_in(&locking).iter().any(|n| !quiet.contains(n)) { + break; + } + } + fs::set_permissions(&locking, fs::Permissions::from_mode(0o500)).ok(); + quiet + }); + + let outcome = write_document(&target, &format!("{payload}\nthe save that fails\n"), None); + let quiet = clamp.join().expect("the clamp"); + fs::set_permissions(&dir, open.clone()).ok(); + + let residue: Vec = names_in(&dir) + .into_iter() + .filter(|n| !quiet.contains(n) && n != "big.md") + .collect(); + if outcome.is_err() || attempt >= 8 { + break (outcome, before.len(), before_hash, residue); + } + println!("attempt {attempt}: the save beat the clamp, trying again"); + for name in &residue { + fs::remove_file(dir.join(name)).ok(); + } + }; + + let after = fs::read(&target).ok(); + println!("the save returned: {outcome:?}"); + println!("the document is still there: {}", after.is_some()); + println!( + "its bytes: {} -> {}, hash {} -> {}", + before, + after.as_ref().map(|b| b.len()).unwrap_or(0), + before_hash, + after.as_ref().map(|b| fnv1a(b)).unwrap_or(0) + ); + println!("left in docs/ after the failure: {residue:?}"); + println!("git status --porcelain:\n{}", quoted(&git_status())); + + for name in &residue { + fs::remove_file(dir.join(name)).ok(); + } + fs::remove_file(&target).ok(); + + assert!(outcome.is_err(), "the clamp never managed to make a save fail: {outcome:?}"); + let after = after.expect("the document must survive a failed save"); + assert_eq!(after.len(), before, "a failed save truncated the document"); + assert_eq!(fnv1a(&after), before_hash, "a failed save changed the document"); + for name in &residue { + assert!(name.starts_with('.') && name.ends_with(".tmp"), + "a failed save left something that is not a hidden temp file: {name}"); + } + pristine(); +} + +// ---------------------------------------------------------------- 14. killed mid write + +/// Re-exec of this same binary, used as the victim of the SIGKILL below. A no-op when the +/// environment does not ask for it, which is every normal run of the suite. +#[test] +fn the_child_writer() { + let Ok(target) = std::env::var("MARGIN_KILL_TARGET") else { + return; + }; + let megabytes: usize = std::env::var("MARGIN_KILL_MB") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(512); + let text = format!("the interrupted save\n{}\n", "q".repeat(megabytes * 1024 * 1024)); + let _ = write_document(Path::new(&target), &text, None); + println!("the child finished its write, which the parent did not want"); +} + +/// Claim 3. SIGKILL in the middle of a real save of a real document. The document must be whole +/// afterwards, at the old bytes or the new ones and never in between. +/// +/// The kill is aimed rather than timed: the parent watches the folder for the save's own temp file +/// to appear, which is proof the write is under way, and fires a fixed number of microseconds after +/// that. A round where the child finished first is not a round, and the test says so. +#[test] +fn killing_the_process_mid_write_leaves_the_document_whole() { + let root = pristine(); + let dir = root.join("docs"); + let target = dir.join("architecture.md"); + let before = fs::read(&target).expect("the document"); + let quiet = names_in(&dir); + const MB: usize = 512; + + let mut orphans: Vec = Vec::new(); + let mut survived = 0; + let mut killed = 0; + let mut missed = 0; + + for after_us in [0u64, 200, 1_000, 5_000, 20_000, 80_000, 250_000, 600_000] { + let mut child = Command::new(std::env::current_exe().expect("this binary")) + .args(["--exact", "the_child_writer", "--nocapture"]) + .env("MARGIN_KILL_TARGET", &target) + .env("MARGIN_KILL_MB", MB.to_string()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("the child"); + + // Wait for the write to actually be in flight: its temp file is the only signal, and it is + // created before a single byte of the new content is written. + let deadline = Instant::now() + Duration::from_secs(30); + let mut mid: Vec = Vec::new(); + while Instant::now() < deadline { + mid = names_in(&dir) + .into_iter() + .filter(|n| !quiet.contains(n)) + .collect(); + if !mid.is_empty() { + break; + } + if child.try_wait().ok().flatten().is_some() { + break; + } + } + std::thread::sleep(Duration::from_micros(after_us)); + + let raced = child.try_wait().ok().flatten().is_some(); + child.kill().ok(); + let status = child.wait().expect("the child exits"); + if raced { + missed += 1; + } else { + killed += 1; + } + + let now = fs::read(&target).expect("the document must still be there"); + let new_whole = now.starts_with(b"the interrupted save\n") && now.len() == MB * 1024 * 1024 + 22; + let whole = now == before || new_whole; + if whole && !raced { + survived += 1; + } + println!( + "kill {after_us}us after the temp file appeared: child {status:?}{}, \ + saw {mid:?} in flight, document {} bytes, {}", + if raced { " (finished first, not a kill)" } else { "" }, + now.len(), + if now == before { "the old content" } else if new_whole { "the new content" } else { "TORN" } + ); + assert!(whole, "the document is neither the old content nor the new one"); + + let left: Vec = names_in(&dir) + .into_iter() + .filter(|n| !quiet.contains(n)) + .collect(); + for name in &left { + if !orphans.contains(name) { + orphans.push(name.clone()); + } + fs::remove_file(dir.join(name)).ok(); + } + // Back to the old bytes for the next round. + fs::write(&target, &before).expect("reset"); + } + + assert!(killed >= 5, "only {killed} of the rounds actually caught a write in flight"); + println!("{survived}/{killed} real kills left the document whole ({missed} rounds the child won)"); + println!("orphans left by a killed save: {orphans:?}"); + println!("git status --porcelain (orphans already swept by this test):\n{}", quoted(&git_status())); + + assert_eq!(survived, killed, "a kill mid-write left the document neither old nor new"); + for name in &orphans { + assert!( + name.starts_with('.') && name.ends_with(".tmp"), + "a killed save left a visible file in the user's folder: {name}" + ); + } + pristine(); +} + +// ---------------------------------------------------------------- 15. concurrency, harder + +/// Claim 4. 2, 4, 8 and 16 writers against one document, 20 trials each. The document must come out +/// as exactly one writer's bytes: never missing, never truncated, never a mixture. +#[test] +fn concurrent_writers_two_four_eight_sixteen() { + const TRIALS: usize = 20; + + let mut broken: Vec = Vec::new(); + for writers in [2usize, 4, 8, 16] { + let mut lost = 0; + let mut torn = 0; + let mut short = 0; + let mut errors: Vec = Vec::new(); + let mut leftovers: Vec = Vec::new(); + let mut sizes: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut bad_status = String::new(); + + for trial in 0..TRIALS { + let root = pristine(); + let doc = root.join("docs/guides/setup.md"); + let seen = read_document(&doc).expect("the document").modified_ms; + + // Wildly different lengths, so a torn result cannot pass for a whole one by luck: a + // truncation of the 4 MiB writer is not equal to any other writer's payload. + let payloads: Vec = (0..writers) + .map(|n| { + let size = 64 * 1024 * (1 << (n % 7)); + format!("save number {n}\n{}\nend of save number {n}\n", "z".repeat(size)) + }) + .collect(); + + let gate = Arc::new(std::sync::Barrier::new(writers)); + let handles: Vec<_> = payloads + .iter() + .cloned() + .map(|text| { + let doc = doc.clone(); + let gate = gate.clone(); + std::thread::spawn(move || { + gate.wait(); + write_document(&doc, &text, Some(seen)).err() + }) + }) + .collect(); + for handle in handles { + if let Some(e) = handle.join().expect("a thread") { + errors.push(e); + } + } + + match fs::read_to_string(&doc).ok() { + None => { + lost += 1; + if bad_status.is_empty() { + bad_status = git_status(); + } + } + Some(text) if !payloads.contains(&text) => { + if payloads.iter().any(|p| p.starts_with(&text) || text.starts_with(p)) { + short += 1; + } else { + torn += 1; + } + if bad_status.is_empty() { + bad_status = git_status(); + } + println!(" trial {trial}: {} bytes, matching no writer", text.len()); + } + Some(text) => { + sizes.insert(text.len()); + } + } + + for path in snapshot(&root).keys() { + if path.starts_with(".git/") { + continue; + } + let owned = path == "docs/conventions.md.tmp" || path == "docs/design.md.bak"; + let sidecar = path.ends_with(".bak") || path.ends_with(".tmp") || path.contains("/."); + if sidecar && !owned && !leftovers.contains(path) { + leftovers.push(path.clone()); + } + } + } + + println!( + "{writers} writers x {TRIALS} trials: {lost} destroyed, {torn} torn, {short} truncated, \ + {} errors, winners had sizes {:?}, leftovers {leftovers:?}", + errors.len(), + sizes + ); + if !errors.is_empty() { + println!(" first error: {}", errors[0]); + } + if !bad_status.is_empty() { + println!(" git status --porcelain from a bad trial:\n{}", quoted(&bad_status)); + } + if lost > 0 || torn > 0 || short > 0 || !leftovers.is_empty() || !errors.is_empty() { + broken.push(format!( + "{writers} writers: {lost} destroyed, {torn} torn, {short} truncated, \ + {} errors, leftovers {leftovers:?}", + errors.len() + )); + } + } + + pristine(); + assert!( + broken.is_empty(), + "concurrent saves of one document are not safe:\n {}", + broken.join("\n ") + ); +} + +// ---------------------------------------------------------------- 16. the watcher stays quiet + +/// Claim 5. A save emits nothing to the frontend, for the document or for the temp file, and the +/// watcher is demonstrably alive while it does so: the same test makes a change the app did not +/// make and requires that one through. +#[test] +fn a_save_emits_no_watch_event_but_an_outside_change_does() { + let root = pristine(); + let (tx, rx) = mpsc::channel::(); + let watcher = spawn_watcher( + "root-1".to_string(), + root.to_string_lossy().into_owned(), + move |events| { + for event in events { + tx.send(event).ok(); + } + }, + ) + .expect("a watcher"); + std::thread::sleep(Duration::from_secs(2)); + while rx.try_recv().is_ok() {} + + // Every shape of write the app performs: an existing document, a run of quick saves, a brand + // new file, a document in a nested folder, and one big enough that the copy and the fsync take + // real time. + let existing = root.join("docs/design.md"); + let read = read_document(&existing).expect("the document"); + write_document(&existing, &format!("{}\nedited\n", read.text), Some(read.modified_ms)) + .expect("the write"); + + for n in 0..8 { + let read = read_document(&existing).expect("the document"); + write_document(&existing, &format!("{}\nautosave {n}\n", read.text), Some(read.modified_ms)) + .expect("the write"); + } + + let nested = root.join("docs/internals/website.md"); + let read = read_document(&nested).expect("the document"); + write_document(&nested, &format!("{}\nedited\n", read.text), Some(read.modified_ms)) + .expect("the write"); + + let fresh = root.join("docs/guides/brand-new.md"); + write_document(&fresh, "# new\n", None).expect("the write"); + + let big = root.join("docs/heavy.md"); + write_document(&big, &"h".repeat(48 * 1024 * 1024), None).expect("the write"); + + // At the root of the watched folder, where neither the hidden rule nor the transient rule can + // help: only `note_self_write` stands between this save and the frontend. + let top = root.join("README.md"); + let read = read_document(&top).expect("the document"); + write_document(&top, &format!("{}\nedited\n", read.text), Some(read.modified_ms)) + .expect("the write"); + + // Spelled through the /tmp symlink, which is how a path picked in a file dialog can reach the + // backend while the watcher is placed on the canonical /private/tmp form. + let aliased = PathBuf::from( + root.to_string_lossy() + .replacen("/private/tmp/", "/tmp/", 1), + ) + .join("notes.txt"); + let read = read_document(&aliased).expect("the document"); + write_document(&aliased, &format!("{}\nedited\n", read.text), Some(read.modified_ms)) + .expect("the write"); + + std::thread::sleep(Duration::from_secs(3)); + let ours: Vec = rx.try_iter().map(|e| format!("{} {}", e.kind, e.path)).collect(); + println!("watch events for {} saves the app made:", 14); + for event in &ours { + println!(" {event}"); + } + + // Now a change the app did not make, written straight to disk with no suppression, to prove the + // silence above is suppression and not a dead watcher. + let outside = root.join("docs/guides/release.md"); + let text = fs::read_to_string(&outside).expect("the document"); + fs::write(&outside, format!("{text}\nsomebody else edited this\n")).expect("the outside write"); + std::thread::sleep(Duration::from_secs(3)); + let theirs: Vec = rx.try_iter().map(|e| format!("{} {}", e.kind, e.path)).collect(); + println!("watch events for one change the app did not make:"); + for event in &theirs { + println!(" {event}"); + } + drop(watcher); + + println!("git status --porcelain:\n{}", quoted(&git_status())); + fs::remove_file(&fresh).ok(); + fs::remove_file(&big).ok(); + pristine(); + + assert!(ours.is_empty(), "the app's own saves reached the frontend as external changes: {ours:?}"); + assert!( + theirs.iter().any(|e| e.ends_with("docs/guides/release.md")), + "the watcher missed a change the app did not make, so the silence above proves nothing: {theirs:?}" + ); +} + +/// Claim 5, the wiring. `note_self_write` is called from production code, for both ends of the +/// rename, and not only from tests. +#[test] +fn note_self_write_is_called_from_production_code() { + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut callers: Vec = Vec::new(); + let mut stack = vec![src.clone()]; + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir).expect("src").flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + let text = fs::read_to_string(&path).expect("a source file"); + for (n, line) in text.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.starts_with("//") || trimmed.starts_with("///") { + continue; + } + if trimmed.contains("note_self_write(") && !trimmed.starts_with("pub fn") { + callers.push(format!( + "{}:{}: {trimmed}", + path.strip_prefix(&src).unwrap_or(&path).display(), + n + 1 + )); + } + } + } + } + println!("callers of note_self_write outside tests:"); + for caller in &callers { + println!(" {caller}"); + } + assert!( + !callers.is_empty(), + "note_self_write is dead code: nothing in src/ calls it" + ); + assert!( + callers.iter().any(|c| c.starts_with("fs.rs")), + "nothing in the write path calls note_self_write: {callers:?}" + ); + // Both ends of the rename: the target and the temp file. + assert!( + callers.iter().any(|c| c.contains("(&tmp)")), + "the temp file is not registered as a self write: {callers:?}" + ); + assert!( + callers.iter().any(|c| c.contains("(path)")), + "the document is not registered as a self write: {callers:?}" + ); +} + +/// Claim 2 again, on the failure path the app can actually clean up after: the folder stays +/// writable and the copy of the original is what fails. Nothing at all may be left behind. +#[test] +fn a_save_that_fails_where_it_can_tidy_up_leaves_nothing() { + let root = pristine(); + let dir = root.join("docs"); + let target = dir.join("architecture.md"); + let before = fs::read(&target).expect("the document"); + let quiet = names_in(&dir); + + let open = fs::metadata(&target).expect("the document").permissions(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o000)).expect("lock the document"); + let outcome = write_document(&target, "the save that cannot read the original\n", None); + fs::set_permissions(&target, open).expect("unlock the document"); + + let residue: Vec = names_in(&dir) + .into_iter() + .filter(|n| !quiet.contains(n)) + .collect(); + println!("the save returned: {outcome:?}"); + println!("left in docs/: {residue:?}"); + println!("git status --porcelain:\n{}", quoted(&git_status())); + + assert!(outcome.is_err(), "a save over an unreadable document reported success"); + assert!(residue.is_empty(), "a recoverable failure left something behind: {residue:?}"); + assert_eq!(fs::read(&target).expect("the document"), before, "the document changed"); + assert!(git_status().is_empty(), "a failed save left the repo dirty"); + pristine(); +} diff --git a/src-tauri/tests/watch.rs b/src-tauri/tests/watch.rs new file mode 100644 index 0000000..50fd078 --- /dev/null +++ b/src-tauri/tests/watch.rs @@ -0,0 +1,428 @@ +// The watcher against a real folder and real filesystem calls, because every interesting thing it +// does is a reaction to what the kernel actually reports rather than to what notify's documentation +// says it reports. FSEvents sets `ItemCreated` on every event it ever emits for a path, describes +// an atomic save as a rename with no relation to the file it replaced, and spells every path +// through /private. None of that is visible from the types. +// +// Waiting is done by writing a probe file and waiting for its event, not by sleeping. Batches are +// delivered oldest first, so the probe's event arriving is proof that everything caused before it +// has already been delivered, which is what makes "nothing was reported" a bounded assertion rather +// than a guess at how long to wait. The one deliberate sleep is in the fixture helper, where a file +// has to be older than the watcher's own idea of newly born for the test to mean anything. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +use margin_docs_lib::dto::WatchEvent; +use margin_docs_lib::fs::write_document; +use margin_docs_lib::watch::{note_self_write, spawn_watcher}; +use notify::RecommendedWatcher; +use notify_debouncer_full::{Debouncer, NoCache}; +use tempfile::TempDir; + +/// How long a test waits for the watcher to prove it is running before calling it broken. +const DEADLINE: Duration = Duration::from_secs(15); + +/// The debouncer holds an event for 300ms and ticks every quarter of that, so a batch that has not +/// arrived in this long is not on its way. +const QUIET: Duration = Duration::from_millis(900); + +/// Comfortably more than the 250ms within which the watcher counts a file as newly born, so that a +/// fixture written by the test is unambiguously a file that was already there. +const AGE: Duration = Duration::from_millis(600); + +const ROOT: &str = "root-1"; + +struct Harness { + /// Declared first so the watcher stops before the folder it is watching is deleted. + _watcher: Debouncer, + dir: TempDir, + rx: Receiver, + probes: AtomicU32, +} + +impl Harness { + fn path(&self, name: &str) -> PathBuf { + self.dir.path().join(name) + } + + /// Waits until the watcher is up and throws away whatever it has reported so far. + fn sync(&self) { + self.drain(); + } + + /// Everything reported up to a fresh probe file, the probe events themselves left out. + fn drain(&self) -> Vec { + let give_up = Instant::now() + DEADLINE; + let mut seen = Vec::new(); + loop { + let n = self.probes.fetch_add(1, Ordering::Relaxed); + let probe = self.path(&format!("probe-{n}.md")); + fs::write(&probe, format!("probe {n}\n")).unwrap(); + let want = probe.to_string_lossy().into_owned(); + loop { + match self.rx.recv_timeout(QUIET) { + Ok(event) if event.path == want => return seen, + Ok(event) => { + if !is_probe(&event.path) { + seen.push(event); + } + } + // Nothing is arriving at all, so the watcher was not yet up when the probe was + // written. Write another one. + Err(_) => break, + } + } + assert!( + Instant::now() < give_up, + "the watcher never reported anything" + ); + } + } + + fn events_for(&self, path: &Path) -> Vec { + let want = path.to_string_lossy().into_owned(); + self.drain() + .into_iter() + .filter(|event| event.path == want) + .collect() + } +} + +/// A watched folder holding `fixtures`, all of them old enough to count as files that were already +/// there when the watch started. +fn harness(fixtures: &[&str]) -> Harness { + let dir = tempfile::tempdir().unwrap(); + for name in fixtures { + let path = dir.path().join(name); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&path, format!("# {name}\n")).unwrap(); + } + if !fixtures.is_empty() { + std::thread::sleep(AGE); + } + + let (tx, rx) = mpsc::channel(); + let watcher = spawn_watcher( + ROOT.to_string(), + dir.path().to_string_lossy().into_owned(), + move |events| { + for event in events { + tx.send(event).ok(); + } + }, + ) + .unwrap(); + Harness { + _watcher: watcher, + dir, + rx, + probes: AtomicU32::new(0), + } +} + +fn is_probe(path: &str) -> bool { + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("probe-")) +} + +fn one<'a>(events: &'a [WatchEvent], what: &str) -> &'a WatchEvent { + assert_eq!(events.len(), 1, "{what}, got {events:?}"); + &events[0] +} + +#[test] +fn a_created_file_is_reported_created() { + let harness = harness(&[]); + harness.sync(); + + let note = harness.path("note.md"); + fs::write(¬e, "# Note\n").unwrap(); + + let events = harness.events_for(¬e); + let event = one(&events, "a new file is one event"); + assert_eq!(event.kind, "created"); + assert_eq!(event.root, ROOT); + assert_eq!(event.old_path, None); + assert_eq!(event.path, note.to_string_lossy()); +} + +#[test] +fn a_modified_file_is_reported_modified() { + let harness = harness(&["note.md"]); + let note = harness.path("note.md"); + harness.sync(); + + fs::write(¬e, "# Note\n\nA second paragraph.\n").unwrap(); + + let events = harness.events_for(¬e); + let event = one(&events, "a write to an existing file is one event"); + assert_eq!(event.kind, "modified"); + assert_eq!(event.old_path, None); +} + +#[test] +fn a_deleted_file_is_reported_removed() { + let harness = harness(&["note.md"]); + let note = harness.path("note.md"); + harness.sync(); + + fs::remove_file(¬e).unwrap(); + + let events = harness.events_for(¬e); + assert_eq!(one(&events, "a delete is one event").kind, "removed"); +} + +#[test] +fn a_self_write_is_reported_as_nothing() { + let harness = harness(&["note.md"]); + let note = harness.path("note.md"); + harness.sync(); + + note_self_write(¬e); + fs::write(¬e, "# Note\n\nWritten by the app itself.\n").unwrap(); + + let events = harness.events_for(¬e); + assert!(events.is_empty(), "the app's own write echoed: {events:?}"); +} + +#[test] +fn the_apps_own_atomic_save_is_reported_as_nothing() { + let harness = harness(&["note.md"]); + let note = harness.path("note.md"); + let temp = harness.path("note.md.tmp"); + harness.sync(); + + // Both halves have to be registered. The rename names the temp file as well as the document, + // and either name getting through would let the echo through with it. + note_self_write(&temp); + note_self_write(¬e); + fs::write(&temp, "# Note\n\nWritten by the app itself.\n").unwrap(); + fs::rename(&temp, ¬e).unwrap(); + + let events = harness.drain(); + assert!( + events.is_empty(), + "the app's own atomic save echoed: {events:?}" + ); +} + +/// The two tests above register the paths by hand, which proves the mechanism and not that anything +/// uses it. This one goes through the real write path: a save of a document reaches the frontend as +/// nothing at all, neither the document nor the temp file it went through. +#[test] +fn a_real_save_is_reported_as_nothing() { + let harness = harness(&["note.md"]); + let note = harness.path("note.md"); + harness.sync(); + + write_document(¬e, "# Note\n\nSaved by the app itself.\n", None).unwrap(); + + let events = harness.drain(); + assert!( + events.is_empty(), + "the app's own save came back as an external change: {events:?}" + ); +} + +#[test] +fn a_self_write_stops_suppressing_once_the_window_is_up() { + let harness = harness(&["note.md"]); + let note = harness.path("note.md"); + harness.sync(); + + note_self_write(¬e); + fs::write(¬e, "one\n").unwrap(); + assert!(harness.events_for(¬e).is_empty()); + + // The suppression is a window and not a switch: a later write to the same path, by the app or + // by anything else, has to come through again once the window is up. + std::thread::sleep(Duration::from_millis(2_100)); + fs::write(¬e, "two\n").unwrap(); + + let events = harness.events_for(¬e); + assert_eq!( + one(&events, "the write after the window is one event").kind, + "modified" + ); +} + +#[test] +fn a_burst_of_writes_is_not_a_burst_of_events() { + let harness = harness(&["note.md"]); + let note = harness.path("note.md"); + harness.sync(); + + for n in 0..10 { + fs::write(¬e, format!("# Note\n\nRevision {n}.\n")).unwrap(); + } + + let events = harness.events_for(¬e); + assert!( + (1..=2).contains(&events.len()), + "ten writes should coalesce, got {events:?}" + ); + assert!(events.iter().all(|event| event.kind == "modified")); +} + +#[test] +fn a_rename_is_reported_at_both_ends() { + let harness = harness(&["before.md"]); + let before = harness.path("before.md"); + let after = harness.path("after.md"); + harness.sync(); + + fs::rename(&before, &after).unwrap(); + + let events = harness.drain(); + let gone = events + .iter() + .find(|event| event.path == before.to_string_lossy()) + .unwrap_or_else(|| panic!("the old name was not reported: {events:?}")); + assert_eq!(gone.kind, "removed"); + + // Not `renamed`. FSEvents reports the two ends as unrelated events and marks both of them + // created, which defeats the debouncer's attempt to pair them up, so the honest report is that + // one name went away and another appeared. + let arrived = events + .iter() + .find(|event| event.path == after.to_string_lossy()) + .unwrap_or_else(|| panic!("the new name was not reported: {events:?}")); + assert_ne!(arrived.kind, "removed"); + if let Some(old) = &arrived.old_path { + assert_eq!(old, &*before.to_string_lossy()); + } +} + +#[test] +fn another_editors_atomic_save_is_one_event_on_the_document() { + let harness = harness(&["note.md"]); + let note = harness.path("note.md"); + let temp = harness.path(".note.md.tmp"); + harness.sync(); + + fs::write(&temp, "# Note\n\nSaved by something else.\n").unwrap(); + fs::rename(&temp, ¬e).unwrap(); + + let events = harness.drain(); + assert!( + events + .iter() + .all(|event| event.path != temp.to_string_lossy()), + "the temp file was reported as if it were a document: {events:?}" + ); + let on_note: Vec<_> = events + .into_iter() + .filter(|event| event.path == note.to_string_lossy()) + .collect(); + let event = one(&on_note, "an atomic save is one event on the document"); + assert_ne!(event.kind, "removed"); + assert_eq!( + event.old_path, None, + "the document was reported as renamed from a temp file it never was" + ); +} + +#[test] +fn hidden_paths_are_not_reported() { + let harness = harness(&[]); + harness.sync(); + + fs::create_dir_all(harness.path(".git")).unwrap(); + fs::write(harness.path(".git/index"), "not a document").unwrap(); + fs::write(harness.path(".DS_Store"), "not a document either").unwrap(); + let note = harness.path("note.md"); + fs::write(¬e, "# Note\n").unwrap(); + + let events = harness.drain(); + assert!( + events + .iter() + .all(|event| event.path == note.to_string_lossy()), + "hidden paths reached the frontend: {events:?}" + ); +} + +#[test] +fn nested_changes_are_reported_under_the_path_the_root_was_opened_as() { + let harness = harness(&["sub/deep.md"]); + let nested = harness.path("sub/deep.md"); + harness.sync(); + + fs::write(&nested, "# Deep\n\nEdited.\n").unwrap(); + + let events = harness.events_for(&nested); + let event = one(&events, "a nested file is one event"); + assert_eq!(event.kind, "modified"); + // Not the /private form FSEvents hands out, which nothing else in the app spells that way. + assert!(event + .path + .starts_with(&*harness.dir.path().to_string_lossy())); +} + +#[test] +fn a_deleted_root_is_reported_removed() { + let outer = tempfile::tempdir().unwrap(); + let root = outer.path().join("notes"); + fs::create_dir(&root).unwrap(); + fs::write(root.join("note.md"), "# Note\n").unwrap(); + + let (tx, rx) = mpsc::channel(); + let watcher = spawn_watcher( + ROOT.to_string(), + root.to_string_lossy().into_owned(), + move |events| { + for event in events { + tx.send(event).ok(); + } + }, + ) + .unwrap(); + + let give_up = Instant::now() + DEADLINE; + let mut live = false; + let mut probe = 0; + while !live { + fs::write(root.join(format!("probe-{probe}.md")), "probe\n").unwrap(); + probe += 1; + live = rx.recv_timeout(QUIET).is_ok(); + assert!( + live || Instant::now() < give_up, + "the watcher never reported anything" + ); + } + + fs::remove_dir_all(&root).unwrap(); + + let want = root.to_string_lossy().into_owned(); + let give_up = Instant::now() + DEADLINE; + let mut removed = false; + while !removed && Instant::now() < give_up { + match rx.recv_timeout(QUIET) { + Ok(event) => removed = event.path == want && event.kind == "removed", + Err(_) => break, + } + } + assert!(removed, "deleting the root reported nothing"); + + drop(watcher); +} + +#[test] +fn a_watch_on_a_folder_that_is_not_there_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("gone"); + let started = spawn_watcher( + ROOT.to_string(), + missing.to_string_lossy().into_owned(), + |_| {}, + ); + assert!(started.is_err()); +} diff --git a/src-tauri/tests/watch_payload.rs b/src-tauri/tests/watch_payload.rs new file mode 100644 index 0000000..0ed9d90 --- /dev/null +++ b/src-tauri/tests/watch_payload.rs @@ -0,0 +1,214 @@ +// The shape of a `watch-event` as the frontend receives it, rather than as Rust holds it. +// +// src-tauri/tests/watch.rs proves the watcher reports the right things about the right paths, but +// it asserts against `WatchEvent`'s Rust fields, and the frontend never sees those. What crosses +// the IPC boundary is serde's JSON, and the frontend reads `event.payload.oldPath` off it. A +// missing `#[serde(rename_all = "camelCase")]` would leave every Rust test green and hand the +// frontend `old_path`, which reads as `undefined`, which is neither the string nor the null the +// TypeScript type promises. Nothing else in the suite would notice. +// +// So this file drives the same real watcher over a real folder and asserts the serialized object +// exactly: every key, no extra keys, and the literal values the TypeScript union in src/ipc.ts +// lists. Between this and the Playwright suite in tests/external-changes.spec.ts, which feeds +// payloads of this shape through the real Tauri listener into the real UI, both ends of the wire +// are pinned to the same object. +// +// The waiting strategy is the one watch.rs uses and for the same reason: a probe file whose event +// proves everything caused before it has already been delivered. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +use margin_docs_lib::dto::WatchEvent; +use margin_docs_lib::watch::spawn_watcher; +use notify::RecommendedWatcher; +use notify_debouncer_full::{Debouncer, NoCache}; +use serde_json::{json, Value}; +use tempfile::TempDir; + +const DEADLINE: Duration = Duration::from_secs(15); +const QUIET: Duration = Duration::from_millis(900); +const AGE: Duration = Duration::from_millis(600); +const ROOT: &str = "root-1"; + +struct Harness { + _watcher: Debouncer, + dir: TempDir, + rx: Receiver, + probes: AtomicU32, +} + +impl Harness { + fn path(&self, name: &str) -> PathBuf { + self.dir.path().join(name) + } + + fn sync(&self) { + self.drain(); + } + + fn drain(&self) -> Vec { + let give_up = Instant::now() + DEADLINE; + let mut seen = Vec::new(); + loop { + let n = self.probes.fetch_add(1, Ordering::Relaxed); + let probe = self.path(&format!("probe-{n}.md")); + fs::write(&probe, format!("probe {n}\n")).unwrap(); + let want = probe.to_string_lossy().into_owned(); + loop { + match self.rx.recv_timeout(QUIET) { + Ok(event) if event.path == want => return seen, + Ok(event) => { + if !is_probe(&event.path) { + seen.push(event); + } + } + Err(_) => break, + } + } + assert!( + Instant::now() < give_up, + "the watcher never reported anything" + ); + } + } + + /// The one event for `path`, as the JSON object the frontend will be handed. + fn payload_for(&self, path: &Path) -> Value { + let want = path.to_string_lossy().into_owned(); + let events: Vec = self + .drain() + .into_iter() + .filter(|event| event.path == want) + .collect(); + assert_eq!(events.len(), 1, "expected one event, got {events:?}"); + serde_json::to_value(&events[0]).unwrap() + } +} + +fn harness(fixtures: &[&str]) -> Harness { + let dir = tempfile::tempdir().unwrap(); + for name in fixtures { + let path = dir.path().join(name); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&path, format!("# {name}\n")).unwrap(); + } + if !fixtures.is_empty() { + std::thread::sleep(AGE); + } + + let (tx, rx) = mpsc::channel(); + let watcher = spawn_watcher( + ROOT.to_string(), + dir.path().to_string_lossy().into_owned(), + move |events| { + for event in events { + tx.send(event).ok(); + } + }, + ) + .unwrap(); + Harness { + _watcher: watcher, + dir, + rx, + probes: AtomicU32::new(0), + } +} + +fn is_probe(path: &str) -> bool { + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("probe-")) +} + +/// Exactly these four keys, spelled the way `WatchEvent` in src/ipc.ts spells them. +fn expect_payload(actual: &Value, path: &Path, kind: &str) { + assert_eq!( + actual, + &json!({ + "root": ROOT, + "path": path.to_string_lossy(), + "kind": kind, + "oldPath": Value::Null, + }), + "the payload the frontend receives is not the object it is typed as" + ); +} + +#[test] +fn an_external_edit_serialises_as_the_frontend_reads_it() { + let harness = harness(&["note.md"]); + let note = harness.path("note.md"); + harness.sync(); + + fs::write(¬e, "# Note\n\nEdited by another program.\n").unwrap(); + + expect_payload(&harness.payload_for(¬e), ¬e, "modified"); +} + +#[test] +fn a_created_file_serialises_as_the_frontend_reads_it() { + let harness = harness(&[]); + harness.sync(); + + let note = harness.path("note.md"); + fs::write(¬e, "# Note\n").unwrap(); + + expect_payload(&harness.payload_for(¬e), ¬e, "created"); +} + +#[test] +fn a_deleted_file_serialises_as_the_frontend_reads_it() { + let harness = harness(&["note.md"]); + let note = harness.path("note.md"); + harness.sync(); + + fs::remove_file(¬e).unwrap(); + + expect_payload(&harness.payload_for(¬e), ¬e, "removed"); +} + +/// `old_path` is the one field whose name differs between the two languages, and the only one that +/// is ever anything but a plain string. A rename is where it would be filled in if it ever were, +/// so this is where a wrong spelling would do its damage. +#[test] +fn old_path_is_spelled_the_way_the_frontend_reads_it() { + let renamed = WatchEvent { + root: ROOT.to_string(), + path: "/tmp/after.md".to_string(), + kind: "renamed".to_string(), + old_path: Some("/tmp/before.md".to_string()), + }; + assert_eq!( + serde_json::to_value(&renamed).unwrap(), + json!({ + "root": ROOT, + "path": "/tmp/after.md", + "kind": "renamed", + "oldPath": "/tmp/before.md", + }) + ); +} + +/// A source-literal check and nothing more: it cannot see a running app. What it does catch is the +/// one silent break the runtime tests on either side cannot, because each side is internally +/// consistent with its own constant. Rename the event on one side and the frontend simply stops +/// hearing anything, with every test still green. +#[test] +fn both_sides_name_the_event_the_same_string() { + assert!( + include_str!("../src/watch.rs").contains(r#"const WATCH_EVENT: &str = "watch-event";"#), + "the backend no longer emits under `watch-event`" + ); + assert!( + include_str!("../../src/ipc.ts").contains(r#"export const WATCH_EVENT = "watch-event";"#), + "the frontend no longer listens for `watch-event`" + ); +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..aa9a18e --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,204 @@ +// The window, and the only file that knows what the whole app looks like at once. +// +// Everything here is wiring: which surface is on screen, which of the backend's events the shell +// listens for, and what happens to an unsaved document when the window is asked to close. No +// business logic and no disk access. The stores hold state, their sibling modules do the work, and +// this file decides what is mounted. +// +// One document at a time and no tab bar, so there is exactly one editor in the tree and it is +// either the WYSIWYG surface or the plain text one, never both. A folder of documents can be open +// with nothing chosen out of it, which is why the empty pane is a state and not an error. + +import { useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { Backlinks } from "./components/Backlinks"; +import { CommandPalette } from "./components/CommandPalette"; +import { ConflictDialog } from "./components/ConflictDialog"; +import { FindBar } from "./components/FindBar"; +import { FindInFiles } from "./components/FindInFiles"; +import { ProofPopover } from "./components/ProofPopover"; +import { QuickOpen } from "./components/QuickOpen"; +import { Recents } from "./components/Recents"; +import { Shortcuts } from "./components/Shortcuts"; +import { Sidebar } from "./components/Sidebar"; +import { Titlebar } from "./components/Titlebar"; +import { Toast } from "./components/Toast"; +import { flushPendingSave, keepBuffer } from "./document"; +import { DocumentEditor, PlainTextEditor, useDocumentFind } from "./editor"; +import { Toolbar, type ToolbarSaveState } from "./editor/Toolbar"; +import { MENU_ACTION_EVENT, isDesktop, isTauri } from "./ipc"; +import { onCommand, type CommandId } from "./keys/commands"; +import { useKeymap } from "./keys/keymap"; +import { handleMenuAction } from "./keys/menu"; +import { openLink } from "./links"; +import { documentKindForPath } from "./model/doc"; +import { useDocument } from "./store/useDocument"; +import { notify } from "./store/useToast"; +import { useWorkspace } from "./store/useWorkspace"; +import { useCompact, useTouch } from "./useMedia"; +import { applyWidth } from "./width"; +import { restoreSession, startWorkspaceEvents } from "./workspace"; + +/** + * Commands whose whole result is a panel that this milestone does not have. They are bound keys + * and native menu rows already, so pressing one has to say something: a key that silently does + * nothing reads as a broken app rather than as an unfinished one. Each line goes when its panel + * arrives. + */ +const UNBUILT: ReadonlyArray<[CommandId, string]> = [ + ["settings", "There is no settings panel yet. The theme is in the title bar."], +]; + +const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1); + +function App() { + const roots = useWorkspace((s) => s.roots); + const path = useDocument((s) => s.path); + const openDocument = useDocument((s) => s.document); + const savePhase = useDocument((s) => s.savePhase); + const externalChange = useDocument((s) => s.externalChange); + const setContent = useDocument((s) => s.setContent); + const reloadFromDisk = useDocument((s) => s.reloadFromDisk); + const find = useDocumentFind(); + + const [resolving, setResolving] = useState(false); + + useKeymap(); + useCompact(); + useTouch(); + + useEffect(() => { + void restoreSession(); + }, []); + + // `watch-event` and `index-progress`, both of them landing in the stores that care. The routing + // itself belongs to src/workspace.ts, which is the module that already knows which root a path + // sits under and whether the open document was the file that moved. + useEffect(() => startWorkspaceEvents(), []); + + // The native menu emits a command id, so this is a lookup and not a second dispatch table. A + // phone has a menu bar to emit from too, hence isTauri rather than isDesktop. + useEffect(() => { + if (!isTauri) return; + const pending = listen(MENU_ACTION_EVENT, (event) => handleMenuAction(event.payload)); + return () => { + void pending.then((stop) => stop()).catch(() => {}); + }; + }, []); + + // Quitting with an edit half a second old must not lose it. The debounce is cancelled and the + // save run to completion before the window is allowed to go, and the close is only intercepted + // when there is actually something to write. + useEffect(() => { + if (!isDesktop) return; + const win = getCurrentWindow(); + const pending = win.onCloseRequested(async (event) => { + if (!useDocument.getState().dirty) return; + event.preventDefault(); + await flushPendingSave(); + void win.destroy(); + }); + return () => { + void pending.then((stop) => stop()).catch(() => {}); + }; + }, []); + + useEffect(() => { + const stops = [ + onCommand("editor-width-narrow", () => applyWidth("narrow")), + onCommand("editor-width-normal", () => applyWidth("normal")), + onCommand("editor-width-wide", () => applyWidth("wide")), + ...UNBUILT.map(([id, message]) => onCommand(id, () => notify(message))), + ]; + return () => { + for (const stop of stops) stop(); + }; + }, []); + + const conflict = externalChange === "changed-on-disk"; + + // Asked once, when the conflict appears. Dismissing it leaves the warning in the toolbar to + // reopen rather than asking again on the next keystroke. + useEffect(() => { + if (conflict) setResolving(true); + }, [conflict]); + + const kind = path === null ? null : documentKindForPath(path); + const saveState: ToolbarSaveState = conflict + ? "conflict" + : savePhase === "saving" + ? "saving" + : "idle"; + + return ( +
+ + +
+ {roots.length === 0 ? ( + + ) : ( + <> + +
+ {openDocument === null || kind === null ? ( +

Choose a document from the sidebar.

+ ) : kind === "markdown" ? ( + <> +
+ + +
+ setResolving(true)} + /> + + ) : ( +
+ +
+ )} +
+ + )} +
+ + + + + + + + + + {resolving && conflict && path !== null && ( + { + setResolving(false); + reloadFromDisk().catch((e) => notify(`Could not reload: ${String(e)}`)); + }} + onKeep={() => { + setResolving(false); + keepBuffer(); + }} + onDismiss={() => setResolving(false)} + /> + )} +
+ ); +} + +export default App; diff --git a/src/api/files.ts b/src/api/files.ts new file mode 100644 index 0000000..6952f48 --- /dev/null +++ b/src/api/files.ts @@ -0,0 +1,36 @@ +import { call, type AssetResult, type FileNode, type ReadResult, type WriteResult } from "../ipc"; + +export const fileRead = (path: string) => call("file_read", { path }); + +/** + * Writes through a temporary file and a rename, so a crash mid-write leaves the old document + * whole. Pass the `modifiedMs` the buffer was read at: if the file has moved on since, nothing is + * written and the result comes back with `conflict`. + */ +export const fileWrite = (path: string, text: string, expectedModifiedMs?: number) => + call("file_write", { path, text, expectedModifiedMs }); + +/** `name` is a suggestion. A taken name gets a suffix, and the node returned carries the real one. */ +export const fileCreate = (parentPath: string, name: string) => + call("file_create", { parentPath, name }); + +export const fileFolderCreate = (parentPath: string, name: string) => + call("file_folder_create", { parentPath, name }); + +export const fileRename = (path: string, name: string) => + call("file_rename", { path, name }); + +export const fileMove = (path: string, destDir: string) => + call("file_move", { path, destDir }); + +export const fileDuplicate = (path: string) => call("file_duplicate", { path }); + +/** To the system Trash, never an unlink. Deleting a document is undoable in Finder. */ +export const fileTrash = (path: string) => call("file_trash", { path }); + +/** + * A pasted image, into an `assets/` folder beside the document. `bytes` is the clipboard payload + * and `name` the filename it suggested, which is usually `image.png` and usually already taken. + */ +export const assetWrite = (docPath: string, bytes: number[], name: string) => + call("asset_write", { docPath, bytes, name }); diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..dd2da10 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,16 @@ +import { call, type Backlink, type IndexStatus, type QuickOpenHit, type SearchHit } from "../ipc"; + +/** Rescans every open root. Progress arrives on the `index-progress` event. */ +export const indexRebuild = () => call("index_rebuild"); + +export const indexStatus = () => call("index_status"); + +/** Fuzzy match over paths relative to their root, across every open root. */ +export const searchQuickOpen = (query: string, limit: number) => + call("search_quick_open", { query, limit }); + +export const searchText = (query: string, limit: number) => + call("search_text", { query, limit }); + +/** Which documents link to this one, for the section at the end of the document. */ +export const backlinksFor = (path: string) => call("backlinks_for", { path }); diff --git a/src/api/roots.ts b/src/api/roots.ts new file mode 100644 index 0000000..3aeb52f --- /dev/null +++ b/src/api/roots.ts @@ -0,0 +1,16 @@ +import { call, type FileNode, type RootInfo } from "../ipc"; + +export const rootsList = () => call("roots_list"); + +/** `path` comes from the native folder picker. Opening a folder never writes anything into it. */ +export const rootOpen = (path: string) => call("root_open", { path }); + +export const rootClose = (rootId: string) => call("root_close", { rootId }); + +/** The whole tree for one root, root node included. */ +export const treeRead = (rootId: string) => call("tree_read", { rootId }); + +export const revealInFinder = (path: string) => call("reveal_in_finder", { path }); + +/** Hands a file to whatever macOS opens it with. The only way to open a non-editable file. */ +export const openExternal = (path: string) => call("open_external", { path }); diff --git a/src/api/spell.ts b/src/api/spell.ts new file mode 100644 index 0000000..146091f --- /dev/null +++ b/src/api/spell.ts @@ -0,0 +1,35 @@ +// Spelling, which is the system's and not this app's. +// +// Everything here goes to NSSpellChecker, the same checker Mail and Notes correct into, so a word +// learned anywhere on the machine is a word this editor does not underline and the user's own +// languages are already configured. Nothing in this app ships a dictionary or has an opinion about +// English. +// +// The checker is asked about a run of text and answers about that run. It has no idea a document +// exists, which is what keeps the caller free to send it a paragraph, a visible screenful or one +// sentence, and to decide for itself what a stale answer is worth. + +import { call, type SpellIssue } from "../ipc"; + +/** + * Every misspelling in one run of text, with offsets in characters counted from the start of that + * run. The caller adds its own base offset; this never sees a document position. + */ +export const spellCheck = (text: string) => call("spell_check", { text }); + +/** + * Teaches the word to the system, for every app on the machine and not only this one. That is the + * honest behaviour for a checker borrowed from the OS, and it is what the "Learn Spelling" item in + * every other Mac app does. + */ +export const spellLearn = (word: string) => call("spell_learn", { word }); + +/** Undoes a `spellLearn`, for a word taught by a slip of the hand. */ +export const spellUnlearn = (word: string) => call("spell_unlearn", { word }); + +/** + * Whether the machine has a checker at all. False on a build that is not macOS, where the answer + * to every check is an empty list rather than an error, and the UI hides itself rather than + * offering a menu that cannot do anything. + */ +export const spellAvailable = () => call("spell_available"); diff --git a/src/api/watch.ts b/src/api/watch.ts new file mode 100644 index 0000000..6034762 --- /dev/null +++ b/src/api/watch.ts @@ -0,0 +1,6 @@ +import { call } from "../ipc"; + +/** Changes arrive on the `watch-event` event, never as a return value. */ +export const watchStart = (rootId: string) => call("watch_start", { rootId }); + +export const watchStop = (rootId: string) => call("watch_stop", { rootId }); diff --git a/src/components/Backlinks.tsx b/src/components/Backlinks.tsx new file mode 100644 index 0000000..684a65d --- /dev/null +++ b/src/components/Backlinks.tsx @@ -0,0 +1,138 @@ +// The "Linked from" section: the documents elsewhere on disk that point at the one on screen. +// +// Nothing here is content. A backlink exists because of bytes in somebody else's file, so it is +// never in the ProseMirror document, never serialized and never written; it is drawn after the last +// block and that is the whole of its existence. Which is why this is a sibling of the editor inside +// the sheet rather than a node at the end of it: it shares the paper and the measure with the +// document and shares nothing else, and a caret cannot land in a section that was never in the +// editable, nor can a select all inside the editor reach it. +// +// Silence is the default and it is the point. No section under a document nothing links to, and no +// section before the index has finished a pass, because "nothing links here" and "I have not looked +// yet" are different facts and only one of them has earned a heading. +// +// The open document and the index are read from their stores rather than passed in, so the mount in +// App.tsx is a bare tag. This component already has to watch the index to know whether its answer +// means anything, so it is subscribed either way, and a prop would only put half of what it needs +// through the shell while the other half went round it. + +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { backlinksFor } from "../api"; +import type { Backlink } from "../ipc"; +import { useDocument } from "../store/useDocument"; +import { useIndex } from "../store/useIndex"; +import { notify } from "../store/useToast"; + +interface Answer { + /** The document these were asked for, kept with them so a slow reply about the file that was open + * a moment ago is never drawn under the file that is open now. */ + path: string; + /** Null is "asked, and could not be told". It draws the same nothing an empty list does, and that + * is a decision rather than an accident: a writer cannot act on a failed index lookup, and a + * permanent error line under every document costs more attention than the feature is worth. The + * two are still not the same fact, so they are not the same value here, and this is the one place + * that could ever tell them apart. */ + links: Backlink[] | null; +} + +const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path; + +/** + * A snippet is the source line the link sits on, so the one construct every snippet is guaranteed + * to contain is the link that made it a backlink, and an editor whose whole pitch is that markdown + * syntax is never visible should not be the thing putting `](../thing.md)` on screen. The link is + * unwrapped to its text and nothing else is: everything else a line might hold is not certain to be + * there, and unwrapping it would be a second markdown reader living in a view. + */ +const readableSnippet = (snippet: string): string => + snippet.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").trim(); + +export function Backlinks() { + const path = useDocument((s) => s.path); + const open = useDocument((s) => s.open); + const phase = useIndex((s) => s.phase); + + const [answer, setAnswer] = useState(null); + const [active, setActive] = useState(0); + const rows = useRef<(HTMLButtonElement | null)[]>([]); + + // Two triggers, both of them in the dependencies: a different document to ask about, and a pass of + // the index finishing. The second is what keeps the section true when somebody edits another file + // and the watcher reindexes it, since `index-progress` lands in useIndex and comes out as a phase. + // + // Anything short of a completed pass is not asked at all, and mid-pass the previous answer is left + // on screen: a reindex is not new information about this document, and blanking the section for + // the duration would be a flicker that says something changed when nothing has. + useEffect(() => { + if (path === null || phase !== "ready") return; + let cancelled = false; + backlinksFor(path) + .then((links) => { + if (!cancelled) setAnswer({ path, links }); + }) + .catch(() => { + if (!cancelled) setAnswer({ path, links: null }); + }); + return () => { + cancelled = true; + }; + }, [path, phase]); + + // Matched against the open path at render rather than cleared in an effect, so switching documents + // cannot paint one frame of the last one's links before the effect catches up. + const links = answer !== null && answer.path === path ? answer.links : null; + if (links === null || links.length === 0) return null; + + // Roving focus: the section is one stop in the tab order however many rows it has, and the arrows + // move inside it. A row per tab stop would make tabbing out of a well linked document a chore + // through chrome, and taking the rows out of the tab order entirely would leave them mouse only. + const focused = active < links.length ? active : 0; + + const move = (delta: number) => { + const next = Math.min(Math.max(focused + delta, 0), links.length - 1); + setActive(next); + rows.current[next]?.focus(); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + move(event.key === "ArrowDown" ? 1 : -1); + }; + + const go = (target: string) => { + open(target).catch((e) => notify(`Could not open ${baseName(target)}: ${String(e)}`)); + }; + + return ( + + ); +} diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 0000000..a11ef26 --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,78 @@ +// Cmd+K: every command the app has, by name. +// +// This is the one palette with nothing behind it. No index, no IPC, no store: the rows are the +// table in src/keys/commands.ts filtered by a subsequence match, so it answers on a build where +// SQLite has fallen over and on the first frame after launch, before a folder is even open. That +// is why src/keys/bindings.ts binds it in the `global` context with a comment saying an overlay may +// not shadow it: whatever is on screen, this is how you get anywhere from inside it, and something +// that reaches into a search index for its own row list would not be able to make that promise. +// +// It lists commands, not bindings, which is why the keys on the right come from `keysFor` and +// `keyLabel` rather than from `bindingLabel`: that one turns a binding into its words, and a +// command with no key at all still belongs in this list. + +import { useEffect, useState } from "react"; +import { useEscapeLayer } from "../escape"; +import { keyLabel, keysFor } from "../keys/bindings"; +import { COMMANDS, commandMatches, onCommand, runCommand } from "../keys/commands"; +import { useKeyContext } from "../keys/keymap"; +import { Palette, type PaletteRow } from "./Palette"; + +interface CommandRow extends PaletteRow { + label: string; + keys: readonly string[]; +} + +export function CommandPalette() { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + + // A toggle, like the shortcuts sheet: the key that opens it is reachable from inside it, so it + // has to mean something the second time it is pressed. + useEffect( + () => + onCommand("command-palette", () => { + setOpen((wasOpen) => !wasOpen); + setQuery(""); + }), + [], + ); + + useEscapeLayer(open, () => setOpen(false)); + useKeyContext("overlay", open); + + if (!open) return null; + + const rows: CommandRow[] = COMMANDS.filter( + (command) => command.palette && commandMatches(command.label, query), + ).map((command) => ({ + key: command.id, + label: command.label, + keys: keysFor(command.id), + run: () => runCommand(command.id), + })); + + return ( + setOpen(false)} + renderRow={(row) => ( + + {row.label} + + {row.keys.map((combo) => ( + + {keyLabel(combo)} + + ))} + + + )} + /> + ); +} diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..007378f --- /dev/null +++ b/src/components/ConfirmDialog.tsx @@ -0,0 +1,56 @@ +import { useEffect, useRef, type ReactNode } from "react"; +import { useEscapeLayer } from "../escape"; +import { Icon } from "./Icon"; + +interface ConfirmDialogProps { + title: string; + message: ReactNode; + confirmLabel?: string; + onConfirm: () => void; + onClose: () => void; +} + +export function ConfirmDialog({ + title, + message, + confirmLabel = "Delete", + onConfirm, + onClose, +}: ConfirmDialogProps) { + const confirmRef = useRef(null); + + useEffect(() => { + confirmRef.current?.focus(); + }, []); + + useEscapeLayer(true, onClose); + + return ( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+
+

{message}

+
+
+ + +
+
+
+ ); +} diff --git a/src/components/ConflictDialog.tsx b/src/components/ConflictDialog.tsx new file mode 100644 index 0000000..dcd9901 --- /dev/null +++ b/src/components/ConflictDialog.tsx @@ -0,0 +1,66 @@ +// Something outside the app changed the file that is open, and the buffer has an edit in it that +// is not on disk. Both copies are real work and the app does not get to pick, so it asks. +// +// There is no merge and there will not be one: a three way merge of somebody's prose is a thing +// that looks like it worked. The two answers are the two copies, and dismissing the dialog picks +// neither, which leaves the warning in the toolbar and the buffer exactly as it was. + +import { useEffect, useRef } from "react"; +import { useEscapeLayer } from "../escape"; +import { Icon } from "./Icon"; + +interface ConflictDialogProps { + /** The file's name, not its path: the path is already in the title bar. */ + name: string; + /** Throws the buffer away and takes what is on disk. */ + onReload: () => void; + /** Keeps the buffer and lets the next save write over the copy on disk. */ + onKeep: () => void; + /** Neither, for now. The document stays unsaved and the toolbar keeps the warning. */ + onDismiss: () => void; +} + +export function ConflictDialog({ name, onReload, onKeep, onDismiss }: ConflictDialogProps) { + const keepRef = useRef(null); + + useEffect(() => { + keepRef.current?.focus(); + }, []); + + useEscapeLayer(true, onDismiss); + + return ( +
+
e.stopPropagation()} + > +
+

Changed on disk

+ +
+
+

+ Something outside Margin Docs has changed {name}, and you have edits + here that are not on disk. Nothing has been written and nothing has been lost yet. +

+
+
+ + + +
+
+
+ ); +} diff --git a/src/components/FileTree.tsx b/src/components/FileTree.tsx new file mode 100644 index 0000000..da1f5b3 --- /dev/null +++ b/src/components/FileTree.tsx @@ -0,0 +1,274 @@ +// The recursive half of the sidebar: rows, twisties, indentation and drop indicators, and nothing +// else. Selection, the keyboard, the drag gesture and every action a row can perform live one +// level up in Sidebar.tsx, because all of those span every open root and a recursive renderer only +// ever sees one subtree. + +import { + useEffect, + useRef, + type CSSProperties, + type KeyboardEvent, + type MouseEvent, + type PointerEvent, +} from "react"; +import { useEscapeLayer } from "../escape"; +import { MARKDOWN_EXTENSIONS } from "../model/doc"; +import type { TreeNode } from "../store/useWorkspace"; +import { Icon } from "./Icon"; +import { RowMenu, type RowMenuEntry } from "./RowMenu"; + +const FOLDER = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"; +const FOLDER_OPEN = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2H7l-4 8z M3 17V7"; +const DOCUMENT = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5"; +const FOREIGN = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5 M9 17l2.5-3 2 2.2 1.5-1.7"; + +/** + * The extension a row hides. Markdown is the app's own format and `.md` on every second row is + * noise, but everything else keeps its extension: two rows both reading "notes", for `notes.md` + * and `notes.txt`, would be a worse lie than the clutter it saved. + */ +export function splitExtension(name: string, isDir = false): { base: string; hidden: string } { + if (isDir) return { base: name, hidden: "" }; + const dot = name.lastIndexOf("."); + if (dot <= 0) return { base: name, hidden: "" }; + const ext = name.slice(dot + 1).toLowerCase(); + if (!(MARKDOWN_EXTENSIONS as readonly string[]).includes(ext)) return { base: name, hidden: "" }; + return { base: name.slice(0, dot), hidden: name.slice(dot) }; +} + +export const prettyName = (node: TreeNode): string => splitExtension(node.name, node.isDir).base; + +export interface TreeRow { + node: TreeNode; + depth: number; + /** The directory the row sits in, empty for a root. Where a "drop above this row" resolves to. */ + parentPath: string; +} + +/** The rows the user can actually see, in the order they appear, which is what the arrow keys walk. */ +export function flattenTree( + nodes: readonly TreeNode[], + expanded: ReadonlySet, + depth = 0, + parentPath = "", +): TreeRow[] { + const rows: TreeRow[] = []; + for (const node of nodes) { + rows.push({ node, depth, parentPath }); + if (node.isDir && expanded.has(node.path) && node.children?.length) + rows.push(...flattenTree(node.children, expanded, depth + 1, node.path)); + } + return rows; +} + +/** + * Every drop resolves to exactly one destination directory, because a filesystem has no row order + * to insert into. The mode is only how the pointer said it: "into" is the folder under the cursor, + * "before" and "after" are the folder that row already lives in. + */ +export type DropMode = "before" | "after" | "into"; + +export interface DropTarget { + dir: string; + mode: DropMode; + /** The row the indicator draws on, which for "into" is the destination folder itself. */ + row: string; +} + +export interface TreeViewState { + expanded: ReadonlySet; + selectedPath: string | null; + /** The one row in the whole sidebar that is in the tab order. */ + tabStopPath: string | null; + draggingPath: string | null; + dropTarget: DropTarget | null; + renamingPath: string | null; + /** The document currently open in the editor, which is a different thing from the selected row. */ + openPath: string | null; +} + +export interface TreeHandlers { + onActivate: (node: TreeNode) => void; + onToggle: (node: TreeNode) => void; + onKeyDown: (e: KeyboardEvent, row: TreeRow) => void; + onPointerDown: (e: PointerEvent, row: TreeRow) => void; + onContextMenu: (e: MouseEvent, row: TreeRow) => void; + onMenuOpenChange: (path: string, open: boolean) => void; + menuItems: (row: TreeRow) => readonly RowMenuEntry[]; + onRenameCommit: (node: TreeNode, base: string) => void; + onRenameCancel: () => void; +} + +interface TreeProps { + nodes: readonly TreeNode[]; + depth: number; + parentPath: string; + state: TreeViewState; + handlers: TreeHandlers; +} + +export function FileTree({ nodes, depth, parentPath, state, handlers }: TreeProps) { + return ( +
    + {nodes.map((node) => ( + + ))} +
+ ); +} + +function TreeItem({ + row, + state, + handlers, +}: { + row: TreeRow; + state: TreeViewState; + handlers: TreeHandlers; +}) { + const { node, depth } = row; + const { base, hidden } = splitExtension(node.name, node.isDir); + const open = node.isDir && state.expanded.has(node.path); + const renaming = state.renamingPath === node.path; + const drop = state.dropTarget?.row === node.path ? state.dropTarget.mode : null; + const foreign = !node.isDir && !node.editable; + + return ( +
  • +
    handlers.onActivate(node)} + onKeyDown={(e) => handlers.onKeyDown(e, row)} + onPointerDown={(e) => handlers.onPointerDown(e, row)} + onContextMenu={(e) => handlers.onContextMenu(e, row)} + > + {node.isDir ? ( + + ) : ( +
    + + {open && node.children?.length ? ( + + ) : null} +
  • + ); +} + +/** + * Blur commits, the way Finder does, so the escape layer lives here rather than in the sidebar: + * unmounting a focused input can fire a blur on the way out, and a cancel that arrived from + * outside would otherwise be overtaken by the commit it was trying to avoid. + */ +function RenameField({ + value, + onCommit, + onCancel, +}: { + value: string; + onCommit: (next: string) => void; + onCancel: () => void; +}) { + const ref = useRef(null); + const settled = useRef(false); + + useEffect(() => { + const input = ref.current; + if (!input) return; + input.focus(); + input.select(); + }, []); + + const commit = (next: string) => { + if (settled.current) return; + settled.current = true; + onCommit(next); + }; + + useEscapeLayer(true, () => { + settled.current = true; + onCancel(); + }); + + return ( + e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onBlur={(e) => commit(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + commit(e.currentTarget.value); + }} + /> + ); +} diff --git a/src/components/FindBar.tsx b/src/components/FindBar.tsx new file mode 100644 index 0000000..778a9f3 --- /dev/null +++ b/src/components/FindBar.tsx @@ -0,0 +1,190 @@ +// Find and replace inside the open document. Margin's bar, minus the cross-chapter scope: there +// is one document open at a time here, and searching every file is `find-in-files` against the +// SQLite index, which is a different panel with different results. +// +// The matching itself is the editor's, not this bar's. `EditorHandle` in src/editor/index.ts does +// not carry a search surface, so the shape this bar drives is declared here and handed in: a +// component that draws a text field has no business owning a ProseMirror decoration set, and the +// alternative, walking the contenteditable DOM behind the editor's back, is a second +// implementation of matching that would disagree with the first the day either changed. + +import { useEffect, useRef, useState } from "react"; +import { useEscapeLayer } from "../escape"; +import { onCommand } from "../keys/commands"; +import { Icon } from "./Icon"; + +export interface FindOptions { + caseSensitive: boolean; + wholeWord: boolean; +} + +export interface FindState { + count: number; + /** Zero based, so `current + 1` is what the "3 of 12" readout shows. */ + current: number; +} + +/** + * What the editor lane implements for this bar to be usable. + * + * A new object whenever `state` changes, the way `EditorHandle` already works: this bar draws the + * "3 of 12" readout from a prop and has nothing to subscribe to, so a handle mutated in place + * would leave the count stale until something unrelated re-rendered. + */ +export interface DocumentFind { + state: FindState; + setQuery: (query: string, options: FindOptions) => void; + clear: () => void; + next: () => void; + prev: () => void; + replaceCurrent: (text: string) => void; + replaceAll: (text: string) => void; + /** Puts the cursor back in the document, which every replace has to do to be worth anything. */ + focus: () => void; +} + +export function FindBar({ find }: { find: DocumentFind | null }) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [replacement, setReplacement] = useState(""); + const [caseSensitive, setCaseSensitive] = useState(false); + const [wholeWord, setWholeWord] = useState(false); + const [expanded, setExpanded] = useState(false); + const findRef = useRef(null); + + useEffect( + () => + onCommand("find", () => { + setOpen(true); + const input = findRef.current; + input?.focus(); + input?.select(); + }), + [], + ); + + useEffect(() => { + if (!open) return; + const input = findRef.current; + input?.focus(); + input?.select(); + }, [open]); + + useEffect(() => { + if (!find) return; + if (open) find.setQuery(query, { caseSensitive, wholeWord }); + else find.clear(); + }, [find, open, query, caseSensitive, wholeWord]); + + useEscapeLayer(open, () => setOpen(false)); + + if (!open || !find) return null; + + const { count, current } = find.state; + const countLabel = !query ? "" : count === 0 ? "No results" : `${current + 1} of ${count}`; + + const onFindKey = (e: React.KeyboardEvent) => { + if (e.key !== "Enter") return; + e.preventDefault(); + if (e.shiftKey) find.prev(); + else find.next(); + }; + + const replaceOne = () => { + find.replaceCurrent(replacement); + find.focus(); + }; + + const replaceEvery = () => { + find.replaceAll(replacement); + find.focus(); + }; + + return ( +
    + + +
    +
    + setQuery(e.target.value)} + onKeyDown={onFindKey} + /> + {countLabel} + + + + + +
    + + {expanded && ( +
    + setReplacement(e.target.value)} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + replaceOne(); + }} + /> + + +
    + )} +
    +
    + ); +} diff --git a/src/components/FindInFiles.tsx b/src/components/FindInFiles.tsx new file mode 100644 index 0000000..abacd73 --- /dev/null +++ b/src/components/FindInFiles.tsx @@ -0,0 +1,129 @@ +// Cmd+Shift+F: the text inside every file in every open root, which is the one question the file +// tree and the find bar between them cannot answer. The bar in src/components/FindBar.tsx searches +// the one document that is open; this searches the ones that are not. +// +// Same debounce and the same reason as quick open, a little longer because a full text query reads +// the whole corpus rather than one column of paths, and the same deliberate absence of a second +// guard around the race: the sequence number in src/store/useSearch.ts already refuses an answer +// that has been overtaken. +// +// A row opens the document it found the line in, and stops there. Putting the caret on the line +// itself would need a way to say "open this file at line 42", and the editor's public surface in +// src/editor/index.ts has no such thing, so the honest version of this today is the file open at +// the top rather than a jump built out of a DOM query behind the editor's back. + +import { useEffect, useState } from "react"; +import { useEscapeLayer } from "../escape"; +import type { MatchRange } from "../ipc"; +import { onCommand } from "../keys/commands"; +import { useKeyContext } from "../keys/keymap"; +import { useDocument } from "../store/useDocument"; +import { useIndex } from "../store/useIndex"; +import { useSearch } from "../store/useSearch"; +import { notify } from "../store/useToast"; +import { useWorkspace } from "../store/useWorkspace"; +import { Palette, highlight, type PaletteRow, type PaletteStatus } from "./Palette"; + +/** Longer than quick open's: this one reads the text of every file rather than their paths. */ +const DEBOUNCE_MS = 140; + +interface HitRow extends PaletteRow { + title: string; + /** One based, and counted over the file as it sits on disk, frontmatter included. */ + line: number; + excerpt: string; + ranges: readonly MatchRange[]; + path: string; +} + +export function FindInFiles() { + const [open, setOpen] = useState(false); + + const query = useSearch((s) => s.fullTextQuery); + const setQuery = useSearch((s) => s.setFullTextQuery); + const runFullText = useSearch((s) => s.runFullText); + const hits = useSearch((s) => s.fullTextHits); + const phase = useSearch((s) => s.fullTextPhase); + const error = useSearch((s) => s.fullTextError); + const indexPhase = useIndex((s) => s.phase); + const roots = useWorkspace((s) => s.roots); + const select = useWorkspace((s) => s.select); + const openDocument = useDocument((s) => s.open); + + useEffect( + () => + onCommand("find-in-files", () => { + // Empty field, and last time's rows cleared through the store so its sequence number moves + // with them. See the same lines in QuickOpen.tsx. + setQuery(""); + void runFullText(""); + setOpen(true); + }), + [setQuery, runFullText], + ); + + useEscapeLayer(open, () => setOpen(false)); + useKeyContext("overlay", open); + + useEffect(() => { + if (!open) return; + const timer = window.setTimeout(() => void runFullText(query), DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [open, query, runFullText]); + + if (!open) return null; + + const choose = (path: string) => { + select(path); + openDocument(path).catch((e) => notify(`Could not open: ${String(e)}`)); + }; + + const searching = query.trim() !== ""; + + // A file can answer on several lines, so the path alone is not a key. + const rows: HitRow[] = hits.map((hit, index) => ({ + key: `${hit.path}:${hit.line}:${index}`, + title: hit.title, + line: hit.line, + excerpt: hit.excerpt, + ranges: hit.ranges, + path: hit.path, + run: () => choose(hit.path), + })); + + const status = (): PaletteStatus => { + if (phase === "error") { + return { text: error ?? "The search index could not be read.", error: true }; + } + if (!searching) { + return { + text: + roots.length === 0 ? "Open a folder first." : "Type to search every folder that is open.", + }; + } + if (phase === "loading") return { text: "Searching…" }; + if (indexPhase === "indexing") return { text: "Still indexing. Try again in a moment." }; + return { text: "Nothing in these folders says that." }; + }; + + return ( + setOpen(false)} + renderRow={(row) => ( + + + {row.title} + {row.line} + + {highlight(row.excerpt, row.ranges)} + + )} + /> + ); +} diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx new file mode 100644 index 0000000..bc60a57 --- /dev/null +++ b/src/components/Icon.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; + +interface IconProps { + d?: string; + size?: number; + children?: ReactNode; +} + +export function Icon({ d, size = 16, children }: IconProps) { + return ( + + {children ?? } + + ); +} diff --git a/src/components/Palette.tsx b/src/components/Palette.tsx new file mode 100644 index 0000000..d4678c7 --- /dev/null +++ b/src/components/Palette.tsx @@ -0,0 +1,191 @@ +// The shell behind all three overlay palettes: the backdrop, the one text field, the list under it +// and the keyboard that drives them. +// +// Three sources, one widget. What differs between quick open, find in files and the command palette +// is where the rows come from and what a row does when it is chosen, and that is the whole of what +// the three concrete palettes hand in. Everything a user would call "how the palette behaves", the +// arrow keys, the wrap at the ends, the selection following the mouse, the row scrolling itself +// into view, lives here once so the three cannot drift into three slightly different lists. +// +// Rendered only while its palette is open, never handed a closed flag: mounting is what opens it. +// That is what keeps the selection, the scroll position and the focus fresh on every open without a +// single reset effect, and it leaves the open flag, the Escape layer and the key context in the +// concrete component beside its `onCommand` subscription, which is the shape Shortcuts.tsx already +// has. + +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; +import type { MatchRange } from "../ipc"; + +export interface PaletteRow { + /** Identity, not position: a path, a command id. React's key and nothing more. */ + key: string; + /** What choosing the row does. The palette is already closed by the time this is called. */ + run: () => void; +} + +/** The single line shown in place of the list. */ +export interface PaletteStatus { + text: string; + /** Something failed and this is its message. An empty result is not a failure. */ + error?: boolean; +} + +interface PaletteProps { + /** Names the dialog, its field and its list for a screen reader. */ + label: string; + placeholder: string; + query: string; + onQuery: (query: string) => void; + rows: readonly Row[]; + /** Shown only when there are no rows, so an answer that is still in flight keeps the last rows + * on screen rather than flashing "No results" between two keystrokes. */ + status: PaletteStatus | null; + renderRow: (row: Row) => ReactNode; + onClose: () => void; +} + +export function Palette({ + label, + placeholder, + query, + onQuery, + rows, + status, + renderRow, + onClose, +}: PaletteProps) { + const [selected, setSelected] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + const listId = useId(); + + // Clamped where it is read rather than corrected in an effect. The row count changes with every + // answer the index gives back, and an effect that put the index right afterwards would render one + // frame with a selection pointing past the end of the list first. + const at = Math.min(selected, rows.length - 1); + const current = at >= 0 ? rows[at] : null; + + useEffect(() => inputRef.current?.focus(), []); + + // A new query is a new list, so the selection goes back to the top. Keyed on the query rather + // than on `rows`, because a palette that filters as it renders hands over a new array every time + // and this would then undo every arrow key the moment it was pressed. + useEffect(() => setSelected(0), [query]); + + useEffect(() => { + listRef.current?.children[at]?.scrollIntoView({ block: "nearest" }); + }, [at]); + + const choose = (row: Row) => { + // Closed before the row runs. A command palette row can put another overlay on screen, and the + // two would otherwise unwind the Escape stack and the key context stack in the wrong order. + onClose(); + row.run(); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + // Without this the caret jumps to one end of the field on every step through the list. + e.preventDefault(); + if (rows.length === 0) return; + const next = e.key === "ArrowDown" ? at + 1 : at - 1 + rows.length; + setSelected(next % rows.length); + return; + } + if (e.key === "Enter" && current) { + e.preventDefault(); + choose(current); + } + }; + + return ( + // Mousedown rather than click: a click closes on the release, so dragging a selection out of + // the field and letting go over the backdrop would dismiss the palette mid-gesture. +
    +
    e.stopPropagation()} + > + 0} + aria-controls={rows.length > 0 ? listId : undefined} + aria-activedescendant={current ? `${listId}-${at}` : undefined} + onChange={(e) => onQuery(e.target.value)} + onKeyDown={onKeyDown} + /> + + {rows.length > 0 ? ( +
      + {rows.map((row, index) => ( +
    • setSelected(index)} + onClick={() => choose(row)} + > + {renderRow(row)} +
    • + ))} +
    + ) : ( + status && ( +

    + {status.text} +

    + ) + )} +
    +
    + ); +} + +/** + * The matched characters, marked. + * + * `ranges` are half-open offsets into `text` and they come from whatever did the matching, which is + * the only thing that knows where it landed, so neither search palette runs the match a second time + * to find out. Offsets are clamped and taken in order rather than trusted: they are computed on the + * other side of the IPC boundary against a string this side only has a copy of, and one bad pair + * would otherwise slice a row into nonsense. + */ +export function highlight(text: string, ranges: readonly MatchRange[]): ReactNode { + if (ranges.length === 0) return text; + + const parts: ReactNode[] = []; + let at = 0; + const ordered = [...ranges].sort((a, b) => a.start - b.start); + + ordered.forEach((range, i) => { + const start = Math.max(at, Math.min(range.start, text.length)); + const end = Math.max(start, Math.min(range.end, text.length)); + if (end === start) return; + if (start > at) parts.push(text.slice(at, start)); + parts.push( + + {text.slice(start, end)} + , + ); + at = end; + }); + + if (at < text.length) parts.push(text.slice(at)); + return parts; +} diff --git a/src/components/ProofPopover.tsx b/src/components/ProofPopover.tsx new file mode 100644 index 0000000..d2a2cae --- /dev/null +++ b/src/components/ProofPopover.tsx @@ -0,0 +1,150 @@ +// The menu over a misspelled word: what the system thinks was meant, and the two ways of saying it +// was not a mistake. +// +// Mounted once and drawing nothing until src/editor/proofing.ts puts a word in the store, so App.tsx +// holds one line for it rather than a piece of the feature. It renders into a portal because the +// document scrolls inside its own pane and a menu clipped by the pane it belongs to is no menu at +// all, and it is positioned in viewport coordinates because that is what the editor measured the +// word in. +// +// It never takes focus. A left click on a misspelled word is somebody putting the caret in a word +// they are about to fix by hand as often as it is somebody asking what else it could have been, and +// a menu that steals the caret out of the sentence being typed has broken the more common of the +// two. So the caret stays where the click put it, typing goes on into the document and dismisses the +// menu on the way, and the buttons refuse the focus a mousedown would otherwise give them. +// +// "Learn Spelling" is the item that has to be honest about what it does. The checker is +// NSSpellChecker and the dictionary is the Mac's, so learning a word here teaches Mail, Notes and +// every other app on the machine, which is what makes it useful and also what makes it more than +// this app's business to do quietly. The note under the buttons says so in the menu, where the +// decision is being made, rather than in a tooltip nobody reads first. + +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { replaceSpelling } from "../editor/proofing"; +import { useEscapeLayer } from "../escape"; +import { useProofing, type ProofTarget } from "../store/useProofing"; + +/** Clearance from the word above and from the edges of the window. */ +const GAP = 6; +const MARGIN = 8; + +export function ProofPopover() { + const target = useProofing((s) => s.target); + if (target === null) return null; + // Keyed so that opening the menu over a second word rebuilds it rather than sliding the first + // one's measurements across. + return ; +} + +function ProofMenu({ target }: { target: ProofTarget }) { + const closeMenu = useProofing((s) => s.closeMenu); + const ignoreWord = useProofing((s) => s.ignoreWord); + const learnWord = useProofing((s) => s.learnWord); + + const popRef = useRef(null); + const [at, setAt] = useState({ left: target.left, top: target.bottom + GAP }); + + useLayoutEffect(() => { + const el = popRef.current; + if (!el) return; + const box = el.getBoundingClientRect(); + const left = Math.max( + MARGIN, + Math.min(target.left - box.width / 2, window.innerWidth - box.width - MARGIN), + ); + // Under the word, unless the window has no room under it, in which case above it. Never over it: + // the word is what the menu is about and covering it hides the mistake being corrected. + const below = target.bottom + GAP; + const top = + below + box.height + MARGIN <= window.innerHeight + ? below + : Math.max(MARGIN, target.top - GAP - box.height); + setAt({ left, top }); + }, [target]); + + useEscapeLayer(true, closeMenu); + + useEffect(() => { + const onDown = (e: MouseEvent) => { + if (popRef.current?.contains(e.target as Node)) return; + closeMenu(); + }; + const close = () => closeMenu(); + document.addEventListener("mousedown", onDown, true); + document.addEventListener("scroll", close, true); + window.addEventListener("resize", close); + return () => { + document.removeEventListener("mousedown", onDown, true); + document.removeEventListener("scroll", close, true); + window.removeEventListener("resize", close); + }; + }, [closeMenu]); + + // The caret belongs to the document, not to this menu, so a press on any of these buttons is not + // allowed to move it. + const keepFocus = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + return createPortal( +
    e.preventDefault()} + > + {target.suggestions.length === 0 ? ( +

    No suggestions

    + ) : ( + target.suggestions.map((suggestion) => ( + + )) + )} + +
    + + + + +

    Learning a word teaches this Mac, not only Margin Docs.

    +
    , + document.body, + ); +} diff --git a/src/components/QuickOpen.tsx b/src/components/QuickOpen.tsx new file mode 100644 index 0000000..e6383fe --- /dev/null +++ b/src/components/QuickOpen.tsx @@ -0,0 +1,174 @@ +// Cmd+P: every file the index knows about, across every open root, matched against the path it +// sits at relative to that root. +// +// The query lives in the store rather than here because the store is also what asks SQLite, and the +// two have to be able to disagree for a moment: the field shows the letter that was just typed +// while the index is still answering the word before it. What sits in between is the debounce +// below, so a typist crosses the IPC boundary once for a word rather than once for a letter. +// +// A slow answer landing after a fast one is already handled on the other side of that boundary, by +// the sequence number in src/store/useSearch.ts, and this file deliberately does not grow a second +// guard for the same race: two of them would have to agree forever, and the day they stopped the +// symptom would be a row from a query nobody can see any more. +// +// An empty field is not an empty palette. Cmd+P with nothing typed offers the documents this +// session has already been in, which is the other half of what people reach for the key for. + +import { useEffect, useState } from "react"; +import { useEscapeLayer } from "../escape"; +import type { MatchRange } from "../ipc"; +import { onCommand } from "../keys/commands"; +import { useKeyContext } from "../keys/keymap"; +import { useDocument } from "../store/useDocument"; +import { useIndex } from "../store/useIndex"; +import { useSearch } from "../store/useSearch"; +import { notify } from "../store/useToast"; +import { useWorkspace, type WorkspaceRoot } from "../store/useWorkspace"; +import { Palette, highlight, type PaletteRow, type PaletteStatus } from "./Palette"; + +/** Long enough that a word is one query rather than five, short enough that the pause between two + * words already has an answer waiting in it. */ +const DEBOUNCE_MS = 90; + +/** How many already-visited documents an empty field offers before it stops being a shortlist. */ +const RECENT_LIMIT = 8; + +interface FileRow extends PaletteRow { + path: string; + name: string; + /** The path relative to its root, whole, because that is the string the index counted its match + * offsets against and a trimmed version of it would highlight the wrong characters. */ + where: string; + ranges: readonly MatchRange[]; + /** The root's own name, or empty. Filled in only when more than one folder is open and the + * relative path alone would be ambiguous between them, and empty for a root that was closed + * while its answer was still in flight. */ + root: string; +} + +const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path; + +function relativeTo(path: string, roots: readonly WorkspaceRoot[]): string { + for (const root of roots) { + if (path.startsWith(`${root.path}/`)) return path.slice(root.path.length + 1); + } + return path; +} + +export function QuickOpen() { + const [open, setOpen] = useState(false); + + const query = useSearch((s) => s.quickOpenQuery); + const setQuery = useSearch((s) => s.setQuickOpenQuery); + const runQuickOpen = useSearch((s) => s.runQuickOpen); + const hits = useSearch((s) => s.quickOpenHits); + const phase = useSearch((s) => s.quickOpenPhase); + const error = useSearch((s) => s.quickOpenError); + const indexPhase = useIndex((s) => s.phase); + const roots = useWorkspace((s) => s.roots); + const select = useWorkspace((s) => s.select); + const history = useDocument((s) => s.history); + const openPath = useDocument((s) => s.path); + const openDocument = useDocument((s) => s.open); + + useEffect( + () => + onCommand("quick-open", () => { + // The field starts empty every time and last time's rows go with it. `runQuickOpen("")` is + // what clears them, and going through the store rather than reaching for the array directly + // is also what bumps its sequence number, so an answer to the query this palette was closed + // on cannot arrive inside the one it was just opened for. + setQuery(""); + void runQuickOpen(""); + setOpen(true); + }), + [setQuery, runQuickOpen], + ); + + useEscapeLayer(open, () => setOpen(false)); + useKeyContext("overlay", open); + + useEffect(() => { + if (!open) return; + const timer = window.setTimeout(() => void runQuickOpen(query), DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [open, query, runQuickOpen]); + + if (!open) return null; + + const choose = (path: string) => { + // Selected as well as opened, so the tree, and every command that acts on the selection, agree + // with the document that is now on screen. Opening a file from here and renaming it with the + // next key otherwise renames whatever was last clicked in the sidebar. + select(path); + openDocument(path).catch((e) => notify(`Could not open: ${String(e)}`)); + }; + + const searching = query.trim() !== ""; + + const recent = () => { + const seen = new Set(); + const rows: FileRow[] = []; + for (let i = history.length - 1; i >= 0 && rows.length < RECENT_LIMIT; i -= 1) { + const path = history[i]; + // The document already on screen is not somewhere to go. + if (path === openPath || seen.has(path)) continue; + seen.add(path); + rows.push({ + key: path, + path, + name: baseName(path), + where: relativeTo(path, roots), + ranges: [], + root: "", + run: () => choose(path), + }); + } + return rows; + }; + + const rows: FileRow[] = searching + ? hits.map((hit) => ({ + key: hit.path, + path: hit.path, + name: hit.name, + where: hit.relPath, + ranges: hit.ranges, + root: roots.length > 1 ? baseName(hit.rootPath) : "", + run: () => choose(hit.path), + })) + : recent(); + + const status = (): PaletteStatus => { + if (phase === "error") { + return { text: error ?? "The search index could not be read.", error: true }; + } + if (!searching) { + return { + text: roots.length === 0 ? "Open a folder first." : "Type to find a file by name or path.", + }; + } + if (phase === "loading") return { text: "Searching…" }; + if (indexPhase === "indexing") return { text: "Still indexing. Try again in a moment." }; + return { text: "No file matches." }; + }; + + return ( + setOpen(false)} + renderRow={(row) => ( + + {row.name} + {highlight(row.where, row.ranges)} + {row.root !== "" && {row.root}} + + )} + /> + ); +} diff --git a/src/components/Recents.tsx b/src/components/Recents.tsx new file mode 100644 index 0000000..0c143ac --- /dev/null +++ b/src/components/Recents.tsx @@ -0,0 +1,67 @@ +// The start screen: what the window shows before any folder is open. A quiet list rather than a +// grid of cards, because a folder has no cover and pretending otherwise would just be a row of +// identical rectangles. + +import { commandLabel, runCommand } from "../keys/commands"; +import { notify } from "../store/useToast"; +import { useWorkspace } from "../store/useWorkspace"; +import { addRoot } from "../workspace"; +import { Icon } from "./Icon"; +import { shortcutTitle } from "./Titlebar"; + +const FOLDER = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"; +const OPEN_FOLDER = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M12 10v6 M9 13h6"; + +const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path; +const parentOf = (path: string): string => path.slice(0, path.lastIndexOf("/")) || "/"; + +export function Recents() { + const recentFolders = useWorkspace((s) => s.recentFolders); + const scanPhase = useWorkspace((s) => s.scanPhase); + + // `openFolder` is the picker and takes no path, so a folder that is already known is opened + // through the effects module directly rather than by asking the user to find it again. + const open = (path: string) => { + addRoot(path).catch((e) => notify(`Could not open that folder: ${String(e)}`)); + }; + + return ( +
    +
    +
    +

    Margin Docs

    +

    + Open a folder of markdown files. Nothing is copied, nothing is imported, and nothing is + written until you make an edit. +

    + + + + {recentFolders.length > 0 && ( +
    +
    Recent
    +
      + {recentFolders.map((path) => ( +
    • + +
    • + ))} +
    +
    + )} +
    +
    + ); +} diff --git a/src/components/ResizeHandle.tsx b/src/components/ResizeHandle.tsx new file mode 100644 index 0000000..585c545 --- /dev/null +++ b/src/components/ResizeHandle.tsx @@ -0,0 +1,78 @@ +// The sidebar's drag edge. The width it writes is the `--pane-sidebar` token itself, set on the +// root element, so the stylesheet keeps owning the layout and this only moves a number. + +import { useLayoutEffect, type PointerEvent } from "react"; + +const VAR = "--pane-sidebar"; +const KEY = "margindocs-pane-sidebar"; +const DEFAULT = 248; +const MIN = 200; +const MAX = 460; + +const clamp = (px: number): number => Math.round(Math.min(MAX, Math.max(MIN, px))); + +function currentWidth(): number { + const raw = getComputedStyle(document.documentElement).getPropertyValue(VAR); + const px = parseInt(raw, 10); + return Number.isFinite(px) && px > 0 ? px : DEFAULT; +} + +function applyWidth(px: number): void { + const width = clamp(px); + document.documentElement.style.setProperty(VAR, `${width}px`); + try { + localStorage.setItem(KEY, String(width)); + } catch { + // A webview with storage denied still gets a working drag, just not a remembered one. + } +} + +function resetWidth(): void { + document.documentElement.style.removeProperty(VAR); + try { + localStorage.removeItem(KEY); + } catch { + // See above. + } +} + +export function ResizeHandle() { + useLayoutEffect(() => { + try { + const saved = parseInt(localStorage.getItem(KEY) ?? "", 10); + if (Number.isFinite(saved) && saved > 0) + document.documentElement.style.setProperty(VAR, `${clamp(saved)}px`); + } catch { + // See above. + } + }, []); + + const onPointerDown = (e: PointerEvent) => { + e.preventDefault(); + const handle = e.currentTarget; + const startX = e.clientX; + const startWidth = currentWidth(); + handle.setPointerCapture(e.pointerId); + document.documentElement.setAttribute("data-resizing", ""); + + const onMove = (ev: globalThis.PointerEvent) => applyWidth(startWidth + (ev.clientX - startX)); + const onUp = () => { + document.documentElement.removeAttribute("data-resizing"); + handle.removeEventListener("pointermove", onMove); + handle.removeEventListener("pointerup", onUp); + }; + handle.addEventListener("pointermove", onMove); + handle.addEventListener("pointerup", onUp); + }; + + return ( +
    + ); +} diff --git a/src/components/RowMenu.tsx b/src/components/RowMenu.tsx new file mode 100644 index 0000000..40e672e --- /dev/null +++ b/src/components/RowMenu.tsx @@ -0,0 +1,187 @@ +// The popup menu a row offers, in two forms over one body: a "..." button that opens it under +// itself, and a bare popup anchored to the point a right click happened. Both render into a portal +// so a menu is never clipped by the scrolling tree it belongs to. + +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useEscapeLayer } from "../escape"; +import { Icon } from "./Icon"; + +export interface RowMenuItem { + id: string; + label: string; + /** A Feather-style 24x24 stroke path, the same shape `` takes everywhere else. */ + icon: string; + danger?: boolean; + run: () => void; +} + +/** A hairline between groups of items. Written inline in the array so the order stays readable. */ +export type RowMenuEntry = RowMenuItem | "sep"; + +const MENU_ITEM = ".row-menu-item"; + +interface PopProps { + x: number; + y: number; + /** A thunk, not an array: a tree of a thousand rows should not build a thousand menus it will + * never show, and the items a row offers can depend on state that moved since it was drawn. */ + items: () => readonly RowMenuEntry[]; + onClose: () => void; + /** Where focus goes when the menu closes, so keyboard use does not land back at the document. */ + restoreFocus?: () => void; +} + +function MenuPop({ x, y, items, onClose, restoreFocus }: PopProps) { + const popRef = useRef(null); + const [pos, setPos] = useState({ left: x, top: y }); + + useLayoutEffect(() => { + const el = popRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + setPos({ + left: Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)), + top: Math.max(8, Math.min(y, window.innerHeight - rect.height - 8)), + }); + }, [x, y]); + + useEffect(() => { + popRef.current?.querySelector(MENU_ITEM)?.focus(); + }, []); + + useEscapeLayer(true, () => { + onClose(); + restoreFocus?.(); + }); + + useEffect(() => { + const onDown = (e: MouseEvent) => { + if (popRef.current?.contains(e.target as Node)) return; + onClose(); + }; + const close = () => onClose(); + document.addEventListener("mousedown", onDown, true); + document.addEventListener("scroll", close, true); + window.addEventListener("resize", close); + return () => { + document.removeEventListener("mousedown", onDown, true); + document.removeEventListener("scroll", close, true); + window.removeEventListener("resize", close); + }; + }, [onClose]); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + e.preventDefault(); + const all = Array.from(popRef.current?.querySelectorAll(MENU_ITEM) ?? []); + if (!all.length) return; + const at = all.indexOf(document.activeElement as HTMLElement); + const next = e.key === "ArrowDown" ? (at + 1) % all.length : (at - 1 + all.length) % all.length; + all[next]?.focus(); + }; + + const choose = (e: React.MouseEvent, item: RowMenuItem) => { + e.stopPropagation(); + onClose(); + item.run(); + }; + + return createPortal( +
    e.preventDefault()} + > + {items().map((item, i) => + item === "sep" ? ( +
    + ) : ( + + ), + )} +
    , + document.body, + ); +} + +interface RowMenuProps { + label: string; + items: () => readonly RowMenuEntry[]; + onOpenChange?: (open: boolean) => void; + className?: string; +} + +/** The "..." trigger, for a row that has room to show one. */ +export function RowMenu({ label, items, onOpenChange, className = "" }: RowMenuProps) { + const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null); + const btnRef = useRef(null); + const wasOpen = useRef(false); + + useEffect(() => { + const open = anchor !== null; + if (open === wasOpen.current) return; + wasOpen.current = open; + onOpenChange?.(open); + }, [anchor, onOpenChange]); + + const toggle = (e: React.MouseEvent) => { + e.stopPropagation(); + if (anchor) { + setAnchor(null); + return; + } + const rect = btnRef.current?.getBoundingClientRect(); + if (rect) setAnchor({ x: rect.left, y: rect.bottom + 4 }); + }; + + return ( + <> + + {anchor && ( + setAnchor(null)} + restoreFocus={() => btnRef.current?.focus()} + /> + )} + + ); +} + +interface RowMenuAtProps { + x: number; + y: number; + items: () => readonly RowMenuEntry[]; + onClose: () => void; +} + +/** The same menu opened at a point, which is what a right click on a row produces. */ +export function RowMenuAt({ x, y, items, onClose }: RowMenuAtProps) { + return ; +} diff --git a/src/components/Shortcuts.tsx b/src/components/Shortcuts.tsx new file mode 100644 index 0000000..4f33951 --- /dev/null +++ b/src/components/Shortcuts.tsx @@ -0,0 +1,69 @@ +// Every key the app answers to, generated from src/keys/bindings.ts rather than written out here. +// That is the point of the table over there: a binding that exists is a binding this sheet shows, +// so the two cannot drift and there is no list to remember to update. + +import { useEffect, useState } from "react"; +import { useEscapeLayer } from "../escape"; +import { BINDINGS, GROUPS, bindingLabel, keyLabel } from "../keys/bindings"; +import { commandLabel, onCommand } from "../keys/commands"; +import { useKeyContext } from "../keys/keymap"; +import { Icon } from "./Icon"; + +export function Shortcuts() { + const [open, setOpen] = useState(false); + + useEffect(() => onCommand("shortcuts", () => setOpen((v) => !v)), []); + useEscapeLayer(open, () => setOpen(false)); + useKeyContext("overlay", open); + + if (!open) return null; + + return ( +
    setOpen(false)}> +
    e.stopPropagation()} + > +
    +

    Keyboard shortcuts

    + +
    +
    + {GROUPS.map((group) => { + const rows = BINDINGS.filter((binding) => binding.group === group); + if (!rows.length) return null; + return ( +
    +
    {group}
    +
      + {rows.map((binding) => ( +
    • + {bindingLabel(binding, commandLabel)} + + {binding.keys.map((combo) => ( + + {keyLabel(combo)} + + ))} + +
    • + ))} +
    +
    + ); + })} +
    +
    +
    + ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..1b1e3f3 --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,513 @@ +// Every open folder, one tree each. This component owns everything that spans roots: which row is +// selected, which row holds the tab stop, the arrow-key walk over the visible rows, the drag +// gesture and the menus. FileTree.tsx below it only draws. + +import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type PointerEvent } from "react"; +import { openExternal } from "../api/roots"; +import { commandLabel, runCommand } from "../keys/commands"; +import { useDocument } from "../store/useDocument"; +import { notify } from "../store/useToast"; +import { useWorkspace, type TreeNode } from "../store/useWorkspace"; +import { movePath } from "../workspace"; +import { ConfirmDialog } from "./ConfirmDialog"; +import { + FileTree, + flattenTree, + splitExtension, + type DropTarget, + type TreeHandlers, + type TreeRow, + type TreeViewState, +} from "./FileTree"; +import { Icon } from "./Icon"; +import { ResizeHandle } from "./ResizeHandle"; +import { RowMenuAt, type RowMenuEntry } from "./RowMenu"; +import { shortcutTitle } from "./Titlebar"; + +const EXPANDED_KEY = "margindocs-expanded"; +const ROOTS_SEEN_KEY = "margindocs-roots-seen"; + +/** How far the pointer travels before a press on a row becomes a drag rather than a click. */ +const DRAG_SLOP = 4; +/** How long a drag hovers a closed folder before it springs open, the way Finder does. */ +const SPRING_MS = 650; + +const OPEN_FOLDER = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M12 10v6 M9 13h6"; +const NEW_DOC_ICON = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5 M12 12v5 M9.5 14.5h5"; +const NEW_FOLDER_ICON = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M12 11v6 M9 14h6"; +const RENAME_ICON = "M4 20h4L20 8l-4-4L4 16z M14 6l4 4"; +const DUPLICATE_ICON = "M9 9h11v11H9z M6 15V5h9"; +const REVEAL_ICON = "M9 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4 M15 3h6v6 M10 14L21 3"; +const COPY_PATH_ICON = "M8 4h8a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z M9 2h6v4H9z"; +const TRASH_ICON = "M5 7h14M10 7V5h4v2M7 7l1 13h8l1-13M10 11v6M14 11v6"; +const CLOSE_ICON = "M18 6L6 18M6 6l12 12"; + +function readList(key: string): string[] { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(key) ?? "[]"); + return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : []; + } catch { + return []; + } +} + +function writeList(key: string, values: readonly string[]): void { + try { + localStorage.setItem(key, JSON.stringify(values)); + } catch { + // A webview with storage denied still works, it just forgets the shape of the tree. + } +} + +/** + * Where the pointer says the dragged row should land. + * + * A filesystem has no row order to insert into, so all three answers are one destination + * directory: the middle of a folder means that folder, and the edges of any row mean the folder + * that row already lives in. Reading the answer off the DOM rather than off the row array is what + * keeps this working while the tree scrolls under the pointer. + */ +function dropAt(x: number, y: number): DropTarget | null { + const el = document.elementFromPoint(x, y) as HTMLElement | null; + if (!el) return null; + + const row = el.closest(".tree-row"); + if (row?.dataset.path) { + const path = row.dataset.path; + const parent = row.dataset.parent ?? ""; + const rect = row.getBoundingClientRect(); + const at = (y - rect.top) / rect.height; + if (row.dataset.dir === "true") { + if (!parent || (at > 0.25 && at < 0.75)) return { dir: path, mode: "into", row: path }; + return { dir: parent, mode: at <= 0.25 ? "before" : "after", row: path }; + } + if (!parent) return null; + return { dir: parent, mode: at < 0.5 ? "before" : "after", row: path }; + } + + // The indent gutter of a nested list belongs to the folder that owns the list, not to the root. + const owner = el + .closest(".tree-item") + ?.querySelector(":scope > .tree-row"); + if (owner?.dataset.path) return { dir: owner.dataset.path, mode: "into", row: owner.dataset.path }; + + const section = el.closest(".tree-section"); + if (section?.dataset.root) return { dir: section.dataset.root, mode: "into", row: section.dataset.root }; + return null; +} + +/** A drop that would not move anything, or would move a folder inside itself, is not a drop. */ +function usableDrop(target: DropTarget | null, path: string, parent: string): DropTarget | null { + if (!target) return null; + if (target.dir === parent) return null; + if (target.dir === path || target.dir.startsWith(`${path}/`)) return null; + return target; +} + +const sameDrop = (a: DropTarget | null, b: DropTarget | null): boolean => + a?.dir === b?.dir && a?.mode === b?.mode && a?.row === b?.row; + +export function Sidebar() { + const roots = useWorkspace((s) => s.roots); + const expanded = useWorkspace((s) => s.expanded); + const selectedPath = useWorkspace((s) => s.selectedPath); + const scanPhase = useWorkspace((s) => s.scanPhase); + const select = useWorkspace((s) => s.select); + const toggleExpanded = useWorkspace((s) => s.toggleExpanded); + const newDocument = useWorkspace((s) => s.newDocument); + const newFolder = useWorkspace((s) => s.newFolder); + const renameEntry = useWorkspace((s) => s.renameEntry); + const duplicateEntry = useWorkspace((s) => s.duplicateEntry); + const deleteEntry = useWorkspace((s) => s.deleteEntry); + const revealInFinder = useWorkspace((s) => s.revealInFinder); + const closeFolder = useWorkspace((s) => s.closeFolder); + const openDocument = useDocument((s) => s.open); + const openPath = useDocument((s) => s.path); + + const [hydrated, setHydrated] = useState(false); + const [renamingPath, setRenamingPath] = useState(null); + const [menuOpenPath, setMenuOpenPath] = useState(null); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; row: TreeRow } | null>(null); + const [pendingDelete, setPendingDelete] = useState(null); + const [dragPath, setDragPath] = useState(null); + const [dropTarget, setDropTarget] = useState(null); + + const gesture = useRef<{ path: string; parent: string; x: number; y: number; active: boolean } | null>(null); + const suppressClick = useRef(false); + const dropRef = useRef(null); + const spring = useRef(null); + + useEffect(() => { + const already = useWorkspace.getState().expanded; + for (const path of readList(EXPANDED_KEY)) if (!already.has(path)) toggleExpanded(path); + setHydrated(true); + }, [toggleExpanded]); + + useEffect(() => { + if (!hydrated) return; + writeList(EXPANDED_KEY, [...expanded]); + }, [hydrated, expanded]); + + // A folder the user has only just opened should show its contents. One that they opened months + // ago and then collapsed should stay collapsed, which is why "seen" is remembered separately + // rather than inferred from an empty expansion set. + useEffect(() => { + if (!hydrated) return; + const seen = readList(ROOTS_SEEN_KEY); + const fresh = roots.filter((r) => !seen.includes(r.path)); + if (!fresh.length) return; + const already = useWorkspace.getState().expanded; + for (const root of fresh) if (!already.has(root.path)) toggleExpanded(root.path); + writeList(ROOTS_SEEN_KEY, [...seen, ...fresh.map((r) => r.path)]); + }, [hydrated, roots, toggleExpanded]); + + useEffect( + () => () => { + if (spring.current !== null) clearTimeout(spring.current); + }, + [], + ); + + const rootNodes: TreeNode[] = useMemo( + () => + roots.map((root) => ({ + path: root.path, + name: root.name, + isDir: true, + editable: false, + children: root.tree, + })), + [roots], + ); + + const rows = useMemo( + () => rootNodes.flatMap((node) => flattenTree([node], expanded, 0, "")), + [rootNodes, expanded], + ); + + const tabStopPath = + rows.find((r) => r.node.path === selectedPath)?.node.path ?? rows[0]?.node.path ?? null; + + const focusRow = (path: string) => + requestAnimationFrame(() => { + document.querySelector(`.tree-row[data-path="${CSS.escape(path)}"]`)?.focus(); + }); + + const moveFocus = (row: TreeRow | undefined) => { + if (!row) return; + select(row.node.path); + focusRow(row.node.path); + }; + + const activate = (node: TreeNode) => { + if (suppressClick.current) { + suppressClick.current = false; + return; + } + select(node.path); + if (node.isDir) { + toggleExpanded(node.path); + return; + } + if (node.editable) { + openDocument(node.path).catch((e) => notify(`Could not open: ${String(e)}`)); + return; + } + openExternal(node.path).catch((e) => notify(`Could not open: ${String(e)}`)); + }; + + const onKeyDown = (e: KeyboardEvent, row: TreeRow) => { + const at = rows.findIndex((r) => r.node.path === row.node.path); + switch (e.key) { + case "Enter": + case " ": + e.preventDefault(); + activate(row.node); + break; + case "ArrowDown": + e.preventDefault(); + moveFocus(rows[at + 1]); + break; + case "ArrowUp": + e.preventDefault(); + moveFocus(rows[at - 1]); + break; + case "Home": + e.preventDefault(); + moveFocus(rows[0]); + break; + case "End": + e.preventDefault(); + moveFocus(rows[rows.length - 1]); + break; + case "ArrowRight": + e.preventDefault(); + if (!row.node.isDir) break; + if (!expanded.has(row.node.path)) toggleExpanded(row.node.path); + else moveFocus(rows[at + 1]); + break; + case "ArrowLeft": + e.preventDefault(); + if (row.node.isDir && expanded.has(row.node.path)) toggleExpanded(row.node.path); + else if (row.parentPath) moveFocus(rows.find((r) => r.node.path === row.parentPath)); + break; + default: + break; + } + }; + + const setDrop = (target: DropTarget | null) => { + if (sameDrop(dropRef.current, target)) return; + dropRef.current = target; + setDropTarget(target); + if (spring.current !== null) { + clearTimeout(spring.current); + spring.current = null; + } + if (target?.mode !== "into") return; + const dir = target.dir; + if (useWorkspace.getState().expanded.has(dir)) return; + spring.current = window.setTimeout(() => { + spring.current = null; + if (dropRef.current?.dir === dir) useWorkspace.getState().toggleExpanded(dir); + }, SPRING_MS); + }; + + const onPointerMove = (e: globalThis.PointerEvent) => { + const g = gesture.current; + if (!g) return; + if (!g.active) { + if (Math.abs(e.clientX - g.x) < DRAG_SLOP && Math.abs(e.clientY - g.y) < DRAG_SLOP) return; + g.active = true; + setDragPath(g.path); + } + setDrop(usableDrop(dropAt(e.clientX, e.clientY), g.path, g.parent)); + }; + + // The disk half of a drop. Everything it does now lives in src/workspace.ts beside renamePath, + // which is where it belonged: refreshing both folders, following the selection, and rewriting the + // relative links the move broke. Calling fileMove from here would move the bytes and leave every + // link pointing at the old path. + const moveInto = async (path: string, dir: string) => { + await movePath(path, dir); + }; + + const onPointerUp = () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + const g = gesture.current; + gesture.current = null; + const target = dropRef.current; + if (g?.active) { + suppressClick.current = true; + if (target) moveInto(g.path, target.dir).catch((e) => notify(`Could not move: ${String(e)}`)); + } + setDragPath(null); + setDrop(null); + }; + + const onPointerDown = (e: PointerEvent, row: TreeRow) => { + suppressClick.current = false; + if (e.button !== 0 || renamingPath === row.node.path || menuOpenPath === row.node.path) return; + // A root is where its folder lives on disk, not a row inside a tree, so it does not move. + if (!row.parentPath) return; + const target = e.target as HTMLElement; + if (target.closest(".row-menu-btn") || target.closest(".tree-twisty")) return; + gesture.current = { path: row.node.path, parent: row.parentPath, x: e.clientX, y: e.clientY, active: false }; + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + }; + + const onContextMenu = (e: MouseEvent, row: TreeRow) => { + e.preventDefault(); + e.stopPropagation(); + select(row.node.path); + setContextMenu({ x: e.clientX, y: e.clientY, row }); + }; + + // Open first, then offer the rename: the editor takes focus as it mounts, and a rename field + // that opened before it would be blurred out from under the user mid-word. + const createDocument = (dir: string) => { + newDocument(dir) + .then((path) => { + select(path); + return openDocument(path).then(() => setRenamingPath(path)); + }) + .catch((e) => notify(`Could not create the document: ${String(e)}`)); + }; + + const createFolder = (dir: string) => { + newFolder(dir) + .then((path) => { + select(path); + setRenamingPath(path); + }) + .catch((e) => notify(`Could not create the folder: ${String(e)}`)); + }; + + const copyPath = (path: string) => { + navigator.clipboard + .writeText(path) + .then(() => notify("Path copied")) + .catch(() => notify("Could not copy the path")); + }; + + const menuItems = (row: TreeRow): readonly RowMenuEntry[] => { + const node = row.node; + const dir = node.isDir ? node.path : row.parentPath; + const isRoot = !row.parentPath; + const items: RowMenuEntry[] = [ + { id: "new-doc", label: "New Document", icon: NEW_DOC_ICON, run: () => createDocument(dir) }, + { id: "new-folder", label: "New Folder", icon: NEW_FOLDER_ICON, run: () => createFolder(dir) }, + ]; + if (!isRoot) { + items.push("sep"); + items.push({ + id: "rename", + label: "Rename", + icon: RENAME_ICON, + run: () => setRenamingPath(node.path), + }); + items.push({ + id: "duplicate", + label: "Duplicate", + icon: DUPLICATE_ICON, + run: () => + duplicateEntry(node.path).catch((e) => notify(`Could not duplicate: ${String(e)}`)), + }); + } + items.push("sep"); + items.push({ + id: "reveal", + label: "Reveal in Finder", + icon: REVEAL_ICON, + run: () => + revealInFinder(node.path).catch((e) => notify(`Could not reveal in Finder: ${String(e)}`)), + }); + items.push({ id: "copy-path", label: "Copy Path", icon: COPY_PATH_ICON, run: () => copyPath(node.path) }); + items.push("sep"); + if (isRoot) + items.push({ + id: "close-folder", + label: "Close Folder", + icon: CLOSE_ICON, + run: () => closeFolder(node.path), + }); + else + items.push({ + id: "delete", + label: "Delete", + icon: TRASH_ICON, + danger: true, + run: () => setPendingDelete(node), + }); + return items; + }; + + const view: TreeViewState = { + expanded, + selectedPath, + tabStopPath, + draggingPath: dragPath, + dropTarget, + renamingPath, + openPath, + }; + + const handlers: TreeHandlers = { + onActivate: activate, + onToggle: (node) => { + select(node.path); + toggleExpanded(node.path); + }, + onKeyDown, + onPointerDown, + onContextMenu, + onMenuOpenChange: (path, open) => + setMenuOpenPath((current) => (open ? path : current === path ? null : current)), + menuItems, + // The row hides the extension, so the rename has to put back the one it took away rather than + // quietly turning notes.md into a file with no extension at all. + onRenameCommit: (node, typed) => { + setRenamingPath(null); + const { base, hidden } = splitExtension(node.name, node.isDir); + const trimmed = typed.trim(); + if (!trimmed || trimmed === base) return; + renameEntry(node.path, `${trimmed}${hidden}`).catch((e) => + notify(`Could not rename: ${String(e)}`), + ); + }, + onRenameCancel: () => setRenamingPath(null), + }; + + return ( + <> + + + + + {contextMenu && ( + menuItems(contextMenu.row)} + onClose={() => setContextMenu(null)} + /> + )} + + {pendingDelete && ( + + Move {pendingDelete.name} to the Trash? You can put it back from + Finder. + + } + confirmLabel="Move to Trash" + onConfirm={() => { + const path = pendingDelete.path; + setPendingDelete(null); + deleteEntry(path).catch((e) => notify(`Could not delete: ${String(e)}`)); + }} + onClose={() => setPendingDelete(null)} + /> + )} + + ); +} diff --git a/src/components/Titlebar.tsx b/src/components/Titlebar.tsx new file mode 100644 index 0000000..84a4405 --- /dev/null +++ b/src/components/Titlebar.tsx @@ -0,0 +1,227 @@ +// The macOS overlay title bar. The traffic lights float over its left end, which is why the row +// itself carries `data-tauri-drag-region` and every control inside it does not: an interactive +// element that also drags the window swallows its own click. + +import { useEffect, useRef, useState } from "react"; +import { useEscapeLayer } from "../escape"; +import { keyLabel, keysFor } from "../keys/bindings"; +import { commandLabel, onCommand, runCommand, type CommandId } from "../keys/commands"; +import { useDocument } from "../store/useDocument"; +import { useTheme } from "../store/useTheme"; +import { notify } from "../store/useToast"; +import { useWorkspace } from "../store/useWorkspace"; +import { splitExtension } from "./FileTree"; +import { Icon } from "./Icon"; +import { WidthMenu } from "./WidthMenu"; + + +const SIDEBAR_KEY = "margindocs-sidebar"; + +const SIDEBAR_ICON = "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z M9 3v18"; +const NEW_DOC = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5 M12 12v5 M9.5 14.5h5"; +const SEARCH = "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14z M20 20l-3.6-3.6"; +const SUN = "M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10z M12 1v2 M12 21v2 M4.2 4.2l1.4 1.4 M18.4 18.4l1.4 1.4 M1 12h2 M21 12h2 M4.2 19.8l1.4-1.4 M18.4 5.6l1.4-1.4"; +const MOON = "M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"; +const MORE = "M5 12h.01M12 12h.01M19 12h.01"; + +/** A tooltip that names the action and prints its key in the glyphs the sheet uses. */ +export function shortcutTitle(id: CommandId): string { + const keys = keysFor(id); + return keys.length ? `${commandLabel(id)} (${keyLabel(keys[0])})` : commandLabel(id); +} + +export function Titlebar() { + const path = useDocument((s) => s.path); + const dirty = useDocument((s) => s.dirty); + const externalChange = useDocument((s) => s.externalChange); + const renameEntry = useWorkspace((s) => s.renameEntry); + const theme = useTheme((s) => s.theme); + const toggleTheme = useTheme((s) => s.toggle); + + const [sidebar, setSidebar] = useState( + () => document.documentElement.getAttribute("data-sidebar") !== "false", + ); + const [menu, setMenu] = useState(false); + const [renaming, setRenaming] = useState(false); + + useEffect(() => { + document.documentElement.setAttribute("data-sidebar", String(sidebar)); + try { + localStorage.setItem(SIDEBAR_KEY, String(sidebar)); + } catch { + // A webview with storage denied still toggles, it just forgets between launches. + } + }, [sidebar]); + + useEffect(() => onCommand("toggle-sidebar", () => setSidebar((v) => !v)), []); + useEffect(() => setRenaming(false), [path]); + + useEscapeLayer(menu, () => setMenu(false)); + + const fileName = path ? path.slice(path.lastIndexOf("/") + 1) : ""; + const { base, hidden } = splitExtension(fileName); + + // The filename and the document's H1 are unrelated, and this is the place that promise is + // easiest to break. What the title bar shows is the file on disk, and the only thing that ever + // renames it is the user typing here. Editing a heading is a content edit and nothing else: it + // does not move the file, because a path is what git, every other editor and every relative + // link from another document already agreed on. + const commitRename = (next: string) => { + setRenaming(false); + const trimmed = next.trim(); + if (!path || !trimmed || trimmed === base) return; + renameEntry(path, `${trimmed}${hidden}`).catch((e) => notify(`Could not rename: ${String(e)}`)); + }; + + const menuItem = (id: CommandId) => ( + + ); + + return ( +
    +
    + +
    + + {path && + (renaming ? ( + setRenaming(false)} /> + ) : ( + + ))} + +
    + + + + +
    + + {menu && ( + <> +
    setMenu(false)} /> +
    + {menuItem("open-folder")} + {menuItem("new-folder")} +
    + {menuItem("find-in-files")} + {menuItem("shortcuts")} + {menuItem("settings")} +
    + {menuItem("check-updates")} + {menuItem("report-issue")} +
    + + )} +
    +
    +
    + ); +} + +function TitleRename({ + value, + onCommit, + onCancel, +}: { + value: string; + onCommit: (next: string) => void; + onCancel: () => void; +}) { + const ref = useRef(null); + const settled = useRef(false); + + useEffect(() => { + const input = ref.current; + if (!input) return; + input.focus(); + input.select(); + }, []); + + const commit = (next: string) => { + if (settled.current) return; + settled.current = true; + onCommit(next); + }; + + useEscapeLayer(true, () => { + settled.current = true; + onCancel(); + }); + + return ( + commit(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + commit(e.currentTarget.value); + }} + /> + ); +} diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 0000000..df849aa --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,23 @@ +import { useEffect } from "react"; +import { useToast } from "../store/useToast"; + +const DWELL_MS = 4200; + +export function Toast() { + const message = useToast((s) => s.message); + const dismiss = useToast((s) => s.dismiss); + + useEffect(() => { + if (!message) return; + const timer = setTimeout(dismiss, DWELL_MS); + return () => clearTimeout(timer); + }, [message, dismiss]); + + if (!message) return null; + + return ( +
    + {message} +
    + ); +} diff --git a/src/components/WidthMenu.tsx b/src/components/WidthMenu.tsx new file mode 100644 index 0000000..59a2844 --- /dev/null +++ b/src/components/WidthMenu.tsx @@ -0,0 +1,189 @@ +// The width control there was no way to click: a title bar button that shows the applied width and +// opens the three named steps with the current one marked. +// +// It belongs beside the theme toggle rather than in the editor pill. Everything in the pill edits +// the file; this edits the app's view of it and touches no byte on disk, and the title bar already +// holds the other two of exactly that kind, the sidebar and the theme, both persisted under the +// same `margindocs-` prefix and both restored by the same boot script. The pill is also the wrong +// place mechanically: its tools go dead while a save conflict is open, and being unable to widen +// the page because the file moved on disk is nonsense, and the foot of src/styles/toolbar.css +// records that the row is already four pixels over the pane at the app's minimum window. +// +// The DOM is the source of truth and this component's state is a cache of it. `applyWidth` writes +// `data-width` on the root element and index.html's boot script writes it before React exists, +// while the keyboard commands and the native menu both call `applyWidth` without telling anyone, +// so the attribute is read on mount and watched with a MutationObserver. A component that +// remembered the last width it set itself would open showing the wrong one the first time somebody +// reached for the key instead. +// +// Focus is not taken from the document. A mouse press on any button here is prevented, so the +// caret stays in the sentence somebody is in the middle of; only a keyboard open moves focus into +// the menu, and closing puts it back where it came from. + +import { useEffect, useId, useRef, useState } from "react"; +import { useEscapeLayer } from "../escape"; +import { applyWidth, WIDTHS, type EditorWidth } from "../width"; +import { Icon } from "./Icon"; + +const ITEM = ".width-menu-item"; + +const CHECK_D = "M20 6L9 17l-5-5"; + +/** The page's two edges with three lines of text between them, so the button says which width is + * applied without spending a word of the title bar on it. The edges never move and only the + * measure does, which is the whole of what the setting changes. The sibling's glyph for this is a + * double headed arrow, and it is not ported: an arrow six units long is a smudge at 16px, which is + * the only size this is ever drawn at. */ +const WIDTH_ICON: Record = { + narrow: "M3 4v16M21 4v16M9 7h6M9 12h6M9 17h6", + normal: "M3 4v16M21 4v16M7 7h10M7 12h10M7 17h10", + wide: "M3 4v16M21 4v16M5 7h14M5 12h14M5 17h14", +}; + +function isWidth(value: string | null): value is EditorWidth { + return WIDTHS.includes(value as EditorWidth); +} + +/** Capitalised for a menu. The names themselves belong to src/width.ts and the command ids. */ +function widthLabel(width: EditorWidth): string { + return width.charAt(0).toUpperCase() + width.slice(1); +} + +/** No attribute at all is the default, because sheet.css only writes rules for narrow and wide and + * the boot script only sets the attribute when something was saved. */ +function appliedWidth(): EditorWidth { + const value = document.documentElement.getAttribute("data-width"); + return isWidth(value) ? value : "normal"; +} + +function useAppliedWidth(): EditorWidth { + const [width, setWidth] = useState(appliedWidth); + + useEffect(() => { + const read = () => setWidth(appliedWidth()); + const observer = new MutationObserver(read); + observer.observe(document.documentElement, { attributeFilter: ["data-width"] }); + // The attribute can have moved between the first render and this effect running. + read(); + return () => observer.disconnect(); + }, []); + + return width; +} + +export function WidthMenu() { + const width = useAppliedWidth(); + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + /** Where focus was when a keyboard user opened the menu, and null when a mouse user did, since + * that press never moved it. */ + const returnTo = useRef(null); + const focusOnOpen = useRef(false); + const labelId = useId(); + + const close = (restoreFocus = true) => { + setOpen(false); + const el = returnTo.current; + returnTo.current = null; + if (restoreFocus && el?.isConnected) el.focus(); + }; + + useEscapeLayer(open, () => close()); + + useEffect(() => { + if (!open || !focusOnOpen.current) return; + focusOnOpen.current = false; + menuRef.current?.querySelector(ITEM)?.focus(); + }, [open]); + + const toggle = (e: React.MouseEvent) => { + if (open) { + close(); + return; + } + // `detail` is 0 when Enter or Space activated the button and 1 when a pointer did. A keyboard + // user cannot reach the items unless focus is moved into the menu; a mouse user is mid + // sentence and would lose their caret to a setting that has nothing to do with the text. + const byKeyboard = e.detail === 0; + returnTo.current = byKeyboard ? (document.activeElement as HTMLElement | null) : null; + focusOnOpen.current = byKeyboard; + setOpen(true); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + e.preventDefault(); + const all = Array.from(menuRef.current?.querySelectorAll(ITEM) ?? []); + const at = all.indexOf(document.activeElement as HTMLElement); + const next = e.key === "ArrowDown" ? (at + 1) % all.length : (at - 1 + all.length) % all.length; + all[next]?.focus(); + }; + + const choose = (next: EditorWidth) => { + applyWidth(next); + close(); + }; + + const name = `Editor width: ${widthLabel(width)}`; + + return ( +
    { + if (!open || e.currentTarget.contains(e.relatedTarget)) return; + close(false); + }} + > + + {open && ( + <> +
    close()} /> +
    + {/* Three words that mean nothing on their own, so the menu says what they are a width + of and then lends the same line to assistive tech as its own name. Presentational + because a menu's children are meant to be its items, and because being announced as + the menu's name and again as a line inside it is the same sentence twice. */} + + {WIDTHS.map((w) => ( + + ))} +
    + + )} +
    + ); +} diff --git a/src/dev/fixture.ts b/src/dev/fixture.ts new file mode 100644 index 0000000..e897c27 --- /dev/null +++ b/src/dev/fixture.ts @@ -0,0 +1,290 @@ +// A dev-only folder of documents, held in memory. It exists so the real UI can be opened in a +// plain browser with no Tauri behind it, by a person or by Playwright, without pointing the app at +// anybody's actual files. +// +// It is shaped like a folder somebody would really have rather than three files called test.md, +// because every interesting case in this app is a case the tree has to render: nesting several +// levels deep, a .txt that is editable, a .png that is not, an assets folder beside a document, +// frontmatter, a callout, a table, a toggle, a code block, and relative links between documents +// so backlinks have something to find. +// +// Anchored to the current time at load, so the tree never shows a modified date from last year. + +import type { FileKind, RootInfo } from "../ipc"; + +const MINUTE = 60_000; +const HOUR = 3_600_000; +const DAY = 86_400_000; + +const now = Date.now(); + +/** One file or directory. `text` is empty for a directory and for anything binary. */ +export interface DevEntry { + path: string; + dir: boolean; + text: string; + /** Not a text file. Greyed in the tree, opened by the system, refused by `file_read`. */ + binary: boolean; + modifiedMs: number; +} + +export const HANDBOOK = "/Users/you/Documents/Handbook"; +export const SCRATCH = "/Users/you/Documents/Scratch"; + +/** Stable for a given path, the way the Rust side derives a root id from the folder it opened. */ +export const rootIdFor = (path: string): string => + path + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + +export const devRoots: RootInfo[] = [ + { id: rootIdFor(HANDBOOK), path: HANDBOOK, name: "Handbook", openedMs: now - 6 * DAY }, + { id: rootIdFor(SCRATCH), path: SCRATCH, name: "Scratch", openedMs: now - 2 * HOUR }, +]; + +const readme = `--- +title: Handbook +updated: 2026-02-11 +tags: [team, reference] +--- + +# Handbook + +Everything the team needs, in one folder, in plain markdown. Nothing here is generated and nothing +here needs an account to read. + +Start with [Getting started](guides/getting-started.md). Skim [Writing](guides/writing.md) before +you open your first pull request, and keep the +[keyboard reference](reference/keyboard.md) somewhere you can see it. + +## What lives where + +Guides are the things you read once. The reference is the thing you come back to. Anything under +\`archive/\` is kept because deleting it would lose the argument, not because it is still true. +`; + +const gettingStarted = `--- +title: Getting started +tags: [onboarding] +--- + +# Getting started + +Clone the repository and run the app once before you change anything. It is much easier to read a +diff when you have seen the thing the diff is about. + +\`\`\`sh +git clone git@github.com:example/handbook.git +cd handbook +pnpm install +pnpm dev +\`\`\` + +> [!NOTE] +> The first run builds the search index. On a folder this size it takes a second or two, and quick +> open stays empty until it finishes. + +## Your editor + +Any editor is fine. Two settings are not optional: trim trailing whitespace, and end every file +with a newline. Without them every pull request carries noise nobody wrote. + +> [!WARNING] +> Do not edit anything under \`archive/\`. Those documents are kept as a record and a change there +> will not be reviewed. + +When something is bound to a key, [the keyboard reference](../reference/keyboard.md) is the list. +`; + +const writing = `--- +title: Writing +--- + +# Writing + +Short sentences. Say the thing, then stop. If a paragraph is doing two jobs, it is two paragraphs. + +## What the editor does with what you type + +| You write | On disk | In the editor | +| --- | --- | --- | +| A note | \`> [!NOTE]\` | a tinted block with a title | +| A toggle | \`
    \` | a disclosure arrow | +| A link | \`[text](path.md)\` | underlined, click to follow | +| A table | pipes and dashes | a real table with a header row | + +The file on disk stays plain markdown. Anything the editor cannot model is left exactly as it was +found and shown as a raw block you can still edit. + +
    +House style, the short version + +No em dashes. No exclamation marks. Do not start a sentence with "Basically". If you catch +yourself writing "simply", delete it and read the sentence again. + +
    + +## Before you open a pull request + +Read it out loud once. Then read [Getting started](getting-started.md) if you have not, because +half of what gets flagged in review is covered there already, and check the +[handbook index](../README.md) still points at your new page. +`; + +const keyboard = `--- +title: Keyboard reference +--- + +# Keyboard reference + +| Key | Does | +| --- | --- | +| \`Cmd P\` | Quick open, fuzzy match on the whole path | +| \`Cmd Shift F\` | Search the text of every open folder | +| \`Cmd S\` | Save | +| \`Cmd N\` | New document, in the selected folder | +| \`Cmd O\` | Open a folder | +| \`Cmd \\\` | Show or hide the sidebar | +| \`Cmd B\` | Bold | +| \`Cmd K\` | Command palette | + +Nothing here is configurable yet. If a key is wrong for you, say so and it can move. +`; + +const retro = `--- +title: 2024 retro +archived: true +--- + +# 2024 retro + +Kept for the record. Most of this is out of date and none of it should be edited. + +## What went well + +Shipping small and often. The three week gap in July is the only stretch nobody enjoyed, and it +was the week the build broke twice. + +## What did not + +Documentation drifted from the code for most of the second half of the year, which is the reason +this folder exists at all. +`; + +const notes = `Scratch notes, not markdown, still editable. + +Ask about the archive folder. Nobody seems to know who owns it. +Chase the design review before Thursday. +The index rebuild takes longer than it should on the big folder. +`; + +const inbox = `# Inbox + +Things that have not found a home yet. + +- Move the keyboard reference into the guides folder, or do not, but decide. +- A callout for "deprecated" would be useful. +- Check whether the .txt files should be indexed too. +`; + +const todo = `Buy a new keyboard +Reply to the design review thread +Rebuild the index after the folder move +`; + +/** + * A one pixel PNG. The point of it is the tree row, not the image: it proves a file the editor + * will not open is greyed and hands itself to the system instead. + */ +export const devPng = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +const dir = (path: string, modifiedMs: number): DevEntry => ({ + path, + dir: true, + text: "", + binary: false, + modifiedMs, +}); + +const file = (path: string, text: string, modifiedMs: number): DevEntry => ({ + path, + dir: false, + text, + binary: false, + modifiedMs, +}); + +export const devEntries: DevEntry[] = [ + dir(HANDBOOK, now - 20 * MINUTE), + file(`${HANDBOOK}/README.md`, readme, now - 20 * MINUTE), + dir(`${HANDBOOK}/guides`, now - 3 * HOUR), + file(`${HANDBOOK}/guides/getting-started.md`, gettingStarted, now - 3 * HOUR), + file(`${HANDBOOK}/guides/writing.md`, writing, now - 2 * DAY), + dir(`${HANDBOOK}/reference`, now - 5 * DAY), + file(`${HANDBOOK}/reference/keyboard.md`, keyboard, now - 5 * DAY), + dir(`${HANDBOOK}/reference/assets`, now - 5 * DAY), + { + path: `${HANDBOOK}/reference/assets/diagram.png`, + dir: false, + text: "", + binary: true, + modifiedMs: now - 5 * DAY, + }, + dir(`${HANDBOOK}/archive`, now - 200 * DAY), + dir(`${HANDBOOK}/archive/2024`, now - 200 * DAY), + file(`${HANDBOOK}/archive/2024/retro.md`, retro, now - 200 * DAY), + file(`${HANDBOOK}/notes.txt`, notes, now - 45 * MINUTE), + dir(SCRATCH, now - 90 * MINUTE), + file(`${SCRATCH}/inbox.md`, inbox, now - 90 * MINUTE), + file(`${SCRATCH}/todo.txt`, todo, now - 8 * HOUR), +]; + +export const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1); + +export const dirName = (path: string): string => path.slice(0, path.lastIndexOf("/")) || "/"; + +export const joinPath = (parent: string, name: string): string => + parent.endsWith("/") ? `${parent}${name}` : `${parent}/${name}`; + +export const extensionOf = (path: string): string => { + const name = baseName(path); + const dot = name.lastIndexOf("."); + return dot > 0 ? name.slice(dot + 1).toLowerCase() : ""; +}; + +export function kindOf(entry: DevEntry): FileKind { + if (entry.dir) return "dir"; + const ext = extensionOf(entry.path); + if (ext === "md" || ext === "markdown") return "markdown"; + if (ext === "txt") return "text"; + return "other"; +} + +export const editableKind = (kind: FileKind): boolean => kind === "markdown" || kind === "text"; + +/** Frontmatter title first, then the first heading, then the filename. What the Rust index does. */ +export function titleOf(path: string, text: string): string { + const front = /^---\n([\s\S]*?)\n---/.exec(text); + const titled = front && /^title:\s*(.+)$/m.exec(front[1]); + if (titled) return titled[1].trim().replace(/^["']|["']$/g, ""); + const heading = /^#\s+(.+)$/m.exec(text); + if (heading) return heading[1].trim(); + return baseName(path); +} + +/** Resolves `](../thing.md)` against the document that wrote it. Null for anything not local. */ +export function resolveRelative(fromFile: string, target: string): string | null { + if (!target || /^[a-z]+:/i.test(target) || target.startsWith("#")) return null; + const clean = target.split("#")[0].split("?")[0]; + if (!clean) return null; + const parts = clean.startsWith("/") ? clean.split("/") : `${dirName(fromFile)}/${clean}`.split("/"); + const out: string[] = []; + for (const part of parts) { + if (part === "" || part === ".") continue; + if (part === "..") out.pop(); + else out.push(part); + } + return `/${out.join("/")}`; +} diff --git a/src/dev/mockIpc.ts b/src/dev/mockIpc.ts new file mode 100644 index 0000000..34be760 --- /dev/null +++ b/src/dev/mockIpc.ts @@ -0,0 +1,564 @@ +// Serves the IPC surface from the dev fixture when the app is opened in a browser rather than in +// Tauri. This exists so the real UI can be driven and looked at, by a person or by Playwright, +// without a build of the Rust side and without pointing the app at real documents. +// +// It is reachable only when `import.meta.env.DEV` is true and `isTauri` is false, so it is absent +// from a production bundle and can never shadow the real backend inside the app. +// +// Writes mutate the fixture for the session, so creating a document and typing into it behaves +// the way it will on disk. +// +// `external` at the bottom is the other half: the world outside the app, for a test that needs a +// file to change while the app is looking at it. It mutates the fixture the way another program +// would, behind the app's back and without going through `file_write`, and hands back the exact +// `WatchEvent` payloads the Rust watcher would have emitted for what it did. Emitting them is the +// caller's job, because emitting means the Tauri event bus and this module has no opinion about +// where that comes from. src-tauri/tests/watch_payload.rs is what keeps those payloads honest. + +import type { + AssetResult, + Backlink, + FileNode, + IndexStatus, + MatchRange, + QuickOpenHit, + ReadResult, + RootInfo, + SearchHit, + SpellIssue, + WatchEvent, + WriteResult, +} from "../ipc"; +import { + baseName, + devEntries, + devRoots, + dirName, + editableKind, + extensionOf, + joinPath, + kindOf, + resolveRelative, + rootIdFor, + titleOf, + type DevEntry, +} from "./fixture"; + +const entries = new Map(devEntries.map((e) => [e.path, { ...e }])); +const roots: RootInfo[] = devRoots.map((r) => ({ ...r })); + +/** Roots with a watch running, so `external` can refuse to invent an event nobody subscribed to. */ +const watching = new Set(); + +/** + * A modification time that is always newer than the last one handed out. `Date.now()` twice in the + * same millisecond is two writes the app cannot tell apart, and telling them apart is the entire + * mechanism behind conflict detection and the reload guard. + */ +let lastStamp = 0; +function stamp(): number { + lastStamp = Math.max(Date.now(), lastStamp + 1); + return lastStamp; +} + +/** Held writes, for a test that needs a buffer to stay dirty while something else touches disk. */ +let writeGate: Promise | null = null; +let openGate: (() => void) | null = null; + +/** + * A first launch, which the fixture otherwise has no way to show: it is seeded with two open + * folders, so the empty state somebody new actually opens on was the one screen nobody could look + * at. With this set there are no roots and the tree comes back empty. + */ +const firstRun = (): boolean => { + try { + return localStorage.getItem("margindocs-dev-empty") === "1"; + } catch { + return false; + } +}; + +function entryAt(path: string): DevEntry { + const entry = entries.get(path); + if (!entry) throw new Error(`no such file: ${path}`); + return entry; +} + +function rootFor(path: string): RootInfo | undefined { + return roots.find((r) => path === r.path || path.startsWith(`${r.path}/`)); +} + +const relTo = (root: RootInfo, path: string): string => path.slice(root.path.length + 1); + +function childrenOf(path: string): DevEntry[] { + const prefix = `${path}/`; + return [...entries.values()] + .filter((e) => e.path.startsWith(prefix) && !e.path.slice(prefix.length).includes("/")) + .sort((a, b) => { + if (a.dir !== b.dir) return a.dir ? -1 : 1; + return a.path.localeCompare(b.path, undefined, { sensitivity: "base" }); + }); +} + +function nodeFor(entry: DevEntry): FileNode { + const kind = kindOf(entry); + return { + path: entry.path, + name: baseName(entry.path), + kind, + editable: editableKind(kind), + modifiedMs: entry.modifiedMs, + children: entry.dir ? childrenOf(entry.path).map(nodeFor) : [], + }; +} + +/** Every descendant of a directory, the directory itself included, deepest last. */ +function subtree(path: string): DevEntry[] { + const prefix = `${path}/`; + return [...entries.values()].filter((e) => e.path === path || e.path.startsWith(prefix)); +} + +/** `name` is a suggestion. A taken one gets a numbered suffix, the way the Rust side does it. */ +function freePath(parent: string, name: string): string { + const candidate = joinPath(parent, name); + if (!entries.has(candidate)) return candidate; + const dot = name.lastIndexOf("."); + const stem = dot > 0 ? name.slice(0, dot) : name; + const ext = dot > 0 ? name.slice(dot) : ""; + for (let n = 2; ; n += 1) { + const next = joinPath(parent, `${stem} ${n}${ext}`); + if (!entries.has(next)) return next; + } +} + +function put(entry: DevEntry): DevEntry { + entries.set(entry.path, entry); + return entry; +} + +/** Moves an entry and everything under it, which is the same operation for a rename and a move. */ +function relocate(from: string, to: string): DevEntry { + for (const entry of subtree(from)) { + entries.delete(entry.path); + entries.set(entry.path === from ? to : to + entry.path.slice(from.length), { + ...entry, + path: entry.path === from ? to : to + entry.path.slice(from.length), + }); + } + return entryAt(to); +} + +const textFiles = (): DevEntry[] => + [...entries.values()].filter((e) => !e.dir && !e.binary && editableKind(kindOf(e))); + +/** Subsequence match, the cheap kind quick open wants: every query character in order. */ +function fuzzy(haystack: string, query: string): { score: number; ranges: MatchRange[] } | null { + const lower = haystack.toLowerCase(); + const needle = query.toLowerCase().replace(/\s+/g, ""); + if (!needle) return { score: 0, ranges: [] }; + const ranges: MatchRange[] = []; + let at = 0; + let score = 0; + let previous = -2; + for (const character of needle) { + const found = lower.indexOf(character, at); + if (found < 0) return null; + // Runs read as a word and score far better than the same letters scattered over a path. + score += found === previous + 1 ? 8 : 1; + if (found > lower.lastIndexOf("/")) score += 4; + const last = ranges[ranges.length - 1]; + if (last && last.end === found) last.end = found + 1; + else ranges.push({ start: found, end: found + 1 }); + previous = found; + at = found + 1; + } + return { score: score - Math.floor(haystack.length / 10), ranges }; +} + +/** A window of the line around the first match, so a long line does not fill the results list. */ +function snippetAround(line: string, start: number, length: number) { + const from = Math.max(0, start - 32); + const head = from > 0 ? "…" : ""; + const body = line.slice(from, from + 160); + const tail = from + 160 < line.length ? "…" : ""; + return { + snippet: `${head}${body}${tail}`, + range: { start: head.length + (start - from), end: head.length + (start - from) + length }, + }; +} + +/** + * The whole vocabulary of the dev spell checker. Real spelling comes from the system and this is + * only ever a stand-in for a browser, so the list is short on purpose: it holds the words someone + * exercising the feature is likely to type at it and nothing else. + */ +const DEV_MISSPELLINGS: Record = { + teh: ["the", "then", "tea"], + recieve: ["receive", "relieve"], + seperate: ["separate", "desperate"], + occured: ["occurred"], + definately: ["definitely", "defiantly"], + accomodate: ["accommodate"], + wierd: ["weird", "wired"], + begining: ["beginning"], + neccessary: ["necessary"], + publically: ["publicly"], + writting: ["writing", "written"], + markdwon: ["markdown"], +}; + +/** Words `spell_learn` was told about this session. The real checker teaches the whole machine. */ +const devLearned = new Set(); + +export async function mockCall(command: string, args?: Record): Promise { + const a = (args ?? {}) as Record; + switch (command) { + case "roots_list": + return (firstRun() ? [] : roots) as unknown as T; + + case "root_open": { + const path = a.path as unknown as string; + const existing = roots.find((r) => r.path === path); + if (existing) return existing as unknown as T; + const opened: RootInfo = { + id: rootIdFor(path), + path, + name: baseName(path), + openedMs: Date.now(), + }; + roots.push(opened); + if (!entries.has(path)) { + put({ path, dir: true, text: "", binary: false, modifiedMs: Date.now() }); + } + return opened as unknown as T; + } + + case "root_close": { + const id = a.rootId as unknown as string; + const at = roots.findIndex((r) => r.id === id); + if (at >= 0) roots.splice(at, 1); + return undefined as T; + } + + case "tree_read": { + const root = roots.find((r) => r.id === (a.rootId as unknown as string)); + if (!root) throw new Error(`no such root: ${a.rootId as unknown as string}`); + return nodeFor(entryAt(root.path)) as unknown as T; + } + + case "reveal_in_finder": + case "open_external": + // Nothing to hand a file to in a browser tab, so say so rather than looking broken. + console.info(`dev mock: ${command} ${a.path as unknown as string}`); + return undefined as T; + + case "file_read": { + const entry = entryAt(a.path as unknown as string); + if (entry.dir || entry.binary) throw new Error(`not a text file: ${entry.path}`); + return { + path: entry.path, + text: entry.text, + modifiedMs: entry.modifiedMs, + } satisfies ReadResult as unknown as T; + } + + case "file_write": { + // Held only when a test has asked for it, so a buffer can be observed dirty while something + // outside the app changes the same file. + if (writeGate) await writeGate; + const entry = entryAt(a.path as unknown as string); + const expected = a.expectedModifiedMs as unknown as number | undefined; + if (typeof expected === "number" && expected !== entry.modifiedMs) { + return { + path: entry.path, + modifiedMs: entry.modifiedMs, + conflict: true, + } satisfies WriteResult as unknown as T; + } + entry.text = a.text as unknown as string; + entry.modifiedMs = stamp(); + return { + path: entry.path, + modifiedMs: entry.modifiedMs, + conflict: false, + } satisfies WriteResult as unknown as T; + } + + case "file_create": { + const path = freePath(a.parentPath as unknown as string, a.name as unknown as string); + return nodeFor( + put({ path, dir: false, text: "", binary: false, modifiedMs: stamp() }), + ) as unknown as T; + } + + case "file_folder_create": { + const path = freePath(a.parentPath as unknown as string, a.name as unknown as string); + return nodeFor( + put({ path, dir: true, text: "", binary: false, modifiedMs: stamp() }), + ) as unknown as T; + } + + case "file_rename": { + const from = a.path as unknown as string; + return nodeFor( + relocate(from, freePath(dirName(from), a.name as unknown as string)), + ) as unknown as T; + } + + case "file_move": { + const from = a.path as unknown as string; + const to = freePath(a.destDir as unknown as string, baseName(from)); + return nodeFor(relocate(from, to)) as unknown as T; + } + + case "file_duplicate": { + const source = entryAt(a.path as unknown as string); + const ext = extensionOf(source.path); + const stem = ext ? baseName(source.path).slice(0, -(ext.length + 1)) : baseName(source.path); + const path = freePath(dirName(source.path), ext ? `${stem} copy.${ext}` : `${stem} copy`); + return nodeFor(put({ ...source, path, modifiedMs: stamp() })) as unknown as T; + } + + case "file_trash": { + for (const entry of subtree(a.path as unknown as string)) entries.delete(entry.path); + return undefined as T; + } + + case "asset_write": { + const folder = joinPath(dirName(a.docPath as unknown as string), "assets"); + if (!entries.has(folder)) { + put({ path: folder, dir: true, text: "", binary: false, modifiedMs: stamp() }); + } + const path = freePath(folder, (a.name as unknown as string) || "image.png"); + put({ path, dir: false, text: "", binary: true, modifiedMs: stamp() }); + return { + path, + relPath: `assets/${baseName(path)}`, + } satisfies AssetResult as unknown as T; + } + + case "watch_start": + watching.add(a.rootId as unknown as string); + return undefined as T; + + case "watch_stop": + watching.delete(a.rootId as unknown as string); + return undefined as T; + + case "index_rebuild": + case "index_status": { + const total = textFiles().length; + return { + phase: "idle", + indexed: total, + total, + lastIndexed: Date.now(), + error: null, + message: null, + } satisfies IndexStatus as unknown as T; + } + + case "search_quick_open": { + const query = a.query as unknown as string; + const limit = (a.limit as unknown as number) ?? 30; + const hits: QuickOpenHit[] = []; + for (const entry of textFiles()) { + const root = rootFor(entry.path); + if (!root) continue; + const relPath = relTo(root, entry.path); + const match = fuzzy(relPath, query); + if (!match) continue; + hits.push({ + path: entry.path, + name: baseName(entry.path), + root: root.id, + relPath, + score: match.score, + ranges: match.ranges, + }); + } + return hits.sort((x, y) => y.score - x.score).slice(0, limit) as unknown as T; + } + + case "search_text": { + const query = (a.query as unknown as string) ?? ""; + const limit = (a.limit as unknown as number) ?? 100; + const needle = query.toLowerCase(); + const hits: SearchHit[] = []; + if (!needle.trim()) return [] as unknown as T; + for (const entry of textFiles()) { + const root = rootFor(entry.path); + if (!root) continue; + const title = titleOf(entry.path, entry.text); + entry.text.split("\n").forEach((line, index) => { + const at = line.toLowerCase().indexOf(needle); + if (at < 0 || hits.length >= limit) return; + const { snippet, range } = snippetAround(line, at, needle.length); + hits.push({ + path: entry.path, + root: root.id, + title, + line: index + 1, + snippet, + ranges: [range], + }); + }); + } + return hits.slice(0, limit) as unknown as T; + } + + case "backlinks_for": { + const target = a.path as unknown as string; + const found: Backlink[] = []; + for (const entry of textFiles()) { + if (entry.path === target || kindOf(entry) !== "markdown") continue; + const lines = entry.text.split("\n"); + const line = lines.find((text) => + [...text.matchAll(/\]\(([^)\s]+)\)/g)].some( + (m) => resolveRelative(entry.path, m[1]) === target, + ), + ); + if (line === undefined) continue; + found.push({ + path: entry.path, + title: titleOf(entry.path, entry.text), + snippet: line.trim(), + }); + } + return found as unknown as T; + } + + // Spelling in a browser is not the system checker and cannot be: NSSpellChecker is not + // reachable from a page. What it is instead is a fixed list of misspellings, which is enough to + // put a real underline under a real word and open a real menu of suggestions over it. A dev + // fixture that flagged every word it did not recognise would need a dictionary, and shipping + // one here to exercise a feature whose whole point is not shipping one would be absurd. + case "spell_available": + return true as unknown as T; + + case "spell_check": { + const text = (a.text as unknown as string) ?? ""; + const issues: SpellIssue[] = []; + for (const match of text.matchAll(/[\p{L}']+/gu)) { + const word = match[0]; + const guesses = DEV_MISSPELLINGS[word.toLowerCase()]; + if (guesses === undefined || devLearned.has(word.toLowerCase())) continue; + issues.push({ + start: match.index, + end: match.index + word.length, + word, + // Matching the case of what was typed, because a suggestion that comes back lower case + // for a word opening a sentence is a correction the user then has to correct. + suggestions: guesses.map((guess) => + word[0] === word[0].toUpperCase() ? guess[0].toUpperCase() + guess.slice(1) : guess, + ), + }); + } + return issues as unknown as T; + } + + case "spell_learn": + devLearned.add((a.word as unknown as string).toLowerCase()); + return undefined as T; + + case "spell_unlearn": + devLearned.delete((a.word as unknown as string).toLowerCase()); + return undefined as T; + + default: + throw new Error(`dev mock has no handler for ${command}`); + } +} + +/** + * The world outside the app: what another program does to the folder while it is open. + * + * Every function here mutates the fixture directly rather than going through `mockCall`, which is + * the point. A change made this way has not been through `file_write`, so nothing has registered a + * self-write against it and nothing has told the open document its file moved on: it is a change + * the app can only find out about from a watch event, exactly like a change made by vim or by a + * git checkout. + * + * The return value is the `WatchEvent` list the Rust watcher would have emitted for that change, + * in the order it would have emitted them. A rename is two events and not one, because FSEvents + * describes the two ends as unrelated and src-tauri/src/watch.rs reports what it is told; see + * `a_rename_is_reported_at_both_ends` in src-tauri/tests/watch.rs. + * + * A change to a root with no watch running is an error rather than an event. Nothing outside a + * watched folder is reported to anybody, so a test that gets an event out of this has also proved + * that opening the folder started the watch. + */ +function watchEventFor( + path: string, + kind: WatchEvent["kind"], + oldPath: string | null = null, +): WatchEvent { + const root = rootFor(path); + if (!root) throw new Error(`no open root owns ${path}`); + if (!watching.has(root.id)) throw new Error(`no watch is running on ${root.path}`); + return { root: root.id, path, kind, oldPath }; +} + +export const external = { + /** Another program rewrites the file. New bytes, new modification time. */ + write(path: string, text: string): WatchEvent[] { + const entry = entryAt(path); + entry.text = text; + entry.modifiedMs = stamp(); + return [watchEventFor(path, "modified")]; + }, + + /** + * The same bytes, a newer modification time: `touch`, a git checkout that restores what was + * already there, a backup tool. The watcher cannot tell this from a real edit and reports it as + * one, which is why the app compares bytes and not just timestamps. + */ + touch(path: string): WatchEvent[] { + entryAt(path).modifiedMs = stamp(); + return [watchEventFor(path, "modified")]; + }, + + /** An event with nothing behind it, for proving what the app does with a change that is not one. */ + signal(path: string, kind: WatchEvent["kind"]): WatchEvent[] { + return [watchEventFor(path, kind)]; + }, + + remove(path: string): WatchEvent[] { + entryAt(path); + const event = watchEventFor(path, "removed"); + for (const entry of subtree(path)) entries.delete(entry.path); + return [event]; + }, + + rename(from: string, to: string): WatchEvent[] { + relocate(from, to); + return [watchEventFor(from, "removed"), watchEventFor(to, "created")]; + }, + + /** What is actually on disk now, for asserting that the app has written nothing it should not. */ + read(path: string): string | null { + const entry = entries.get(path); + return entry && !entry.dir ? entry.text : null; + }, + + exists(path: string): boolean { + return entries.has(path); + }, + + /** + * Holds every `file_write` until `resumeWrites`. A save that cannot land is how a test keeps a + * buffer dirty for as long as it needs to, instead of racing the 500ms autosave. + */ + pauseWrites(): void { + if (writeGate) return; + writeGate = new Promise((resolve) => { + openGate = resolve; + }); + }, + + resumeWrites(): void { + openGate?.(); + writeGate = null; + openGate = null; + }, +}; diff --git a/src/document.ts b/src/document.ts new file mode 100644 index 0000000..da684b9 --- /dev/null +++ b/src/document.ts @@ -0,0 +1,487 @@ +// Everything the open document does to the disk, and the only place that decides when. The store +// next door holds what is on screen and the setters a keystroke can settle on its own; this module +// reads the file, hands it to the markdown bridge, and writes it back 500ms after the last edit. +// +// Opening writes nothing. There is exactly one call to `fileWrite` in this file, it sits inside +// `performSave`, and `performSave` returns before reaching it unless the buffer is dirty, which +// only `setContent` can make it. That is the product's first promise and +// src/store/useDocument.test.ts asserts it rather than trusting this paragraph. +// +// `setContent` marks the buffer dirty through `differsFromDisk` below, which is the second half of +// that promise. That question is asked of the document the file was read from and never of the +// keystroke before, so the answer is "is the buffer different" rather than "did something happen": +// a paragraph typed into and then undone is the document that was opened, and it does not put the +// file on the debounce. A transaction that moved something the markdown has no spelling for gets +// the same answer for the same reason, since the file would not show it either. Dragging a table +// column is the whole of that today. +// +// `performSave` also never runs twice at once for the open document: `saveNow` keeps at most one +// call to it on the wire, folding anything that arrives while one is running into a single next +// lap rather than starting a second write alongside the first. +// +// This module and src/store/useDocument.ts import each other: the store's async actions delegate +// down here, and the work down here lands back in the store. Neither touches the other while its +// own module body is still evaluating, so the cycle resolves. Nothing here runs at import time for +// the same reason: the subscription that drives the debounce is installed by `initDocument`, which +// `loadDocument` calls itself so the shell cannot forget to. + +import type { Mark, Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { fileRead, fileWrite } from "./api/files"; +import type { ReadResult, WriteResult } from "./ipc"; +import { + parseMarkdown, + parsePlainText, + serializeMarkdown, + serializePlainText, +} from "./markdown"; +import { documentKindForPath, type MarkdownDocument } from "./model/doc"; +import { useDocument } from "./store/useDocument"; +import { notify } from "./store/useToast"; + +/** Long enough that a sentence is one save, short enough that Cmd+Tab away is already on disk. */ +export const SAVE_DEBOUNCE_MS = 500; + +let saveTimer: ReturnType | null = null; +let unsubscribe: (() => void) | null = null; + +/** + * The file as this module last saw it, either read or written: its bytes, and the tree those bytes + * are the serialization of. + * + * The bytes are here because a watcher fires on a touch, on a git checkout that restores the same + * content and on this app's own save, and without them the buffer would be thrown away and rebuilt + * for all three. The tree is here because the bytes cannot answer whether the buffer still holds + * the document that was read: a hand written file does not serialize to its own bytes, so from the + * moment it opens the two differ for house style reasons that have nothing to do with any edit. + * + * Either can be null on its own, because they answer different questions. Null bytes mean the file + * no longer holds the bytes this module last saw, so there is nothing a write could be compared + * against and `performSave` reads it as "write". A null document means the file holds a document + * this module has never had, somebody else's copy or none at all, so `differsFromDisk` reads it as + * "dirty". Both say the same thing: the one thing worse than an unnecessary write is a skipped + * necessary one. + */ +let diskText: string | null = null; +let diskDoc: ProseMirrorNode | null = null; + +/** The only way either of those moves, so that they cannot drift apart into two answers about the + * same file, one of which sends a write and the other of which holds it back. */ +function rememberDisk(text: string | null, doc: ProseMirrorNode | null): void { + diskText = text; + diskDoc = doc; +} + +/** Markdown and plain text are two different round trips and picking the wrong one mangles a .txt. */ +function bridgeFor(path: string) { + const kind = documentKindForPath(path); + if (kind === null) throw new Error(`${path} is not a document this editor opens`); + return kind === "markdown" + ? { parse: parseMarkdown, serialize: serializeMarkdown } + : { parse: parsePlainText, serialize: serializePlainText }; +} + +/** + * The attributes the serializer never reads, by the node that carries them. + * + * `colwidth` is the whole list, and the list was written by going through src/model/schema.ts + * attribute by attribute against src/markdown/serialize.ts. prosemirror-tables puts a width on + * every cell of a column when its edge is dragged and GFM has no column widths, so that drag is a + * real change to the document and no change at all to the file. + * + * Three others were considered and left off. `colspan` and `rowspan` are unreadable to the + * serializer too, but no op this editor offers can move them, and a table that carried one could + * not be written as a table at all, so calling a change to one insignificant would be hiding the + * one case that needs to be seen. `raw.source` is the file's own bytes and is never written to + * after the parse. And `align` is the near miss: the serializer reads it off the table's first row + * only, so a body cell's copy does not reach the file on its own, but that first row is the + * delimiter row and every align op in tables.ts writes the whole column at once. An alignment + * change is always a change to the file. + */ +const UNWRITTEN_ATTRS: Record = { + tableHeader: ["colwidth"], + tableCell: ["colwidth"], +}; + +/** + * Why nothing below compares a NodeType or a MarkType, only its name. + * + * The two documents this comparison is given are never built on the same schema. The one the file + * was read from comes off the bridge, which parses against src/model/schema.ts; the one the editor + * hands back is bound to TipTap's own schema, which src/editor/extensions.ts generates from those + * same specs and which src/editor/Editor.tsx rebinds every opened document on to before it can be + * edited. Two `Schema` instances over one set of specs, so every type object in one is a different + * object from its twin in the other, and `a.type !== b.type` was true of every pair this function + * had ever been handed. Everything behind it, the colwidth exemption included, was unreachable. + * + * The name is also the right thing to compare rather than a way around that. src/markdown/ + * serialize.ts dispatches on `node.type.name` and `mark.type.name` and reads nothing else off a + * type, so two nodes agreeing on their name, their attributes, their marks, their text and their + * children are two nodes it writes the same bytes for. + */ +const sameType = (a: { name: string }, b: { name: string }): boolean => a.name === b.name; + +/** Two nodes of the same type, agreeing on every attribute the serializer would go looking for. */ +function sameAttrs(a: ProseMirrorNode, b: ProseMirrorNode): boolean { + const unwritten = UNWRITTEN_ATTRS[a.type.name]; + const names = Object.keys(a.attrs); + // An attribute one side carries and the other does not is not provably nothing, and walking a's + // names only ever shows one of the two directions. + if (names.length !== Object.keys(b.attrs).length) return false; + for (const name of names) { + if (a.attrs[name] === b.attrs[name]) continue; + if (unwritten !== undefined && unwritten.includes(name)) continue; + return false; + } + return true; +} + +/** + * The marks on one piece of text, in order. + * + * This replaces `Mark.sameSet`, which compares MarkType by object and so answered "different" for + * every span anybody had ever made bold or turned into a link. Order is compared rather than the + * set treated as unordered because ProseMirror keeps a mark set sorted by the schema's own + * declaration order and both schemas declare the same marks in the same order, so a mismatch is + * either a real difference or the two schemas having drifted apart, and both are worth a write. + */ +function sameMarks(a: readonly Mark[], b: readonly Mark[]): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + const one = a[i]; + const other = b[i]; + if (one === other) continue; + if (!sameType(one.type, other.type)) return false; + const names = Object.keys(one.attrs); + if (names.length !== Object.keys(other.attrs).length) return false; + for (const name of names) if (one.attrs[name] !== other.attrs[name]) return false; + } + return true; +} + +function sameToTheSerializer(a: ProseMirrorNode, b: ProseMirrorNode): boolean { + // The whole reason this is cheap enough to run on every keystroke, even against a tree many + // transactions old. A transaction rebuilds only the spine down to what it touched, so every + // subtree no edit since the read has visited is still the same object it was and the walk stops + // dead at it. Only what has actually been typed into is ever compared node by node. + // + // It buys nothing between an open and the first save, because the tree that was read and the + // editor's rebind of it share no object at all, so every keystroke in that window walks the + // whole document. Measured at 0.08ms on a 57kB file of 984 nodes, against a 500ms debounce. + // Once a save has landed, `diskDoc` is the editor's own tree and the sharing is back. + if (a === b) return true; + if (!sameType(a.type, b.type) || a.text !== b.text || a.childCount !== b.childCount) return false; + if (!sameMarks(a.marks, b.marks)) return false; + if (!sameAttrs(a, b)) return false; + for (let i = 0; i < a.childCount; i += 1) { + if (!sameToTheSerializer(a.child(i), b.child(i))) return false; + } + return true; +} + +/** + * Whether a tree differs, anywhere the file would show it, from the document on disk. + * + * This is what the dirty flag is, and the whole of it. Asking it of the document that was read + * rather than of the tree a keystroke ago is what makes it a fact about the file instead of a + * count of transactions: a paragraph typed into and then undone comes back false, because the + * buffer is the file again, and a flag that only ever counted up would have had the whole document + * rewritten in house style for an edit that no longer exists. + * + * It is deliberately lopsided: everything counts as a change to the file unless it is provably not + * one. A change wrongly called insignificant is a keystroke that never reaches the disk, which is + * the worst thing in this module; a change wrongly called significant costs one write that + * `performSave` then finds nothing to do. + */ +export function differsFromDisk(next: ProseMirrorNode): boolean { + return diskDoc === null || !sameToTheSerializer(diskDoc, next); +} + +/** The same question, asked of whatever the store is holding now. */ +function bufferDiffersFromDisk(): boolean { + const now = useDocument.getState().content; + return now !== null && differsFromDisk(now); +} + +function apply(read: ReadResult, document: MarkdownDocument): void { + rememberDisk(read.text, document.doc); + useDocument.setState({ + path: read.path, + document, + content: document.doc, + modifiedMs: read.modifiedMs, + frontmatter: document.frontmatter, + dirty: false, + savePhase: "idle", + saveError: null, + externalChange: "synced", + }); +} + +function scheduleSave(): void { + cancelPendingSave(); + saveTimer = setTimeout(() => { + saveTimer = null; + saveNow().catch((e) => notify(`Could not save: ${String(e)}`)); + }, SAVE_DEBOUNCE_MS); +} + +export function cancelPendingSave(): void { + if (saveTimer !== null) clearTimeout(saveTimer); + saveTimer = null; +} + +/** + * Starts the debounce. Idempotent, and returning the teardown rather than keeping it private is + * what lets a test run the lifecycle without leaving a timer behind for the next one. + */ +export function initDocument(): () => void { + if (unsubscribe !== null) return unsubscribe; + const stop = useDocument.subscribe((state, previous) => { + if (state.content === previous.content) return; + // Clean is not just "nothing more to schedule". The edit that armed the timer can have been + // undone while it was still counting down, and letting it run out would put the debounce's + // whole point, one write per burst of typing, behind a document nobody changed. + if (state.dirty) scheduleSave(); + else cancelPendingSave(); + }); + unsubscribe = () => { + stop(); + unsubscribe = null; + cancelPendingSave(); + }; + return unsubscribe; +} + +/** + * Reads a file and puts it in the store. Reads only: the bridge is pure, nothing here has a path + * to `fileWrite`, and a document that is opened and closed again leaves the file untouched. + */ +export async function loadDocument(path: string): Promise { + initDocument(); + const { parse } = bridgeFor(path); + const read = await fileRead(path); + apply(read, parse(read.text, read.path)); +} + +/** Throws the buffer away and takes what is on disk. The explicit half of a conflict. */ +export async function reloadDocument(): Promise { + const path = useDocument.getState().path; + if (path === null) return; + cancelPendingSave(); + await loadDocument(path); +} + +/** The single write for the open document that is currently on the wire, if any. */ +let writeInFlight: Promise | null = null; + +/** Set when a save is requested while `writeInFlight` is already running. One flag, not a queue: + * it can only ever mean "write again after this one", never "write N more times". */ +let saveAgainRequested = false; + +/** + * Serializes and writes, now. Returns having done nothing when the buffer is clean, which is what + * makes Cmd+S on an untouched document a no-op rather than a reformat. + * + * At most one `fileWrite` for the open document is ever in flight at a time. A call that lands + * while one is already running does not start a second: it flags that another save is wanted and + * folds into a single write that goes out the moment the first one lands, picking up whatever is + * newest in the store by then. That is what keeps the backend from ever seeing two writes of the + * same path race each other, and it is also what keeps the last thing the user typed from being + * the one write that never happened: it is either the content already on the wire, or it is + * exactly what the next lap serializes. + */ +export async function saveNow(): Promise { + cancelPendingSave(); + if (writeInFlight !== null) { + saveAgainRequested = true; + return writeInFlight; + } + const inFlight = runSaveLoop(); + writeInFlight = inFlight; + try { + await inFlight; + } finally { + if (writeInFlight === inFlight) writeInFlight = null; + } +} + +/** + * Runs `performSave` once, then again for every save that arrived while it was on the wire, + * collapsed to the single latest one. Stops the moment a lap does not end in a clean write: a + * conflict or a no-op buffer is not something a stacked-up request should cause to be retried. + */ +async function runSaveLoop(): Promise { + for (;;) { + saveAgainRequested = false; + const outcome = await performSave(); + if (outcome !== "wrote" || !saveAgainRequested) return; + } +} + +async function performSave(): Promise<"wrote" | "conflict" | "skipped"> { + const { path, document, content, dirty, modifiedMs } = useDocument.getState(); + if (path === null || document === null || content === null || !dirty) return "skipped"; + const { serialize } = bridgeFor(path); + + useDocument.setState({ savePhase: "saving", saveError: null }); + let text: string; + try { + text = serialize(document, content); + } catch (e) { + useDocument.setState({ savePhase: "error", saveError: String(e) }); + throw e; + } + + // The second line, not the first. `differsFromDisk` is what keeps a buffer that is not different + // from the file from being dirty at all, and it has to be, because this comparison only catches + // the case where the serialized bytes already match the file: on a document the editor has + // written before they do, and on a hand written one they differ for house style reasons that have + // nothing to do with any edit, so this would let the write through and the whole file would be + // reformatted for a gesture that moved a line on screen. What is left here is everything else + // that can serialize to the bytes already on disk: two different trees that spell the same + // markdown, and a buffer this module cannot vouch for because the file moved under it. + if (text === diskText) { + // Those bytes are on disk and this is a tree that produces them, which is all `diskDoc` has + // ever claimed to be. Nothing was written, so nothing needs to be. + rememberDisk(text, content); + useDocument.setState({ + dirty: bufferDiffersFromDisk(), + savePhase: "idle", + saveError: null, + }); + return "skipped"; + } + + let result: WriteResult; + try { + result = await fileWrite(path, text, modifiedMs ?? undefined); + } catch (e) { + if (useDocument.getState().path === path) { + useDocument.setState({ savePhase: "error", saveError: String(e) }); + } + throw e; + } + + // The document was switched while the write was in flight, so this result belongs to a buffer + // nobody is looking at any more and applying it would stamp the new one's timestamp. + if (useDocument.getState().path !== path) return "skipped"; + + if (result.conflict) { + // Not an error and not something to retry. Nothing was written, the edit is still only in the + // buffer, and which copy wins is the user's call. What is on disk is somebody else's copy, + // which this module has not read, so it stops claiming to know either the bytes or the + // document: the buffer stays dirty however much of the edit the user takes back, and the + // decision the UI is now asking for is the only thing that clears it. + rememberDisk(null, null); + useDocument.setState({ savePhase: "idle", externalChange: "changed-on-disk" }); + return "conflict"; + } + + rememberDisk(text, content); + const stillDirty = bufferDiffersFromDisk(); + useDocument.setState({ + modifiedMs: result.modifiedMs, + dirty: stillDirty, + savePhase: "idle", + saveError: null, + externalChange: "synced", + }); + // Typed into, or undone, while that write was on the wire. An undo is the case that needs this: + // it went past the subscription at a moment when the buffer and the file did agree, so nothing + // armed the debounce for it, and this write is what has just made it a difference again. + if (stillDirty) scheduleSave(); + else cancelPendingSave(); + return "wrote"; +} + +/** + * Gets an unsaved edit onto disk before something else happens to the document: switching away, + * closing it, quitting. Swallows its own error into a toast, because the caller is on its way + * somewhere else and failing that journey over a failed save helps nobody. + */ +export function flushPendingSave(): Promise { + if (!useDocument.getState().dirty) { + cancelPendingSave(); + return Promise.resolve(); + } + return saveNow().catch((e) => notify(`Could not save: ${String(e)}`)); +} + +/** + * Resolves a conflict the other way from `reloadDocument`: the buffer wins and the copy on disk is + * the one that goes. + * + * Dropping `modifiedMs` is what makes the next write land. The backend refuses a write whose + * expected timestamp has moved on, which is the whole conflict mechanism, and there is no way to + * say "yes, I know" other than to stop claiming to know what was there. The write itself is the + * ordinary debounced one, so nothing is put on disk here either. + */ +export function keepBuffer(): void { + if (useDocument.getState().path === null) return; + // What is on disk is whatever the other writer put there, which this module has not read, so the + // last bytes it saw are no longer the file's and neither is the document they came from. Saying + // so is what stops `performSave` from deciding this write is unnecessary and leaving the other + // copy in place, which is the opposite of what the user just asked for, and it is what keeps the + // buffer dirty through an undo taken while the banner is up. + rememberDisk(null, null); + // Dirty even if nothing has been typed: the file moved or went, so the buffer and the disk + // disagree, and that is the only thing the flag has ever meant. + useDocument.setState({ modifiedMs: null, externalChange: "synced", dirty: true }); + scheduleSave(); +} + +/** + * Lets go of the open document without writing it, for when the file it came from has just gone. + * `close` on its own flushes, which for a document that was this second sent to the Trash would + * put the file straight back. + */ +export function abandonDocument(): void { + cancelPendingSave(); + useDocument.setState({ dirty: false }); + useDocument.getState().close(); +} + +/** + * Something outside the app touched the open document. Clean buffers take the new bytes silently, + * dirty ones are left exactly as they are and the UI is told there is a choice to make. + */ +export async function documentChangedOnDisk(path: string): Promise { + if (useDocument.getState().path !== path) return; + + let read: ReadResult; + try { + read = await fileRead(path); + } catch { + // Deleted, renamed out from under us, or unreadable. The buffer is now the only copy there is, + // so it stays put. The bytes go, because there are none left to hold a write back, and the + // document stays, because it is still the last one this module knew the file to hold and it is + // what keeps a buffer nobody has typed into from turning dirty and putting the file back. + // Resurrecting a file the user deleted is `keepBuffer`, and it is the user's word, not a + // side effect of clicking into the editor afterwards. + rememberDisk(null, diskDoc); + useDocument.setState({ externalChange: "changed-on-disk" }); + return; + } + + const state = useDocument.getState(); + if (state.path !== path) return; + if (read.modifiedMs === state.modifiedMs) return; + if (read.text === diskText) { + useDocument.setState({ modifiedMs: read.modifiedMs }); + return; + } + if (state.dirty) { + // The buffer stays, but these are the file's bytes now and this module has just read them, so + // it says so rather than going on remembering the ones the other writer replaced. It does not + // parse them: the document on disk is somebody else's and no tree here is it, so the honest + // answer to "is the buffer different from the file" is that we do not know, which is the answer + // that keeps this dirty until the user picks a side. + rememberDisk(read.text, null); + useDocument.setState({ externalChange: "changed-on-disk" }); + return; + } + + const { parse } = bridgeFor(path); + apply(read, parse(read.text, read.path)); +} diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx new file mode 100644 index 0000000..d76c02d --- /dev/null +++ b/src/editor/Editor.tsx @@ -0,0 +1,678 @@ +// The document surface, and the only place in the app that holds a TipTap instance. +// +// The autosave contract lives here as much as it does in the store, and it is a contract about +// what this component does NOT do. Opening a document installs a ProseMirror state and nothing +// else: no serializer runs, no write is scheduled, and `onChange` does not fire, because +// `view.updateState` is not a dispatched transaction and TipTap only emits `update` when a +// transaction actually changed the document. A file that is opened, read and closed is never +// written. Once the user types, `onChange` hands out the live ProseMirror node on every keystroke, +// which is cheap; turning that node into markdown happens once, later, on the shell's debounce. +// +// Ported from margin's editor/Editor.tsx. Margin keeps an EditorState per chapter because a book +// is many documents open at once; here there is one document at a time, so that cache collapses +// into a small path-keyed LRU whose only job is making a return to a recent file instant, with its +// undo history and its caret still where they were. + +import { useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from "react"; +import type { ReactElement } from "react"; +import { EditorContent, useEditor } from "@tiptap/react"; +import type { Editor } from "@tiptap/react"; +import { EditorState, TextSelection } from "@tiptap/pm/state"; +import type { EditorProps as ProseMirrorProps, EditorView } from "@tiptap/pm/view"; +import type { CalloutKind, HeadingLevel, MarkdownDocument } from "../model/doc"; +import { marks as markSpecs } from "../model/schema"; +import type { MarkName } from "../model/schema"; +import { notify } from "../store/useToast"; +import { insertMath, insertMermaid, setCodeLanguage, tableCommand } from "./blocks"; +import { createEditorExtensions } from "./extensions"; +import { change, markable, place, placeable } from "./fits"; +import { loadPosition, savePosition, type DocumentPosition } from "./positions"; +import { searchStateOf, type SearchOptions } from "./search"; +import type { + BlockCommand, + BlockKind, + DocumentFind, + EditorActiveState, + EditorHandle, + EditorProps, + TableOp, +} from "./index"; + +// How much of the pane the caret is kept out of when the view scrolls it into sight. The toolbar +// pill is 44px tall (32px controls, 5px of padding, a 1px border) and sticks 22px above the bottom +// of the pane, so it covers the last 66px of it; a line of body prose on top of that means the +// caret's whole line clears the glass instead of sitting against it. Nothing overlaps the top, +// where the titlebar is a sibling above the scroller rather than floating over it, so the number +// there is breathing room and nothing more. +const CARET_KEEPOUT = { top: 24, right: 0, bottom: 98, left: 0 }; + +/** Enough that going back to what you were just looking at is instant, and not a document store. */ +const CACHE_LIMIT = 8; + +const EMPTY_CONTENT = { type: "doc", content: [{ type: "paragraph" }] }; + +const MARK_NAMES = Object.keys(markSpecs) as MarkName[]; + +/** Blocks a cursor can be inside without them being what the toolbar should report. */ +const PASS_THROUGH = new Set([ + "paragraph", + "listItem", + "taskItem", + "tableRow", + "tableCell", + "tableHeader", +]); + +const REPORTED = new Set([ + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + "toggle", + "table", + "mathBlock", + "raw", +]); + +interface Cached { + state: EditorState; + document: MarkdownDocument; + scroll: number; +} + +const listeners = new Set<() => void>(); +let currentHandle: EditorHandle | null = null; +let currentFind: DocumentFind | null = null; + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function announce(): void { + for (const listener of listeners) listener(); +} + +const handleSnapshot = () => currentHandle; +const findSnapshot = () => currentFind; + +/** The handle for the document currently on screen, or null when there is none. */ +export function useEditorHandle(): EditorHandle | null { + return useSyncExternalStore(subscribe, handleSnapshot, handleSnapshot); +} + +/** Find and replace over the open document, for whatever draws the find bar. */ +export function useDocumentFind(): DocumentFind | null { + return useSyncExternalStore(subscribe, findSnapshot, findSnapshot); +} + +function reportContentError(error: unknown): void { + notify(`Part of this document could not be read into the editor: ${String(error)}`); +} + +function scrollerOf(editor: Editor): HTMLElement | null { + const dom = editor.view.dom as HTMLElement; + const pane = dom.closest(".editor-pane"); + if (pane instanceof HTMLElement) return pane; + for (let el = dom.parentElement; el; el = el.parentElement) { + const overflow = getComputedStyle(el).overflowY; + if (overflow === "auto" || overflow === "scroll") return el; + } + return null; +} + +/** Ticking a box is an edit like any other, so it goes through the view and dirties the buffer. */ +function toggleTask(view: EditorView, item: Element): boolean { + const $pos = view.state.doc.resolve(view.posAtDOM(item, 0)); + for (let depth = $pos.depth; depth > 0; depth -= 1) { + const node = $pos.node(depth); + if (node.type.name !== "taskItem") continue; + view.dispatch( + view.state.tr.setNodeMarkup($pos.before(depth), undefined, { + ...node.attrs, + checked: !node.attrs.checked, + }), + ); + return true; + } + return false; +} + +/** + * The ProseMirror props this component installs directly on the view, as opposed to the ones an + * extension contributes through `addProseMirrorPlugins`. + * + * Lifted out of the component and exported so that it can be enumerated. It is a third channel into + * the document, alongside the editor handle and the extensions' own plugins, and it was the one + * src/editor/fits.test.ts could not see: that file reads every extension's plugins and every + * extension's keymap, and `editorProps` is neither. A `handlePaste` or a `handleKeyDown` added here + * would be asked before any of them and answer for the whole document, unenumerated. The click + * handler that is here today only flips a checkbox, which is the harmless case; the enumeration is + * for the next one. + */ +export function createEditorProps(context: { + editable: () => boolean; + onOpenLink: (href: string) => void; +}): ProseMirrorProps { + return { + attributes: { class: "prose" }, + scrollThreshold: CARET_KEEPOUT, + scrollMargin: CARET_KEEPOUT, + handleClick: (view, _pos, event) => { + const target = event.target as HTMLElement | null; + // The checkbox is drawn by the list item's own ::before, so a click that lands on the item + // itself rather than on the paragraph inside it is a click on the box. + const item = target?.closest(".task-item"); + if (item && item === event.target && toggleTask(view, item)) { + event.preventDefault(); + return true; + } + const anchor = target?.closest("a[href]"); + const href = anchor?.getAttribute("href"); + if (!href) return false; + // A plain click puts the caret in the link text, which is the only way to edit it. Opening + // is the modified click, or any click at all while the document is not editable. + if (context.editable() && !event.metaKey && !event.ctrlKey) return false; + event.preventDefault(); + context.onOpenLink(href); + return true; + }, + }; +} + +function enclosing(editor: Editor, names: readonly string[]): { name: string; pos: number } | null { + const { $from } = editor.state.selection; + for (let depth = $from.depth; depth > 0; depth -= 1) { + const name = $from.node(depth).type.name; + if (names.includes(name)) return { name, pos: $from.before(depth) }; + } + return null; +} + +function activeStateOf(editor: Editor): EditorActiveState { + const marks = MARK_NAMES.filter((mark) => editor.isActive(mark)); + const { $from } = editor.state.selection; + // Asked separately from the walk below, because that walk stops at the innermost block it has a + // name for and a cell selection's own position is not inside any of them. isActive answers for a + // cursor in a cell, a selection across cells and the table selected whole, alike. + const inTable = editor.isActive("table"); + const base = { marks, inTable, codeLanguage: null }; + + for (let depth = $from.depth; depth > 0; depth -= 1) { + const node = $from.node(depth); + const name = node.type.name; + if (PASS_THROUGH.has(name)) continue; + if (name === "heading") { + return { ...base, block: "heading", headingLevel: node.attrs.level as HeadingLevel, callout: null }; + } + if (name === "callout") { + return { ...base, block: "callout", headingLevel: null, callout: node.attrs.kind as CalloutKind }; + } + if (name === "codeBlock") { + const language = node.attrs.language as string | null; + return { ...base, block: "codeBlock", headingLevel: null, callout: null, codeLanguage: language }; + } + if (REPORTED.has(name)) { + return { ...base, block: name as BlockKind, headingLevel: null, callout: null }; + } + } + + return { ...base, block: "paragraph", headingLevel: null, callout: null }; +} + +function sameActive(a: EditorActiveState, b: EditorActiveState): boolean { + return ( + a.block === b.block && + a.headingLevel === b.headingLevel && + a.callout === b.callout && + a.inTable === b.inTable && + a.codeLanguage === b.codeLanguage && + a.marks.length === b.marks.length && + a.marks.every((mark, i) => b.marks[i] === mark) + ); +} + +/** + * The command half of the handle, built once per editor and shared by every published snapshot. + * + * Exported for src/editor/fits.test.ts and for nothing else: the shell gets the handle through + * `useEditorHandle` and has no business building one. That test enumerates the keys of what this + * returns and refuses to pass unless every insert among them has been proved to keep its hands off + * a document it cannot insert into, which is the only way this file's own insert commands and the + * block lanes' are held to the same rule. + */ +export function createCommands(editor: Editor): Omit { + const setCallout = (kind: CalloutKind | null) => { + const found = enclosing(editor, ["callout", "blockquote"]); + if (kind === null) { + if (found?.name !== "callout") return; + change(editor, "unwrap", (chain) => + chain.command(({ tr, state, dispatch }) => { + if (dispatch) tr.setNodeMarkup(found.pos, state.schema.nodes.blockquote, {}); + return true; + }), + ); + return; + } + if (found) { + change(editor, "wrap", (chain) => + chain.command(({ tr, state, dispatch }) => { + if (dispatch) tr.setNodeMarkup(found.pos, state.schema.nodes.callout, { kind }); + return true; + }), + ); + return; + } + change(editor, "wrap", (chain) => chain.wrapIn("callout", { kind })); + }; + + return { + focus: () => { + editor.commands.focus(); + }, + + toggleMark: (mark) => { + editor.chain().focus().toggleMark(mark).run(); + }, + + // With a collapsed caret and no link under it the url goes in as TEXT and is marked + // afterwards, and the text lands whether the mark can or not: in a fence or a raw block, where + // the schema allows no marks at all, that is the url typed into somebody's code. Asked of the + // mark rather than of the block, so it is the same question in both places and in whatever + // block comes next. The two chains below only add a mark, which ProseMirror already declines + // to do where the schema says no. + setLink: (href, title = null) => { + if (href === null) { + editor.chain().focus().extendMarkRange("link").unsetMark("link").run(); + return; + } + if (editor.state.selection.empty && !editor.isActive("link")) { + if (!markable(editor.state, editor.schema.marks.link)) return; + if (!placeable(editor.state, editor.schema.nodes.text)) return; + editor + .chain() + .focus() + .extendMarkRange("link") + .insertContent({ type: "text", text: href, marks: [{ type: "link", attrs: { href, title } }] }) + .run(); + return; + } + editor.chain().focus().extendMarkRange("link").setMark("link", { href, title }).run(); + }, + + // The conversions, and the other half of what fits.ts guards. `place` is for a command that + // adds a node; these change or rewrap the block the caret is already in, which is the question + // `change` answers: a raw block refuses all of them, because a conversion writes its preserved + // source back out as escaped markdown and a wrap writes it back out prefixed, and a conversion + // that would take a callout or a toggle away with it is thrown out rather than dispatched. A + // conversion added here that does not go through `change` fails src/editor/fits.test.ts. + + setBlock: (block: BlockCommand) => { + switch (block) { + case "paragraph": + change(editor, "convert", (chain) => chain.clearNodes().setNode("paragraph")); + return; + case "bulletList": + change(editor, "wrap", (chain) => chain.toggleList("bulletList", "listItem")); + return; + case "orderedList": + change(editor, "wrap", (chain) => chain.toggleList("orderedList", "listItem")); + return; + case "taskList": + change(editor, "wrap", (chain) => chain.toggleList("taskList", "taskItem")); + return; + case "blockquote": + change(editor, "wrap", (chain) => chain.toggleWrap("blockquote")); + return; + case "codeBlock": + change(editor, "convert", (chain) => chain.toggleNode("codeBlock", "paragraph")); + return; + case "toggle": + // "unwrap" because this is the toggle's own button: pressed inside one it takes that + // toggle away, summary and all, which is what the user pressed it for. + change(editor, "unwrap", (chain) => chain.toggleWrap("toggle")); + } + }, + + setHeading: (level) => { + if (level === null) change(editor, "convert", (chain) => chain.setNode("paragraph")); + else change(editor, "convert", (chain) => chain.setNode("heading", { level })); + }, + + setCallout, + + // Every one of these goes through `place`, which is the guard and the insert in one call, for + // the reason written out in fits.ts: with the caret in a table cell an unguarded insert splits + // the table around the new node and leaves a row with no cells in it, which is a table the + // serializer writes back as three blank lines, and in a fence or a raw block it cuts the user's + // own bytes in half and writes the remainder out as prose. An insert added here that does not + // go through `place` fails src/editor/fits.test.ts, which is the point of that file. + + insertRule: () => { + place(editor, editor.schema.nodes.horizontalRule, (chain) => + chain.insertContent({ type: "horizontalRule" }), + ); + }, + + // The same guard the insert below runs, asked on its own, because the Insert image tool has to + // write the picture into the assets folder before it has a path to insert and a refusal after + // that write is an orphan file beside somebody's document. src/editor/paste.ts asks this same + // question before it sends any bytes; the toolbar had no way to. + canInsertImage: () => placeable(editor.state, editor.schema.nodes.image), + + // And it still says so when it refuses, for the caller that asks afterwards anyway. + insertImage: (src, alt = null) => { + const placed = place(editor, editor.schema.nodes.image, (chain) => + chain.insertContent({ type: "image", attrs: { src, alt, title: null } }), + ); + if (!placed) notify("An image cannot go where the cursor is."); + }, + + insertTable: (rows, columns) => { + const table = editor.schema.nodes.table; + const cells = (type: string) => + Array.from({ length: Math.max(1, columns) }, () => ({ type })); + const body = Array.from({ length: Math.max(0, rows - 1) }, () => ({ + type: "tableRow", + content: cells("tableCell"), + })); + const searchFrom = Math.max(0, editor.state.selection.$from.pos - 1); + + place(editor, table, (chain) => + chain + .insertContent({ + type: "table", + content: [{ type: "tableRow", content: cells("tableHeader") }, ...body], + }) + // The insert leaves the caret past the table, so the first thing typed lands under it + // rather than in it, and the toolbar goes on reading active.inTable as false while a + // table is on screen. Same transaction as the insert, so it is one undo and not two. + .command(({ tr, dispatch }) => { + if (!dispatch) return true; + let found: number | null = null; + tr.doc.nodesBetween(searchFrom, tr.doc.content.size, (node, pos) => { + if (found !== null) return false; + if (node.type === table) found = pos; + return found === null; + }); + // A table, its first row and its first cell are one position each, so three in is the + // first place text can go. + if (found !== null) tr.setSelection(TextSelection.create(tr.doc, found + 3)); + return true; + }), + ); + }, + + // The four below are the block lanes' own commands, and this is the whole of the wiring: each + // one lives with the extension that gives its block behaviour, in src/editor/blocks/, so that + // tables, code, math and mermaid are worked on without four hands in this file. They return + // false where they have nothing to act on, which is a button pressed in the wrong place and + // means nothing happens. + + tableCommand: (op: TableOp) => { + tableCommand(editor, op); + }, + + insertMath: (display: boolean) => { + insertMath(editor, display); + }, + + insertMermaid: () => { + insertMermaid(editor); + }, + + setCodeLanguage: (language: string | null) => { + setCodeLanguage(editor, language); + }, + }; +} + +/** + * The find bar's own handle, which is the second surface this file publishes and the other place a + * command can reach the document from outside the editor layer. + * + * Exported for src/editor/fits.test.ts for the same reason `createCommands` is: two of these seven + * write to the document, and a handle nothing enumerates is a handle a method gets added to without + * anybody saying where it may run. + */ +export function createFind(editor: Editor): Omit { + return { + setQuery: (query: string, options: SearchOptions) => { + editor.commands.setSearch(query, options); + }, + clear: () => { + editor.commands.clearSearch(); + }, + next: () => { + editor.commands.findNext(); + }, + prev: () => { + editor.commands.findPrev(); + }, + replaceCurrent: (text: string) => { + editor.commands.replaceCurrent(text); + }, + replaceAll: (text: string) => { + editor.commands.replaceAllInDocument(text); + }, + focus: () => { + editor.commands.focus(); + }, + }; +} + +export function DocumentEditor({ + document, + onChange, + onOpenLink, + editable = true, +}: EditorProps): ReactElement { + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + const onOpenLinkRef = useRef(onOpenLink); + onOpenLinkRef.current = onOpenLink; + const documentRef = useRef(document); + documentRef.current = document; + const editableRef = useRef(editable); + editableRef.current = editable; + + const cache = useRef(new Map()); + const host = useRef(null); + const installed = useRef(null); + const position = useRef(null); + const scrollToken = useRef(0); + const publish = useRef<(() => void) | null>(null); + + const extensions = useMemo( + () => + createEditorExtensions({ + documentPath: () => documentRef.current.path, + onError: notify, + }), + [], + ); + + const editor = useEditor({ + extensions, + // Empty on purpose: the layout effect below installs the document through the one code path + // that also restores the caret and reports a document the schema cannot hold. + content: EMPTY_CONTENT, + editable, + immediatelyRender: false, + enableContentCheck: true, + editorProps: createEditorProps({ + editable: () => editableRef.current, + onOpenLink: (href) => onOpenLinkRef.current(href), + }), + onContentError: ({ error }) => reportContentError(error), + onUpdate: ({ editor }) => onChangeRef.current(editor.state.doc), + }); + + const applyScroll = (ed: Editor, top: number) => { + const scroller = scrollerOf(ed); + if (!scroller) return; + const token = (scrollToken.current += 1); + const apply = () => { + if (scrollToken.current === token) scroller.scrollTop = top; + }; + apply(); + requestAnimationFrame(apply); + // Web fonts land after the first paint and change every line's height under the caret with + // them, so the offset that was right a moment ago is wrong once Literata arrives. + window.document.fonts?.ready.then(apply).catch(() => {}); + }; + + const buildState = (ed: Editor, source: MarkdownDocument): EditorState => { + const base = ed.view.state; + try { + // The bridge builds its tree against src/model/schema.ts and TipTap builds an identical one + // of its own from the same specs, so the node has to be rebound before it can be edited. + const doc = ed.schema.nodeFromJSON(source.doc.toJSON()); + doc.check(); + return EditorState.create({ doc, plugins: base.plugins }); + } catch (error) { + reportContentError(error); + return EditorState.create({ schema: base.schema, plugins: base.plugins }); + } + }; + + const remember = (path: string, entry: Cached) => { + cache.current.delete(path); + cache.current.set(path, entry); + for (const stale of Array.from(cache.current.keys()).slice(0, cache.current.size - CACHE_LIMIT)) { + cache.current.delete(stale); + } + }; + + const stash = (ed: Editor, source: MarkdownDocument) => { + const scroll = scrollerOf(ed)?.scrollTop ?? 0; + const { from, to } = ed.state.selection; + remember(source.path, { state: ed.view.state, document: source, scroll }); + savePosition(source.path, { from, to, scroll }); + }; + + const install = (ed: Editor, source: MarkdownDocument, focus: boolean) => { + const cached = cache.current.get(source.path); + // Reusable when it is the same object, or a fresh read of a file whose bytes have not moved + // since that state was built. Anything else and the file is the newer copy, so the cached + // tree goes: an instant reopen is not worth showing somebody yesterday's document. + const entry = + cached && (cached.document === source || cached.document.source === source.source) + ? cached + : null; + if (entry) { + ed.view.updateState(entry.state); + if (focus) ed.commands.focus(undefined, { scrollIntoView: false }); + applyScroll(ed, entry.scroll); + } else { + ed.view.updateState(buildState(ed, source)); + const saved = loadPosition(source.path); + const size = ed.state.doc.content.size; + const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0; + const chain = ed.chain().setTextSelection(selection); + if (focus) chain.focus(undefined, { scrollIntoView: false }); + chain.run(); + remember(source.path, { state: ed.view.state, document: source, scroll: saved?.scroll ?? 0 }); + applyScroll(ed, saved?.scroll ?? 0); + } + position.current = null; + publish.current?.(); + }; + + useLayoutEffect(() => { + if (!editor) return; + // A new editor instance, which React's strict double mount produces, cannot be handed states + // built against the old one's plugins, and it has nothing installed in it yet whatever the + // last document was. + const carried = host.current === editor; + if (!carried) cache.current.clear(); + const previous = carried ? installed.current : null; + if (previous === document) return; + if (previous) stash(editor, previous); + install(editor, document, !previous || previous.path !== document.path); + installed.current = document; + host.current = editor; + // The document is installed by identity, not by field: a new object is a different file or a + // reload from disk, and a keystroke is neither. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editor, document]); + + useEffect(() => { + if (!editor) return; + // Not setEditable(value): its default emits an `update` with no transaction behind it, which + // would reach onChange and mark a document dirty that nobody has typed into. + editor.setEditable(editable, false); + }, [editor, editable]); + + useEffect(() => { + if (!editor) return; + const commands = createCommands(editor); + const find = createFind(editor); + let active = activeStateOf(editor); + currentHandle = { active, ...commands }; + currentFind = { state: { count: 0, current: 0 }, ...find }; + + const push = () => { + const next = activeStateOf(editor); + if (!sameActive(active, next)) { + active = next; + currentHandle = { active, ...commands }; + } + const search = searchStateOf(editor.state); + const count = search?.matches.length ?? 0; + const current = search?.current ?? 0; + if (currentFind === null || currentFind.state.count !== count || currentFind.state.current !== current) { + currentFind = { state: { count, current }, ...find }; + } + announce(); + }; + + publish.current = push; + push(); + editor.on("transaction", push); + + return () => { + editor.off("transaction", push); + publish.current = null; + currentHandle = null; + currentFind = null; + announce(); + }; + }, [editor]); + + useEffect(() => { + if (!editor) return; + const scroller = scrollerOf(editor); + let timer: ReturnType; + + const write = () => { + const source = installed.current; + if (source && position.current) savePosition(source.path, position.current); + }; + + const persist = () => { + const { from, to } = editor.state.selection; + const scroll = scroller?.scrollTop ?? 0; + position.current = { from, to, scroll }; + const entry = installed.current ? cache.current.get(installed.current.path) : undefined; + if (entry) entry.scroll = scroll; + clearTimeout(timer); + timer = setTimeout(write, 400); + }; + + editor.on("selectionUpdate", persist); + scroller?.addEventListener("scroll", persist, { passive: true }); + + return () => { + clearTimeout(timer); + editor.off("selectionUpdate", persist); + scroller?.removeEventListener("scroll", persist); + write(); + }; + }, [editor]); + + return ; +} diff --git a/src/editor/PlainTextEditor.tsx b/src/editor/PlainTextEditor.tsx new file mode 100644 index 0000000..a2824e5 --- /dev/null +++ b/src/editor/PlainTextEditor.tsx @@ -0,0 +1,321 @@ +// A .txt file. Not markdown, so nothing here parses any: a line reading "# heading" is that +// literal text on screen and those literal bytes on disk, and the only thing between the two is a +// textarea. +// +// The document shape mirrors the bridge's `parsePlainText` exactly, one line to one paragraph, +// which is what makes splitting and joining on the newline exact inverses and the round trip byte +// identical down to a missing final newline. +// +// Find has no ProseMirror decorations to draw here, since a textarea has no tree to decorate. What +// it has is a native selection, which is the one highlight a textarea can show, so a match is +// found by selecting it and scrolling it into view rather than by painting a span around it. The +// state that search.ts keeps in a plugin lives in a ref instead, published through the same +// `DocumentFind` shape src/editor/index.ts declares for the markdown surface, so FindBar.tsx never +// has to know which editor it is talking to. + +import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; +import type { ReactElement } from "react"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { schema } from "../model/schema"; +import { + EMPTY_PLAIN_SEARCH, + recomputePlainSearch, + replaceAllMatches, + replaceMatch, + sameQuery, + type PlainSearchState, + type SearchMatch, +} from "./plainFind"; +import type { DocumentFind, PlainTextProps } from "./index"; + +function textOf(doc: ProseMirrorNode): string { + const lines: string[] = []; + doc.forEach((block) => lines.push(block.textContent)); + return lines.join("\n"); +} + +function docOf(text: string): ProseMirrorNode { + const lines = text.split("\n"); + const blocks = lines.map((line) => + schema.nodes.paragraph.create(null, line ? schema.text(line) : null), + ); + return schema.nodes.doc.create(null, blocks); +} + +/** The properties a mirror div needs to copy for its line wrapping, and so the vertical position + * it measures, to match the textarea's own. Only what wrapping and line height depend on: nothing + * about colour or the caret. */ +function copyWrappingStyle(mirror: HTMLDivElement, field: HTMLTextAreaElement): void { + const style = getComputedStyle(field); + mirror.style.position = "absolute"; + mirror.style.visibility = "hidden"; + mirror.style.top = "0"; + mirror.style.left = "-9999px"; + mirror.style.width = `${field.clientWidth}px`; + mirror.style.fontFamily = style.fontFamily; + mirror.style.fontSize = style.fontSize; + mirror.style.fontWeight = style.fontWeight; + mirror.style.fontStyle = style.fontStyle; + mirror.style.letterSpacing = style.letterSpacing; + mirror.style.lineHeight = style.lineHeight; + mirror.style.textTransform = style.textTransform; + mirror.style.wordSpacing = style.wordSpacing; + mirror.style.tabSize = style.tabSize; + mirror.style.whiteSpace = style.whiteSpace; + mirror.style.wordBreak = style.wordBreak; + mirror.style.overflowWrap = style.overflowWrap; +} + +/** Where a character offset lands inside the textarea's own box, found the only way a plain + * textarea allows: rendering the same text in an invisible twin under the same font and width and + * reading back where a marker after it fell. There is no scroll of its own to subtract, since the + * field is always exactly as tall as its text (see the layout effect below). */ +function caretOffset(field: HTMLTextAreaElement, index: number): { top: number; height: number } { + const mirror = window.document.createElement("div"); + copyWrappingStyle(mirror, field); + mirror.textContent = field.value.slice(0, index); + const marker = window.document.createElement("span"); + marker.textContent = field.value.slice(index, index + 1) || "."; + mirror.appendChild(marker); + window.document.body.appendChild(mirror); + const top = marker.offsetTop; + const height = marker.offsetHeight; + window.document.body.removeChild(mirror); + return { top, height }; +} + +function scrollerOf(field: HTMLTextAreaElement): HTMLElement | null { + for (let node = field.parentElement; node; node = node.parentElement) { + if (node.classList.contains("editor-pane")) return node; + } + return null; +} + +/** The textarea has no scrollbar of its own, so bringing a match into view means scrolling the + * pane around it instead, the same "roughly centred" placement search.ts asks the pane for. */ +function scrollMatchIntoView(field: HTMLTextAreaElement, match: SearchMatch): void { + const pane = scrollerOf(field); + if (!pane) return; + const { top, height } = caretOffset(field, match.from); + const fieldTop = field.getBoundingClientRect().top - pane.getBoundingClientRect().top + pane.scrollTop; + const target = fieldTop + top - pane.clientHeight / 2 + height / 2; + pane.scrollTo({ top: Math.max(0, target), behavior: "smooth" }); +} + +const findListeners = new Set<() => void>(); +let currentPlainFind: DocumentFind | null = null; + +function subscribePlainFind(listener: () => void): () => void { + findListeners.add(listener); + return () => { + findListeners.delete(listener); + }; +} + +function announcePlainFind(): void { + for (const listener of findListeners) listener(); +} + +const plainFindSnapshot = () => currentPlainFind; + +/** + * Find and replace for the .txt surface, published the same way Editor.tsx publishes the markdown + * one, so src/editor/index.ts can hand `useDocumentFind` whichever of the two is actually on + * screen without either side knowing the other exists. + */ +export function usePlainTextFind(): DocumentFind | null { + return useSyncExternalStore(subscribePlainFind, plainFindSnapshot, plainFindSnapshot); +} + +export function PlainTextEditor({ + document, + onChange, + editable = true, +}: PlainTextProps): ReactElement { + const field = useRef(null); + const [text, setText] = useState(() => textOf(document.doc)); + const source = useRef(document); + const textRef = useRef(text); + textRef.current = text; + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + const search = useRef(EMPTY_PLAIN_SEARCH); + const publishRef = useRef<() => void>(() => {}); + + // Set when the open file is replaced by a newer read of itself, which is what an external edit + // to a clean buffer looks like from here. Editor.tsx stashes and restores the ProseMirror + // selection across the same event; a textarea has no selection of its own to survive a value + // change, so it has to be carried by hand or the caret lands at the end of the file. + const carry = useRef<{ start: number; end: number } | null>(null); + + if (source.current !== document) { + const reload = source.current.path === document.path; + const el = field.current; + carry.current = reload && el ? { start: el.selectionStart, end: el.selectionEnd } : null; + source.current = document; + const next = textOf(document.doc); + setText(next); + textRef.current = next; + // A new file has nothing to do with whatever was being searched for in the last one, and its + // matches would point at the wrong offsets anyway. A reload of the same file is the same + // story: the offsets are against text that has just been replaced. + search.current = EMPTY_PLAIN_SEARCH; + } + + const methods = useRef | null>(null); + + // Only a new object when the count or the position in it actually moved, the same guard + // Editor.tsx's own `push` keeps: FindBar re-issues `setQuery`/`clear` on every render it is open + // for, and a new object on every one of those, whether anything changed or not, is what a + // `useSyncExternalStore` subscriber reads as new state to render, which is what that repeated + // call turns into an infinite loop rather than the no-op it is meant to be. + const publish = () => { + const count = search.current.matches.length; + const current = search.current.current; + if (!currentPlainFind || currentPlainFind.state.count !== count || currentPlainFind.state.current !== current) { + currentPlainFind = { state: { count, current }, ...methods.current! }; + } + announcePlainFind(); + }; + publishRef.current = publish; + + if (!methods.current) { + // Collapses whatever is selected without moving the caret: the closest a textarea has to + // "no decoration", for the moment a query stops matching anything or find closes altogether. + const deselect = () => { + const el = field.current; + if (!el) return; + el.setSelectionRange(el.selectionStart, el.selectionStart); + }; + + const land = (next: PlainSearchState) => { + search.current = next; + const match = next.matches[next.current]; + if (match) { + const el = field.current; + if (el) { + el.setSelectionRange(match.from, match.to); + scrollMatchIntoView(el, match); + } + } else { + deselect(); + } + publishRef.current(); + }; + + methods.current = { + setQuery: (query, options) => { + if (sameQuery(search.current, query, options)) return; + land(recomputePlainSearch(textRef.current, query, options, 0)); + }, + clear: () => { + search.current = EMPTY_PLAIN_SEARCH; + deselect(); + publishRef.current(); + }, + next: () => { + const s = search.current; + if (!s.matches.length) return; + land({ ...s, current: (s.current + 1) % s.matches.length }); + }, + prev: () => { + const s = search.current; + if (!s.matches.length) return; + land({ ...s, current: (s.current - 1 + s.matches.length) % s.matches.length }); + }, + replaceCurrent: (replacement) => { + const s = search.current; + if (!s.matches.length) return; + const match = s.matches[s.current]; + const nextText = replaceMatch(textRef.current, match, replacement); + textRef.current = nextText; + setText(nextText); + onChangeRef.current(docOf(nextText)); + land(recomputePlainSearch(nextText, s.query, s.options, s.current)); + }, + replaceAll: (replacement) => { + const s = search.current; + if (!s.matches.length) return; + const nextText = replaceAllMatches(textRef.current, s.matches, replacement); + textRef.current = nextText; + setText(nextText); + onChangeRef.current(docOf(nextText)); + land(recomputePlainSearch(nextText, s.query, s.options, s.current)); + }, + focus: () => { + field.current?.focus(); + }, + }; + } + + useEffect(() => { + field.current?.focus(); + }, [document.path]); + + // Republished after every render that changed which document is open, which is what carries the + // reset above (a new file means no active search) out to whatever is drawing the find bar. The + // functions themselves close over refs and read them fresh on every call, so nothing here needs + // rebuilding when only the text or the search changes, just the announcing of it. + useLayoutEffect(() => { + publishRef.current(); + }, [document]); + + // Unregistering is a real unmount only, not a document change: switching files keeps this + // component and its handle in place, and only leaving the plain text surface entirely (the + // document closes, or a markdown file replaces it) should hand `useDocumentFind` back to null. + useEffect(() => { + return () => { + currentPlainFind = null; + announcePlainFind(); + }; + }, []); + + // The pane is the scroller for every other document, and a textarea with its own scrollbar + // inside that pane would be two of them, one of which puts the last line under the chrome with + // no way to scroll it clear. So the field is always exactly as tall as its text. + useLayoutEffect(() => { + const el = field.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + + // Clamped, because the edit that arrived from outside may well be shorter than what was on + // screen. Landing at the end of a file that shrank under you is the same complaint as landing + // at the end of one that grew. + const want = carry.current; + carry.current = null; + if (!want) return; + const end = Math.min(want.end, el.value.length); + el.setSelectionRange(Math.min(want.start, end), end); + }, [text]); + + return ( +