mirror of
https://github.com/priyanshujain/margin-docs.git
synced 2026-10-02 11:07:05 +00:00
feature fixes
This commit is contained in:
1 parent
1b31e2f189
commit
586ee946d0
38 files changed
+1925
-247
No files matched your search
@@ -52,14 +52,27 @@ jobs:
|
||||
- name: Stub the build inputs
|
||||
run: mkdir -p dist && touch dist/index.html
|
||||
|
||||
# Every target except `no_write_on_open`, which is skipped rather than run and failing.
|
||||
# That suite checks the promise the whole project rests on, that the editor writes nothing
|
||||
# the user did not edit, and it checks it against a real git repository rather than a
|
||||
# TempDir. The repository is hand built at a hardcoded path under /tmp, so there is nothing
|
||||
# here to create it and macOS reaps it after three days without access. It is not skipped
|
||||
# because it is unimportant. It is skipped because it currently cannot run anywhere, and a
|
||||
# CI that is permanently red says less than one that is green about a named subset.
|
||||
# Making the fixture reproducible is tracked in the issue.
|
||||
# Every target except `no_write_on_open`, which gets the step below to itself.
|
||||
- name: Test
|
||||
working-directory: src-tauri
|
||||
run: cargo test --lib --bins --test fs --test fts5 --test watch --test watch_payload
|
||||
|
||||
# The suite that checks the promise the whole project rests on, that the editor writes
|
||||
# nothing the user did not edit. It runs against a real git repository rather than against a
|
||||
# TempDir, because the evidence it wants is a folder somebody could plausibly have opened
|
||||
# coming back from `git status` with no lines in it, and a bag of files in a TempDir cannot
|
||||
# say that. The repository used to be built by hand at a fixed path under /tmp, which meant
|
||||
# the suite ran on one machine until macOS reaped the folder and then ran nowhere, and that
|
||||
# is why it spent a while excluded from this file. It is now built from scratch by
|
||||
# `tests/support/notes_repo.rs` on the first test that asks for it: the documents are copied
|
||||
# out of the markdown corpus, the vendored node_modules is generated, and a git init and one
|
||||
# commit turn the result into the oracle. Nothing has to exist on the runner beforehand
|
||||
# except git itself, and the suite says so plainly if git is missing rather than passing on a
|
||||
# folder nobody checked.
|
||||
#
|
||||
# It gets its own step because the tests share that one folder and several of them mutate it,
|
||||
# so they have to run one at a time. Passing `--test-threads=1` to the command above would
|
||||
# slow the other suites to the same pace for no reason.
|
||||
- name: Test no_write_on_open
|
||||
working-directory: src-tauri
|
||||
run: cargo test --test no_write_on_open -- --test-threads=1
|
||||
@@ -81,41 +81,16 @@ jobs:
|
||||
|
||||
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 }}
|
||||
# One runner, no matrix. The app is macOS only, and a universal build links the aarch64 and
|
||||
# x86_64 slices into a single bundle, so there is exactly one thing to build and nothing for a
|
||||
# matrix to vary. A matrix of one is where the Linux row survived long after the app stopped
|
||||
# shipping on Linux; if a second target ever arrives, putting the matrix back is a small change.
|
||||
runs-on: macos-26
|
||||
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
|
||||
@@ -127,7 +102,7 @@ jobs:
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.rust-targets }}
|
||||
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
@@ -144,7 +119,7 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
releaseId: ${{ needs.prepare.outputs.release_id }}
|
||||
args: ${{ matrix.args }}
|
||||
args: "--target universal-apple-darwin --config src-tauri/tauri.release.conf.json"
|
||||
|
||||
publish:
|
||||
needs: [prepare, build]
|
||||
@@ -159,7 +134,14 @@ jobs:
|
||||
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
|
||||
# A universal build emits one .app.tar.gz, but tauri-action writes it into latest.json
|
||||
# under both darwin-aarch64 and darwin-x86_64, pointing them at the same file and the same
|
||||
# signature. It has to: an installed copy asks the manifest for the architecture it is
|
||||
# running on and never for the universal key, so a manifest that only carried
|
||||
# darwin-universal would offer nobody an update. Those two keys are the whole manifest for
|
||||
# this app, and a release that is missing either one is a release half the users cannot
|
||||
# take.
|
||||
for key in darwin-aarch64 darwin-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
|
||||
|
||||
+76
-1
@@ -44,6 +44,16 @@ character typed into a hand wrapped file put a backslash at the end of every lin
|
||||
`prosemirror-transform` asks the same field before it joins two blocks, so Backspace between two
|
||||
hand wrapped paragraphs rewrote the wraps in both.
|
||||
|
||||
The heading needs all three for the same reason and was declared with none of them, which is the
|
||||
same bug wearing the one hat markdown lets an author wrap by hand. A setext heading is two lines of
|
||||
text over an underline, and mdast writes a break inside one as a backslash, so a character typed
|
||||
into a two line heading put a backslash in the user's words exactly as it used to in a paragraph.
|
||||
The first save cannot rescue it either, because a heading that has been wrapped has no ATX spelling
|
||||
to be normalised into, so it stays setext and every later keystroke re-arms the bug. The field goes
|
||||
on at every level rather than only the two that have an underline: the deeper four cannot be wrapped
|
||||
in the file, but they can hold a line ending written `
`, and at the default a keystroke turned
|
||||
that into a break the writer then swallowed as a space.
|
||||
|
||||
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 `<p>`
|
||||
off somebody else's web page, where the line endings are that page's source indentation and keeping
|
||||
@@ -144,6 +154,16 @@ every construct markdown has except a list, which carries on over one and swallo
|
||||
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.
|
||||
|
||||
A rule inside a list item is the same question asked from the other side, and it is the one place
|
||||
the house style has to give way rather than the seam. `---` is the house spelling and it is a setext
|
||||
underline wherever a paragraph is still open above it, which inside a tight item is the only place
|
||||
the line under the item's text can be. Reading that as "these two blocks cannot be written a line
|
||||
apart" is correct about `---` and wrong about the rule, so the writer spread the whole list to make
|
||||
room, and a tight list came back loose with its items wrapped in paragraphs they did not have. `***`
|
||||
is the same rule in the one spelling that does interrupt a paragraph, the writer already reaches for
|
||||
it when a rule lands on the first byte of a file, and a tight item now gets it. A loose item keeps
|
||||
`---`, because the blank line in front of it has already closed the paragraph.
|
||||
|
||||
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
|
||||
@@ -154,6 +174,15 @@ knowingly drops something the user typed, and it is the right way round: the alt
|
||||
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.
|
||||
|
||||
Dropping it is the decision; dropping it in silence was the bug. The writer had no way to say it had
|
||||
happened, so the save path marked the buffer clean over bytes the edit was not in: the file stopped
|
||||
changing, the unsaved dot went out, and the only trace was the edit still sitting on screen. The
|
||||
writer now reports the refusal on the way past, the dirty flag has that as a second input beside the
|
||||
tree comparison, and the toast says which block and why the file won. The flag stands for exactly as
|
||||
long as those bytes are the file's, so a later lap of the debounce over the same unsavable block
|
||||
says nothing more, and it is deliberately kept out of the question the debounce asks, since a
|
||||
refused edit is not a difference another write could fix and re-arming for it would be a loop.
|
||||
|
||||
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
|
||||
@@ -308,7 +337,21 @@ carries no HTML for ProseMirror to parse and so arrives as the empty slice, empt
|
||||
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
|
||||
index of every plugin claiming a paste or a drop, and fails if anything is in front. A guard is also worth nothing when the gesture it is watching for produces no event to answer, and
|
||||
that is the sibling of the ordering fault rather than a different kind of mistake. Typing over a
|
||||
rectangle of cells is refused through `handleTextInput`, which prosemirror-view offers only for a
|
||||
character the browser was about to insert. An IME produces none: the browser writes the composition
|
||||
into the DOM itself and prosemirror-view reads the result back, and on the way there its own
|
||||
`compositionstart` replaces the rectangle with whatever a DOM range spanning cells makes, because
|
||||
prosemirror-tables hands back a cell selection only while a mouse drag is still down. What follows
|
||||
is either that range collapsed on to the whole content of one cell and replaced, or a text selection
|
||||
left spanning cell boundaries and replaced across them, which joins the cells and rows away and
|
||||
hands the writer a table whose rows disagree in width. Neither reaches the guard, and the second
|
||||
never reaches it by construction, since a change crossing a textblock is dispatched without the
|
||||
offer being made. Claiming the event does not help, because a composition cannot be cancelled and
|
||||
returning true only stops ProseMirror's own bookkeeping. What is decidable is where the composition
|
||||
lands, so the rectangle is collapsed to a caret at the end of its anchor cell before one can start,
|
||||
and false is returned so the library's own handler still runs against that caret. 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.
|
||||
@@ -416,6 +459,38 @@ answers three things a plain file tree cannot: quick open by filename and path o
|
||||
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.
|
||||
|
||||
Derived state that only rebuilds at launch has to survive the session, and the worker behind it was
|
||||
written as though it could not fail. It ran a bare loop over its queue, so one panic ended indexing
|
||||
for as long as the app stayed open: the receiver dropped, every later send was discarded by a `.ok()`
|
||||
that never looked, and search, quick open and backlinks went on answering from a frozen snapshot
|
||||
with nothing on screen to say so. There was a reachable way to reach it, too, since a document is
|
||||
free to contain the control character the search snippets are marked with, and a line holding one
|
||||
and nothing else made the snippet slice start after it ended. The panic fired inside the search
|
||||
command, which holds the connection across its whole row loop, so it poisoned the mutex and took the
|
||||
worker with it. The loop now catches a panic, clears the poison, says so in the index status and
|
||||
takes the next job, and it drains its queue before acting on it rather than one message at a time,
|
||||
because the watcher's report that the kernel dropped events is a full walk of the root and hundreds
|
||||
of those can pile up behind a long build. The drain keeps the last word about each path and never
|
||||
reorders, which is the rule the debouncer already applies inside one batch.
|
||||
|
||||
The watcher had the same shape of gap about its own root. Detecting that the open folder had gone
|
||||
rode on an event arriving for something inside it, because `notify` does not ask FSEvents to watch
|
||||
the path to the root and discards anything above it, so renaming or deleting a parent directory was
|
||||
never noticed at all and the watch was left holding a stream on a path that no longer existed. A
|
||||
watchdog beside each watch stats the root on the debounce tick instead, which is one call and does
|
||||
not depend on any event being delivered by any backend.
|
||||
|
||||
The link rewrite sweep is the one consumer that deliberately does not share the walk. The tree and
|
||||
the index honour `.gitignore` because a sidebar full of build output and a search box full of
|
||||
vendored READMEs are both worse than those files being hidden, and the invariant that a file the
|
||||
tree hides is a file no search result can open is worth keeping for both. The sweep is not display,
|
||||
it writes to the user's files, and a `.gitignore` is a statement about version control rather than
|
||||
about whether something is a document: a relative link inside an ignored draft is one the user still
|
||||
follows, and leaving it pointing at a path this app is the one that moved is a break nobody finds
|
||||
until they follow it. So it gets its own walk with the git sources off and everything else on, the
|
||||
four always skipped folders included, and it is bounded by a document count it reports rather than
|
||||
by a depth it would have to hide.
|
||||
|
||||
## Order of work
|
||||
|
||||
The filesystem layer and the markdown bridge are the two things everything else depends on, and
|
||||
|
||||
+10
-7
@@ -22,14 +22,17 @@ bump the patch number, or give one to set it. The workflow bumps `tauri.conf.jso
|
||||
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.
|
||||
It builds one universal macOS bundle and publishes nothing until it has landed. The last job
|
||||
downloads `latest.json` and refuses to take the release out of draft unless `darwin-aarch64` and
|
||||
`darwin-x86_64` are both present in it. One universal build writes both of those keys, pointing
|
||||
them at the same archive and the same signature, because an installed copy asks the manifest for
|
||||
the architecture it is running on and never for a universal one. 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.
|
||||
Linux is not built. It was until recently, because this pipeline was copied from margin-calendar,
|
||||
which ships on Linux. This app does not, so the row and the manifest key it required are gone and
|
||||
the release runs on one macOS runner.
|
||||
|
||||
Windows is not built. Nothing in `tauri.conf.json` targets it and the app has never claimed it.
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/node": "^22.20.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
|
||||
@@ -8,6 +8,10 @@ const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
// Run before anything else, because `reuseExistingServer` below means the server on the port may
|
||||
// belong to another checkout, and a suite that tests somebody else's code is worse than no suite.
|
||||
// What it checks and why it can check it by comparing bytes is in tests/identity.ts.
|
||||
globalSetup: "./tests/identity.ts",
|
||||
fullyParallel: true,
|
||||
// No retries on purpose. A test that only passes on the second go is a test that is lying about
|
||||
// something.
|
||||
|
||||
Generated
+31
-14
@@ -96,6 +96,9 @@ importers:
|
||||
'@types/katex':
|
||||
specifier: ^0.16.8
|
||||
version: 0.16.8
|
||||
'@types/node':
|
||||
specifier: ^22.20.1
|
||||
version: 22.20.1
|
||||
'@types/react':
|
||||
specifier: ^19.1.8
|
||||
version: 19.2.18
|
||||
@@ -104,16 +107,16 @@ importers:
|
||||
version: 19.2.4(@types/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.6.0
|
||||
version: 4.7.0([email protected])
|
||||
version: 4.7.0([email protected](@types/[email protected]))
|
||||
typescript:
|
||||
specifier: ~5.8.3
|
||||
version: 5.8.3
|
||||
vite:
|
||||
specifier: ^7.0.4
|
||||
version: 7.3.6
|
||||
version: 7.3.6(@types/[email protected])
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.7(@types/[email protected])
|
||||
version: 3.2.7(@types/[email protected])(@types/[email protected])
|
||||
|
||||
packages:
|
||||
|
||||
@@ -911,6 +914,9 @@ packages:
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
|
||||
peerDependencies:
|
||||
@@ -1713,6 +1719,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
|
||||
@@ -2594,6 +2603,10 @@ snapshots:
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/[email protected](@types/[email protected])':
|
||||
dependencies:
|
||||
'@types/react': 19.2.18
|
||||
@@ -2614,7 +2627,7 @@ snapshots:
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1([email protected])
|
||||
|
||||
'@vitejs/[email protected]([email protected])':
|
||||
'@vitejs/[email protected]([email protected](@types/[email protected]))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/[email protected])
|
||||
@@ -2622,7 +2635,7 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.27
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.17.0
|
||||
vite: 7.3.6
|
||||
vite: 7.3.6(@types/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -2634,13 +2647,13 @@ snapshots:
|
||||
chai: 5.3.3
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/[email protected]([email protected])':
|
||||
'@vitest/[email protected]([email protected](@types/[email protected]))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.7
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.6
|
||||
vite: 7.3.6(@types/[email protected])
|
||||
|
||||
'@vitest/[email protected]':
|
||||
dependencies:
|
||||
@@ -3679,6 +3692,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -3735,13 +3750,13 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
[email protected]:
|
||||
[email protected](@types/[email protected]):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 7.3.6
|
||||
vite: 7.3.6(@types/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
@@ -3756,7 +3771,7 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
[email protected]:
|
||||
[email protected](@types/[email protected]):
|
||||
dependencies:
|
||||
esbuild: 0.28.2
|
||||
fdir: 6.5.0([email protected])
|
||||
@@ -3765,13 +3780,14 @@ snapshots:
|
||||
rollup: 4.62.5
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 22.20.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected](@types/[email protected]):
|
||||
[email protected](@types/[email protected])(@types/[email protected]):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.7
|
||||
'@vitest/mocker': 3.2.7([email protected])
|
||||
'@vitest/mocker': 3.2.7([email protected](@types/[email protected]))
|
||||
'@vitest/pretty-format': 3.2.7
|
||||
'@vitest/runner': 3.2.7
|
||||
'@vitest/snapshot': 3.2.7
|
||||
@@ -3789,11 +3805,12 @@ snapshots:
|
||||
tinyglobby: 0.2.17
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 7.3.6
|
||||
vite-node: 3.2.4
|
||||
vite: 7.3.6(@types/[email protected])
|
||||
vite-node: 3.2.4(@types/[email protected])
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/debug': 4.1.13
|
||||
'@types/node': 22.20.1
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
|
||||
+115
-10
@@ -20,7 +20,7 @@ use std::sync::atomic::{AtomicU64, Ordering as Memory};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use ignore::WalkBuilder;
|
||||
use ignore::{DirEntry, WalkBuilder};
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
@@ -387,6 +387,27 @@ fn assemble(
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// True for everything a walk should keep, which is everything except one of the four always
|
||||
/// skipped names turning up as a folder somewhere below the walk root. Returning false for a
|
||||
/// directory prunes it, so nothing inside it is walked either.
|
||||
///
|
||||
/// The root itself is kept whatever it is called, because a user who opens a folder named `dist`
|
||||
/// opened it deliberately and hiding its entire contents from them would be absurd. Files are kept
|
||||
/// whatever they are called too: the four names describe folders, and a document called `target.md`
|
||||
/// is a document.
|
||||
///
|
||||
/// Shared by every walk in the app rather than written out once per walk, so the sidebar, the index
|
||||
/// and the link sweep cannot drift apart about which folders are never worth descending into.
|
||||
fn not_always_skipped(entry: &DirEntry) -> bool {
|
||||
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())
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -407,15 +428,7 @@ pub fn scan_tree(root: &Path, show_ignored: bool) -> Result<FileNode, String> {
|
||||
.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())
|
||||
});
|
||||
builder.filter_entry(not_always_skipped);
|
||||
}
|
||||
|
||||
let mut nodes: HashMap<PathBuf, FileNode> = HashMap::new();
|
||||
@@ -445,6 +458,73 @@ pub fn scan_tree(root: &Path, show_ignored: bool) -> Result<FileNode, String> {
|
||||
assemble(root, &mut nodes, &children).ok_or_else(|| format!("cannot read {}", root.display()))
|
||||
}
|
||||
|
||||
/// Every markdown document under `root`, as flat paths, walked by the link sweep's rules rather
|
||||
/// than the sidebar's.
|
||||
///
|
||||
/// This walk exists because a .gitignore is a statement about version control and not about whether
|
||||
/// a file is a document. The tree and the index honour it, and they are right to: a sidebar full of
|
||||
/// build output and a search box full of vendored READMEs are both worse than those files staying
|
||||
/// out of sight, and neither of them changes anything by leaving a file alone. A link is the
|
||||
/// opposite case. A relative link inside an ignored draft is still a link the user follows, and
|
||||
/// leaving it pointing at a path that this app is the thing that moved is a break nobody finds
|
||||
/// until the day they click it. Hiding that file costs the user a broken document rather than a
|
||||
/// tidy sidebar, so the sweep walks by its own rules and the two are allowed to disagree.
|
||||
///
|
||||
/// So the three git sources come off and the four hardcoded folders stay on, which is what keeps a
|
||||
/// checkout's node_modules out of the sweep whether or not git was ever asked about it. Hidden
|
||||
/// files stay out for the same reason, since a dotted folder is where other languages keep their
|
||||
/// tooling and none of `.venv`, `.next`, `.cache`, `.tox` or `.gradle` holds a link anybody wrote.
|
||||
/// A `.ignore` or `.rgignore` is still honoured, because that file is written for tools that walk
|
||||
/// rather than for git, which makes it the honest way to tell this walk to stay out of a folder.
|
||||
///
|
||||
/// `limit` bounds the work, because the sweep reads and rewrites every file this returns and an
|
||||
/// unbounded one over a folder the size of somebody's home directory is not what they asked for
|
||||
/// when they renamed a file. There is deliberately no depth cap to go with it: a document one level
|
||||
/// past a depth cap is silently not swept and nothing anywhere says so, which is the same class of
|
||||
/// bug this function exists to fix. A count is honest instead, because the caller can see it was
|
||||
/// hit. Which is why one path past the limit comes back rather than exactly `limit` of them: a
|
||||
/// folder holding exactly the budget and a folder holding ten thousand more look identical at
|
||||
/// `limit` paths, and the caller has to be able to tell them apart to say that its coverage was
|
||||
/// partial rather than reporting a complete sweep of a subset.
|
||||
pub fn documents_for_sweep(root: &Path, limit: usize) -> Vec<String> {
|
||||
let mut builder = WalkBuilder::new(root);
|
||||
builder
|
||||
// A symlinked folder pointing back at one of its own ancestors would otherwise walk for
|
||||
// ever, exactly as it would for the tree.
|
||||
.follow_links(false)
|
||||
.hidden(true)
|
||||
// No climbing above the walk root looking for ignore files. The sweep is about this one
|
||||
// folder, and what some parent of it happens to say about it is not this folder's business.
|
||||
.parents(false)
|
||||
.ignore(true)
|
||||
.git_ignore(false)
|
||||
.git_global(false)
|
||||
.git_exclude(false);
|
||||
builder.filter_entry(not_always_skipped);
|
||||
|
||||
let mut out = Vec::new();
|
||||
for entry in builder.build() {
|
||||
// One unreadable entry is one document the sweep does not visit and not a failed sweep. The
|
||||
// caller reports what it covered either way, so losing a row here is a smaller and more
|
||||
// honest failure than refusing to rewrite anything at all.
|
||||
let Ok(entry) = entry else { continue };
|
||||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
// Markdown only. Plain text is indexed and searchable, but nothing in a .txt is a markdown
|
||||
// link this app knows how to rewrite, and opening every one of them to find that out would
|
||||
// be work spent to change nothing.
|
||||
if kind_for(entry.path(), false) != "markdown" {
|
||||
continue;
|
||||
}
|
||||
out.push(path_string(entry.path()));
|
||||
if out.len() > limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn read_document(path: &Path) -> Result<ReadResult, String> {
|
||||
// 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
|
||||
@@ -756,6 +836,31 @@ pub fn tree_read(roots: State<'_, Roots>, root_id: String) -> Result<FileNode, S
|
||||
scan_tree(Path::new(&path), false)
|
||||
}
|
||||
|
||||
/// The flat list of markdown documents the link rewrite sweep has to visit in one root, at most
|
||||
/// `limit` of them plus one more if the folder holds more than that.
|
||||
///
|
||||
/// This is deliberately not `tree_read`, and the difference is the whole point of it. The tree
|
||||
/// hides what the folder's gitignore hides, which is the right answer for a sidebar and for search
|
||||
/// because both of them only ever read: the worst a hidden row costs is a file the user has to find
|
||||
/// another way. The sweep writes. A stale link left inside a file the tree chose not to show is
|
||||
/// bytes on disk that look correct and are not, and the user learns about it by clicking the link
|
||||
/// long after the rename that broke it. Reusing the tree's walk here would mean the app quietly
|
||||
/// breaks the documents it decided were not worth showing, which is worse than either not renaming
|
||||
/// or renaming loudly.
|
||||
///
|
||||
/// The count that comes back is the caller's, not this command's, business: a list longer than
|
||||
/// `limit` means the folder overflowed the budget, and the caller is expected to say the sweep was
|
||||
/// partial rather than rewrite the first `limit` files and report a finished job.
|
||||
#[tauri::command(async)]
|
||||
pub fn sweep_documents(
|
||||
roots: State<'_, Roots>,
|
||||
root_id: String,
|
||||
limit: u32,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let path = roots.path_for(&root_id)?;
|
||||
Ok(documents_for_sweep(Path::new(&path), limit as usize))
|
||||
}
|
||||
|
||||
/// Opens Finder with the file selected, rather than opening the file.
|
||||
#[tauri::command]
|
||||
pub fn reveal_in_finder(
|
||||
|
||||
+137
-8
@@ -20,6 +20,7 @@
|
||||
// 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::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering as Memory};
|
||||
@@ -53,6 +54,14 @@ const LAST_INDEXED_KEY: &str = "last_indexed";
|
||||
/// enough that a pass is not one transaction per file.
|
||||
const BATCH: usize = 64;
|
||||
|
||||
/// Largest file whose text is read into the full text table. Nothing anybody typed is this big: it
|
||||
/// is an export, a dataset or a log that happens to end in .txt, and an appended-to log is rewritten
|
||||
/// on every debounce window for as long as the app is open, so the whole of it would be read and
|
||||
/// tokenised again every time. The row is still written, because the path is worth finding in quick
|
||||
/// open and only the text is left out, which is the same answer this file already gives a document
|
||||
/// that is not UTF-8.
|
||||
const BODY_MAX: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// 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;
|
||||
@@ -199,8 +208,13 @@ pub fn open(app: &AppHandle) -> Result<(), String> {
|
||||
fn connect(file: &Path) -> Result<Connection, String> {
|
||||
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;")
|
||||
// derived from a file on disk: the worst a power cut can cost is a rescan. The size limit is
|
||||
// what makes the write ahead log give its space back after a checkpoint rather than keeping the
|
||||
// high water mark of the largest rebuild for the life of the database, which on a big folder is
|
||||
// the whole of it left sitting in the app data directory until the file is deleted.
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA journal_size_limit = 33554432;",
|
||||
)
|
||||
.map_err(|e| format!("{}: {e}", file.display()))?;
|
||||
migrate(&conn)?;
|
||||
Ok(conn)
|
||||
@@ -367,9 +381,27 @@ fn work(app: AppHandle, jobs: mpsc::Receiver<Job>) {
|
||||
pass
|
||||
};
|
||||
|
||||
for job in jobs {
|
||||
// One blocking wait for the first job, then everything else already sitting behind it, taken as
|
||||
// a batch rather than one at a time. When the kernel drops filesystem events the watcher reports
|
||||
// the root itself as modified, and that job is a walk and a sweep of every folder the user has
|
||||
// open: a two minute build that keeps the kernel dropping queues hundreds of them, and doing
|
||||
// each one in turn means repeating the same full walk hundreds of times while the index falls
|
||||
// further behind the disk with every repeat. It has to be collapsed on this side of the channel
|
||||
// and not by bounding it, because the sender is the debounce callback and holds up the next
|
||||
// batch of events for as long as it is made to wait.
|
||||
while let Ok(first) = jobs.recv() {
|
||||
let mut batch = vec![first];
|
||||
while let Ok(more) = jobs.try_recv() {
|
||||
batch.push(more);
|
||||
}
|
||||
for job in coalesce(batch) {
|
||||
let Ok(index) = state(&app) else { continue };
|
||||
let outcome = match job {
|
||||
// A panic in here would take this thread with it and nothing above would notice. The
|
||||
// sender lives in a OnceLock that is never replaced, so every later job would be dropped
|
||||
// by `send` without a word, and search, quick open and backlinks would go on answering
|
||||
// from the snapshot the index happened to be holding at that moment for the rest of the
|
||||
// session. One document the indexer cannot handle is not worth that.
|
||||
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match job {
|
||||
Job::Rebuild(roots) => {
|
||||
let done = rebuild_pass(&app, &index, &roots, next());
|
||||
index.rebuilding.store(false, Memory::SeqCst);
|
||||
@@ -379,11 +411,63 @@ fn work(app: AppHandle, jobs: mpsc::Receiver<Job>) {
|
||||
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)),
|
||||
}));
|
||||
let outcome = match caught {
|
||||
Ok(outcome) => outcome,
|
||||
Err(_) => {
|
||||
// A rebuild that unwound never reached its own `store`, and the flag left set is
|
||||
// the status line stuck on "indexing" and every later rebuild declining to run.
|
||||
index.rebuilding.store(false, Memory::SeqCst);
|
||||
// Clearing the poison is safe because there is no half written state to inherit:
|
||||
// whatever transaction the panic happened inside was dropped on the way out, and
|
||||
// dropping a transaction rolls it back, so the database is exactly where it was
|
||||
// before the job started. Leaving the poison would fail every later lock instead,
|
||||
// which is the same frozen index arrived at by a different route.
|
||||
index.conn.clear_poison();
|
||||
index.status.clear_poison();
|
||||
let mut status = status_of(&index).unwrap_or_default();
|
||||
status.phase = "error".to_string();
|
||||
status.error =
|
||||
Some("the indexer hit a document it could not handle".to_string());
|
||||
publish(&app, &index, status);
|
||||
Err("a job panicked and was abandoned".to_string())
|
||||
}
|
||||
};
|
||||
if let Err(e) = outcome {
|
||||
eprintln!("search index: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One drained batch with the jobs that have been overtaken taken out of it.
|
||||
///
|
||||
/// Nothing is reordered, because the order is what makes the queue correct in the first place. A job
|
||||
/// is dropped only when a later job in the same drain speaks about the same path, and that later one
|
||||
/// is the one the disk now agrees with, so a create that followed a delete still wins and a delete
|
||||
/// that followed a create still wins. It is the rule `watch::merge` applies within one debounced
|
||||
/// batch, applied again across the batches that piled up while the worker was busy. A rebuild, a
|
||||
/// scan and a forget name no path at all and are always kept.
|
||||
fn coalesce(batch: Vec<Job>) -> Vec<Job> {
|
||||
let mut last: HashMap<PathBuf, usize> = HashMap::new();
|
||||
for (at, job) in batch.iter().enumerate() {
|
||||
if let Some(path) = job_path(job) {
|
||||
last.insert(path.to_path_buf(), at);
|
||||
}
|
||||
}
|
||||
batch
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(at, job)| job_path(job).is_none_or(|path| last.get(path) == Some(at)))
|
||||
.map(|(_, job)| job)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn job_path(job: &Job) -> Option<&Path> {
|
||||
match job {
|
||||
Job::Changed(path) | Job::Removed(path) => Some(path),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A rebuild is a scan of every open root plus the one thing a scan cannot do: drop the rows of a
|
||||
@@ -397,7 +481,16 @@ fn rebuild_pass(
|
||||
) -> Result<(), String> {
|
||||
let ids: Vec<String> = roots.iter().map(|root| root.id.clone()).collect();
|
||||
with_conn(index, |conn| forget_roots_except(conn, &ids))?;
|
||||
scan_pass(app, index, roots, pass)
|
||||
scan_pass(app, index, roots, pass)?;
|
||||
// FTS5 leaves a segment behind for every rewrite of a row, and a document is rewritten on every
|
||||
// save, so an index that is never merged is one a long session slowly makes worse at the one
|
||||
// thing it is for. A full pass is the moment it is fair to do the merging: the user has already
|
||||
// asked for a walk of every folder they have open, and this costs less than the walk did.
|
||||
with_conn(index, |conn| {
|
||||
conn.execute("INSERT INTO docs_fts(docs_fts) VALUES('optimize')", [])
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// One pass over a set of roots: walk them all first so the total is known before the first file is
|
||||
@@ -457,6 +550,15 @@ fn changed(app: &AppHandle, index: &Index, path: &Path, pass: i64) -> Result<(),
|
||||
// Gone again between the event and here, which a debounce window makes perfectly ordinary.
|
||||
return with_conn(index, |conn| remove_under(conn, path));
|
||||
};
|
||||
if is_skipped_below(&root.path, path) {
|
||||
// The walk hides these folders and so must the watcher, which otherwise reaches the indexer
|
||||
// with everything the walk refused to look at. An npm install under an open root is a row
|
||||
// and a body for every README in node_modules, thousands of them, and a sweep will not take
|
||||
// them back out because they were written by the pass that is sweeping. Checked after the
|
||||
// stat rather than before it so a deletion under one of these folders is still applied,
|
||||
// which is what takes away rows an earlier build of this file put there.
|
||||
return Ok(());
|
||||
}
|
||||
if !meta.is_dir() {
|
||||
if !is_document(path) {
|
||||
return Ok(());
|
||||
@@ -571,6 +673,21 @@ fn is_document(path: &Path) -> bool {
|
||||
matches!(crate::fs::kind_for(path, false), "markdown" | "text")
|
||||
}
|
||||
|
||||
/// Whether a path sits inside one of the folders the tree never shows.
|
||||
///
|
||||
/// Only the four unconditional names, and deliberately not the folder's gitignore: these are the
|
||||
/// ones the tree hides whatever a gitignore says, and building an ignore matcher for every event
|
||||
/// that arrives would cost more than the indexing it saves. A path that is the root itself strips to
|
||||
/// an empty relative path with no components at all, so the watcher's "the kernel dropped events,
|
||||
/// here is the root" report is not caught by this and still rescans everything.
|
||||
fn is_skipped_below(root: &str, path: &Path) -> bool {
|
||||
let Ok(rel) = path.strip_prefix(root) else {
|
||||
return false;
|
||||
};
|
||||
rel.components()
|
||||
.any(|part| crate::fs::ALWAYS_SKIPPED.contains(&part.as_os_str().to_string_lossy().as_ref()))
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -602,8 +719,14 @@ fn index_document(
|
||||
}
|
||||
|
||||
// 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();
|
||||
// finding in quick open, and refusing the whole row would make it invisible instead. A file
|
||||
// past `BODY_MAX` is given the same answer for the same reason: nothing an extension can tell
|
||||
// us says how big a .txt is, and the stat that decided the mtime above already knows.
|
||||
let body = if meta.len() > BODY_MAX {
|
||||
String::new()
|
||||
} else {
|
||||
fs::read_to_string(path).unwrap_or_default()
|
||||
};
|
||||
let title = title_for(path, &body);
|
||||
let name = path
|
||||
.file_name()
|
||||
@@ -1188,7 +1311,13 @@ fn snippet_of(line: &str) -> (String, Vec<MatchRange>) {
|
||||
if tail.saturating_sub(from) < SNIPPET_MAX {
|
||||
from = tail.saturating_sub(SNIPPET_MAX).max(lead);
|
||||
}
|
||||
let to = tail.min(from + SNIPPET_MAX);
|
||||
// `from` can end up past `tail` when the line has no text left on it at all. A document is free
|
||||
// to contain the control character the marks are made of, and every line carrying one is read
|
||||
// as a line with a match on it here whether there is anything else on it or not, so a line that
|
||||
// is one stray mark and some spaces reaches this. `tail` is at or after `from` in every ordinary
|
||||
// case, so this only ever changes that one: what comes out is an empty snippet with no ranges
|
||||
// rather than a slice that starts after it ends.
|
||||
let to = tail.max(from).min(from + SNIPPET_MAX);
|
||||
|
||||
let mut out = String::new();
|
||||
let mut shift = from;
|
||||
|
||||
@@ -230,6 +230,7 @@ pub fn run() {
|
||||
fs::root_open,
|
||||
fs::root_close,
|
||||
fs::tree_read,
|
||||
fs::sweep_documents,
|
||||
fs::reveal_in_finder,
|
||||
fs::open_external,
|
||||
fs::file_read,
|
||||
|
||||
+68
-6
@@ -9,7 +9,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use notify::event::{ModifyKind, RenameMode};
|
||||
@@ -192,7 +192,16 @@ where
|
||||
}
|
||||
let canonical = std::fs::canonicalize(&root).map_err(|e| e.to_string())?;
|
||||
|
||||
// Shared rather than owned by the handler, because the root's own disappearance is reported by
|
||||
// the watchdog below and not by the debouncer, and both have to emit into the same place. Behind
|
||||
// a lock because a sink is only `Send` and not `Sync`, which also has the two take turns rather
|
||||
// than interleave two batches in whatever the frontend is doing with them.
|
||||
let sink = Arc::new(Mutex::new(sink));
|
||||
let watchdog_sink = Arc::downgrade(&sink);
|
||||
|
||||
let watched = canonical.clone();
|
||||
let watchdog_id = root_id.clone();
|
||||
let watchdog_path = root.clone();
|
||||
let handler = move |result: DebounceEventResult| {
|
||||
let batch = match result {
|
||||
Ok(batch) => batch,
|
||||
@@ -205,7 +214,9 @@ where
|
||||
};
|
||||
let events = watch_events(&batch, &root_id, &watched, &root);
|
||||
if !events.is_empty() {
|
||||
sink(events);
|
||||
if let Ok(sink) = sink.lock() {
|
||||
(*sink)(events);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -228,6 +239,43 @@ where
|
||||
debouncer
|
||||
.watch(&canonical, RecursiveMode::Recursive)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// The root's own removal is the one change this watcher cannot wait for, so it is asked about
|
||||
// instead. An FSEvents stream is placed on a path and hears nothing that happens above that
|
||||
// path, and notify does not ask for the flag that would change that, so a parent folder renamed
|
||||
// or deleted takes the root with it in complete silence. Even the root's own deletion is a
|
||||
// favour rather than a promise: a folder emptied and removed can come back as one coalesced
|
||||
// event on the parent, which is not a path this stream matches, and then the whole batch is
|
||||
// dropped before anything here sees it. Waiting for an event that may never be sent is what left
|
||||
// a folder deleted out from under the app looking open, with a watcher still in the map holding
|
||||
// a stream on a path that no longer exists.
|
||||
//
|
||||
// One stat per debounce tick settles it on any filesystem, and the answer is terminal: nothing
|
||||
// further will ever arrive on a stream whose path is gone, so the thread reports the removal and
|
||||
// stops. `watch_start` hears that removal like any other and drops the watcher.
|
||||
//
|
||||
// The thread ends with the watch. The sink is the only thing it holds and it holds it weakly, so
|
||||
// once the debouncer is dropped and its own thread lets go of the handler there is nothing left
|
||||
// to report into and nothing to report about.
|
||||
std::thread::spawn(move || loop {
|
||||
std::thread::sleep(DEBOUNCE);
|
||||
let Some(sink) = watchdog_sink.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if !is_gone(&canonical) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(sink) = sink.lock() {
|
||||
(*sink)(vec![WatchEvent {
|
||||
root: watchdog_id.clone(),
|
||||
path: watchdog_path.to_string_lossy().into_owned(),
|
||||
kind: "removed".to_string(),
|
||||
old_path: None,
|
||||
}]);
|
||||
}
|
||||
return;
|
||||
});
|
||||
|
||||
Ok(debouncer)
|
||||
}
|
||||
|
||||
@@ -292,10 +340,12 @@ fn watch_events(
|
||||
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() {
|
||||
// The root itself going away is the one change nothing under it can describe. macOS usually 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.
|
||||
// This is the fast path only: it reports the removal in the same batch as the changes that came
|
||||
// with it, and the watchdog in `spawn_watcher` is what makes it certain to be reported at all.
|
||||
if is_gone(canonical_root) {
|
||||
merge(
|
||||
&mut events,
|
||||
&mut index,
|
||||
@@ -311,6 +361,18 @@ fn watch_events(
|
||||
events
|
||||
}
|
||||
|
||||
/// Whether the path is not there any more, as against unreadable for some other reason.
|
||||
///
|
||||
/// Only a missing file is an answer. A stat that fails because permissions changed or because a
|
||||
/// volume stopped answering says nothing about whether the folder still exists, and closing the
|
||||
/// user's open folder on the strength of it would be worse than reporting nothing at all.
|
||||
fn is_gone(path: &Path) -> bool {
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(_) => false,
|
||||
Err(error) => error.kind() == std::io::ErrorKind::NotFound,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge(events: &mut Vec<WatchEvent>, index: &mut HashMap<String, usize>, next: WatchEvent) {
|
||||
match index.get(&next.path) {
|
||||
// A later `modified` says nothing a create or a rename in the same batch has not already
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
// touched is `git status` being empty, plus a byte-level snapshot of every path under the root
|
||||
// including .git itself.
|
||||
//
|
||||
// The repository is built by the suite, under /private/tmp, on the first test that asks for it, and
|
||||
// there is nothing to set up by hand. `tests/support/notes_repo.rs` is where it comes from.
|
||||
//
|
||||
// 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
|
||||
@@ -25,28 +28,24 @@ use margin_docs_lib::fs::{
|
||||
};
|
||||
use margin_docs_lib::watch::spawn_watcher;
|
||||
|
||||
const REPO: &str = "/private/tmp/margin-notouch/notes-repo";
|
||||
#[path = "support/notes_repo.rs"]
|
||||
mod notes_repo;
|
||||
|
||||
// ---------------------------------------------------------------- fixture
|
||||
|
||||
/// The fixture repository, built on the first call and shared by every test after it.
|
||||
fn repo() -> PathBuf {
|
||||
let path = PathBuf::from(REPO);
|
||||
let path = notes_repo::path().to_path_buf();
|
||||
assert!(
|
||||
path.join(".git").is_dir(),
|
||||
"the fixture repo is missing: {REPO}"
|
||||
"the fixture repo is missing: {}",
|
||||
path.display()
|
||||
);
|
||||
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
|
||||
notes_repo::git(args)
|
||||
}
|
||||
|
||||
/// Back to the committed state, then one warm `git status` so the index's stat cache is already
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
// The folder the `no_write_on_open` suite runs against, built from nothing every time the test
|
||||
// binary starts.
|
||||
//
|
||||
// It has to be a real git repository and not a `TempDir` full of loose files, because `git status`
|
||||
// is the oracle the whole suite leans on: an editor that promises to write nothing the user did not
|
||||
// edit is believable exactly when a checkout of that folder comes back with no lines. A snapshot of
|
||||
// inodes and timestamps says a byte moved; `git status` says which document it belonged to and
|
||||
// whether the user would have seen it in their own diff.
|
||||
//
|
||||
// It used to be a repository somebody built by hand at a fixed path under /tmp, which meant the
|
||||
// suite ran on one machine until macOS reaped the folder and then ran nowhere. Everything below
|
||||
// exists so that the repository is an output of the test run rather than a precondition of it.
|
||||
//
|
||||
// The documents are copied out of `src/markdown/corpus/real`, the same real world markdown the
|
||||
// bridge tests parse, rather than invented here. Reusing them keeps one corpus in the repository
|
||||
// instead of two, and gives the fixture documents that are the length and shape of the ones people
|
||||
// actually keep in a notes folder. Nothing in the suite asserts on their bytes, only that they read
|
||||
// back as UTF-8 and that the tree has the right number of rows, so the corpus is free to grow.
|
||||
//
|
||||
// The vendored `node_modules` is generated rather than copied. Several tests need a folder large
|
||||
// enough that walking it is measurably slower than skipping it, and thirteen thousand tiny files
|
||||
// that exist to be ignored are not files worth committing.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::LazyLock;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Every fixture this suite builds is named `margin-notouch-<pid>-<nanos>`, so a later run can
|
||||
/// recognise the ones earlier runs left behind and work out which of them are finished with.
|
||||
const PREFIX: &str = "margin-notouch-";
|
||||
|
||||
/// 500 packages of 26 entries each is 13,000 paths under `node_modules`. Two tests put a floor
|
||||
/// under this: one wants more than 5,000 entries there, and one wants a walk of the whole root with
|
||||
/// the skip turned off to return more than 10,000 rows. Building it costs about a second.
|
||||
const PACKAGES: usize = 500;
|
||||
const MODULES_PER_PACKAGE: usize = 21;
|
||||
|
||||
/// Where each document in the fixture comes from in `src/markdown/corpus/real`. The paths on the
|
||||
/// left are named by the tests and cannot move without moving the tests too. There are twelve
|
||||
/// editable documents here, three more than the suite's floor of ten, and the two sidecars are the
|
||||
/// files the tests describe as the user's own: a save must not touch either.
|
||||
const DOCUMENTS: [(&str, &str); 14] = [
|
||||
("README.md", "margin-readme.md"),
|
||||
("notes.txt", "margin-claude.md"),
|
||||
("docs/index.md", "calendar-readme.md"),
|
||||
("docs/architecture.md", "editor-architecture.md"),
|
||||
("docs/conventions.md", "editor-conventions.md"),
|
||||
("docs/design.md", "editor-design.md"),
|
||||
("docs/guides/setup.md", "editor-setup.md"),
|
||||
("docs/guides/release.md", "editor-release.md"),
|
||||
("docs/guides/mobile.md", "calendar-mobile.md"),
|
||||
("docs/internals/website.md", "margin-website-readme.md"),
|
||||
("docs/internals/indexing.md", "calendar-architecture.md"),
|
||||
("docs/internals/watcher.md", "calendar-design.md"),
|
||||
// Not documents. A `.bak` and a `.tmp` the user owns, committed so that a save deleting one is
|
||||
// a line of `git status` and not merely an absence somebody has to notice.
|
||||
("docs/design.md.bak", "editor-design.md"),
|
||||
("docs/conventions.md.tmp", "editor-conventions.md"),
|
||||
];
|
||||
|
||||
/// Two files that are not text, so the tree has rows the editor cannot open.
|
||||
const ASSETS: [(&str, &str); 2] = [
|
||||
("assets/logo.png", "128x128.png"),
|
||||
("assets/[email protected]", "[email protected]"),
|
||||
];
|
||||
|
||||
/// Built once per test binary, on whichever test calls `pristine()` first, and then shared. The
|
||||
/// suite runs single threaded, but `LazyLock` is what makes that a property of the fixture rather
|
||||
/// than a rule someone has to remember.
|
||||
static REPO: LazyLock<PathBuf> = LazyLock::new(build);
|
||||
|
||||
/// The root of the fixture repository. Always canonical, so it starts `/private/tmp/` on macOS and
|
||||
/// the same folder is also reachable through the `/tmp` symlink, which one test needs.
|
||||
pub fn path() -> &'static Path {
|
||||
REPO.as_path()
|
||||
}
|
||||
|
||||
/// Runs git inside the fixture and hands back everything it said, output and errors together. The
|
||||
/// suite reads these as prose, so a failing command shows up in the assertion that used it rather
|
||||
/// than as an empty string that quietly looks clean.
|
||||
pub fn git(args: &[&str]) -> String {
|
||||
let out = run(path(), args);
|
||||
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
text.push_str(&String::from_utf8_lossy(&out.stderr));
|
||||
text
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- running git
|
||||
|
||||
/// Git, with the machine it happens to be running on held at arm's length.
|
||||
///
|
||||
/// The fixture is an oracle, so nothing outside it may change what it says. A developer with a
|
||||
/// `core.excludesFile` full of `*.tmp`, a commit template, a signing key, a `gc.auto` that fires
|
||||
/// mid run, or a stray `GIT_DIR` in the environment would each turn a green suite red or, worse, a
|
||||
/// red one green. The config files are pointed at /dev/null and the inherited git variables are
|
||||
/// dropped, so the only configuration in play is the handful of keys written into the repository
|
||||
/// itself by `configure` below.
|
||||
fn run(dir: &Path, args: &[&str]) -> Output {
|
||||
Command::new("git")
|
||||
.current_dir(dir)
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_OPTIONAL_LOCKS", "1")
|
||||
.env_remove("GIT_DIR")
|
||||
.env_remove("GIT_WORK_TREE")
|
||||
.env_remove("GIT_INDEX_FILE")
|
||||
.env_remove("GIT_COMMON_DIR")
|
||||
.env_remove("GIT_OBJECT_DIRECTORY")
|
||||
.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
|
||||
.env_remove("GIT_CEILING_DIRECTORIES")
|
||||
.env_remove("GIT_ATTR_NOSYSTEM")
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"cannot run `git {}`: {e}\n\
|
||||
This suite needs the git command line tool on PATH. Its whole method is to ask a \
|
||||
real repository whether anything moved, so there is no useful way to run it \
|
||||
without git and it fails here rather than passing on a folder nobody checked.",
|
||||
args.join(" ")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn must(dir: &Path, args: &[&str]) {
|
||||
let out = run(dir, args);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"building the fixture: `git {}` failed with {}\n{}{}",
|
||||
args.join(" "),
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- building
|
||||
|
||||
fn build() -> PathBuf {
|
||||
let tmp = fs::canonicalize("/tmp").unwrap_or_else(|_| std::env::temp_dir());
|
||||
sweep(&tmp);
|
||||
|
||||
// Per process and per instant, so two runs of the suite at once get two repositories and
|
||||
// neither has to wait for the other. `notes-repo` is a folder inside it rather than the
|
||||
// temporary folder itself, so the repository has a parent the tests never touch.
|
||||
let stamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let root = tmp
|
||||
.join(format!("{PREFIX}{}-{stamp}", std::process::id()))
|
||||
.join("notes-repo");
|
||||
fs::create_dir_all(&root)
|
||||
.unwrap_or_else(|e| panic!("cannot build the fixture at {}: {e}", root.display()));
|
||||
|
||||
populate(&root);
|
||||
commit(&root);
|
||||
root
|
||||
}
|
||||
|
||||
fn populate(root: &Path) {
|
||||
let corpus = corpus_dir();
|
||||
for (rel, source) in DOCUMENTS {
|
||||
let from = corpus.join(source);
|
||||
let bytes = fs::read(&from).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"the fixture is built out of the markdown corpus and {} is missing: {e}",
|
||||
from.display()
|
||||
)
|
||||
});
|
||||
put(&root.join(rel), &bytes);
|
||||
}
|
||||
|
||||
let icons = project_root().join("src-tauri/icons");
|
||||
for (rel, source) in ASSETS {
|
||||
let from = icons.join(source);
|
||||
let bytes = fs::read(&from)
|
||||
.unwrap_or_else(|e| panic!("the fixture wants {} for an asset: {e}", from.display()));
|
||||
put(&root.join(rel), &bytes);
|
||||
}
|
||||
|
||||
// The only thing the folder ignores. Deliberately not `*.tmp` or `*.bak`: the tests plant files
|
||||
// by those names on purpose and need git to report them.
|
||||
put(
|
||||
&root.join(".gitignore"),
|
||||
b"node_modules/\n.DS_Store\n" as &[u8],
|
||||
);
|
||||
|
||||
vendor(root);
|
||||
}
|
||||
|
||||
/// A vendored `node_modules`, big enough that walking it and skipping it are visibly different
|
||||
/// jobs. The contents are filler; only the count and the shape matter.
|
||||
fn vendor(root: &Path) {
|
||||
let node_modules = root.join("node_modules");
|
||||
for package in 0..PACKAGES {
|
||||
let dir = node_modules.join(format!("pkg-{package:03}"));
|
||||
let lib = dir.join("lib");
|
||||
fs::create_dir_all(&lib)
|
||||
.unwrap_or_else(|e| panic!("cannot build {}: {e}", lib.display()));
|
||||
put(
|
||||
&dir.join("package.json"),
|
||||
format!("{{\n \"name\": \"pkg-{package:03}\",\n \"version\": \"1.0.{package}\"\n}}\n")
|
||||
.as_bytes(),
|
||||
);
|
||||
put(
|
||||
&dir.join("index.js"),
|
||||
b"module.exports = require(\"./lib/mod-00.js\");\n" as &[u8],
|
||||
);
|
||||
put(
|
||||
&dir.join("README.md"),
|
||||
format!("# pkg-{package:03}\n\nVendored. Not a document, and not the user's writing.\n")
|
||||
.as_bytes(),
|
||||
);
|
||||
for module in 0..MODULES_PER_PACKAGE {
|
||||
put(
|
||||
&lib.join(format!("mod-{module:02}.js")),
|
||||
format!("exports.value = {package} * 100 + {module};\n").as_bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn commit(root: &Path) {
|
||||
must(root, &["init", "-q", "-b", "main"]);
|
||||
configure(root);
|
||||
must(root, &["add", "-A"]);
|
||||
must(
|
||||
root,
|
||||
&["commit", "-q", "-m", "the folder as the user left it"],
|
||||
);
|
||||
|
||||
let out = run(root, &["status", "--porcelain"]);
|
||||
let status = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
status.is_empty(),
|
||||
"the fixture did not commit clean, so `git status` cannot be trusted as the oracle:\n{status}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Written into the repository rather than passed as `-c` flags on every call, so that the settings
|
||||
/// travel with the fixture and a command run by hand inside it behaves the way the suite's commands
|
||||
/// do.
|
||||
fn configure(root: &Path) {
|
||||
for (key, value) in [
|
||||
("user.name", "Margin Fixture"),
|
||||
("user.email", "[email protected]"),
|
||||
// Signing would ask for a passphrase, and a passphrase in CI is a hang rather than a
|
||||
// failure.
|
||||
("commit.gpgsign", "false"),
|
||||
("tag.gpgsign", "false"),
|
||||
// A background repack landing between two snapshots would move bytes under .git and read
|
||||
// as the editor having written something.
|
||||
("gc.auto", "0"),
|
||||
("maintenance.auto", "false"),
|
||||
// The suite asserts on exactly which paths git reports, so the answer must not depend on
|
||||
// what the person running it happens to ignore everywhere.
|
||||
("core.excludesFile", "/dev/null"),
|
||||
("core.autocrlf", "false"),
|
||||
// Both write to .git while only reading the working tree, which is the one thing every
|
||||
// snapshot in this suite is watching for.
|
||||
("core.fsmonitor", "false"),
|
||||
("core.untrackedCache", "false"),
|
||||
("core.splitIndex", "false"),
|
||||
("status.showUntrackedFiles", "normal"),
|
||||
] {
|
||||
must(root, &["config", key, value]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- odds and ends
|
||||
|
||||
fn put(path: &Path, bytes: &[u8]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.unwrap_or_else(|e| panic!("cannot build {}: {e}", parent.display()));
|
||||
}
|
||||
fs::write(path, bytes).unwrap_or_else(|e| panic!("cannot write {}: {e}", path.display()));
|
||||
}
|
||||
|
||||
fn project_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.expect("src-tauri has a parent")
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
fn corpus_dir() -> PathBuf {
|
||||
project_root().join("src/markdown/corpus/real")
|
||||
}
|
||||
|
||||
/// The fixture outlives the run that built it, on purpose.
|
||||
///
|
||||
/// A `TempDir` parked in a `static` is a destructor that never runs, and a suite that quietly
|
||||
/// relies on that is worse than one that says so. Nothing here tries to delete the repository when
|
||||
/// the tests finish: the last thing a failing run should do is destroy the evidence, and running
|
||||
/// `git status` and `git diff` inside the folder is the first thing anyone will want. Thirteen
|
||||
/// thousand files that each take a block is about fifty megabytes, which is small enough to leave
|
||||
/// lying about once and much too big to leave lying about once per `cargo test`.
|
||||
///
|
||||
/// So the clearing up happens at the start of the next run instead, and it goes by whether the
|
||||
/// process that built a fixture is still running rather than by how old the folder looks. The pid
|
||||
/// is in the name for exactly this. Asking that question can only be wrong in the safe direction:
|
||||
/// a pid that has been recycled reads as alive and the folder is kept, and a folder is only ever
|
||||
/// removed once nothing is left that could be using it.
|
||||
fn sweep(tmp: &Path) {
|
||||
let Ok(entries) = fs::read_dir(tmp) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let Some(pid) = name.strip_prefix(PREFIX).and_then(|rest| rest.split('-').next()) else {
|
||||
continue;
|
||||
};
|
||||
if !alive(pid) {
|
||||
fs::remove_dir_all(entry.path()).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal zero asks whether a process exists without sending it anything. A `kill` that cannot be
|
||||
/// run at all counts as alive, which leaves the folder where it is rather than deleting a
|
||||
/// repository on a guess.
|
||||
fn alive(pid: &str) -> bool {
|
||||
if pid.is_empty() || !pid.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return true;
|
||||
}
|
||||
Command::new("/bin/kill")
|
||||
.args(["-0", pid])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
@@ -10,6 +10,17 @@ export const rootClose = (rootId: string) => call<void>("root_close", { rootId }
|
||||
/** The whole tree for one root, root node included. */
|
||||
export const treeRead = (rootId: string) => call<FileNode>("tree_read", { rootId });
|
||||
|
||||
/**
|
||||
* Every markdown document in one root, for the link rewrite sweep rather than for the tree.
|
||||
*
|
||||
* It walks past a .gitignore, which the tree does not, because a link inside an ignored draft is
|
||||
* still a link that breaks when the file it points at moves, and a sidebar's reasons for hiding a
|
||||
* file are not reasons to leave its links wrong. More than `limit` paths coming back means the root
|
||||
* holds more documents than the sweep is willing to read.
|
||||
*/
|
||||
export const sweepDocuments = (rootId: string, limit: number) =>
|
||||
call<string[]>("sweep_documents", { rootId, limit });
|
||||
|
||||
export const revealInFinder = (path: string) => call<void>("reveal_in_finder", { path });
|
||||
|
||||
/** Hands a file to whatever macOS opens it with. The only way to open a non-editable file. */
|
||||
|
||||
+107
-16
@@ -7,11 +7,21 @@
|
||||
// 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.
|
||||
// A pointer never moves focus into it. 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.
|
||||
//
|
||||
// A chord is the other case and it is the opposite one, which is the whole of what `fromKeyboard`
|
||||
// on the target decides. Cmd+; had no pointer behind it to leave a caret anywhere useful and no way
|
||||
// of reaching an item once the menu is up, so that opening moves focus to the first item, walks the
|
||||
// items with the arrow keys, holds Tab inside the menu, and puts focus back where it came from when
|
||||
// the menu closes. Enter and Space are not handled here at all: these are real buttons, so the
|
||||
// browser activates the focused one and src/keys/keymap.ts steps over both keys for exactly that
|
||||
// reason. WidthMenu.tsx makes the same split between the two ways of opening, and the walk itself is
|
||||
// RowMenu.tsx's.
|
||||
//
|
||||
// "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
|
||||
@@ -19,18 +29,43 @@
|
||||
// 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 { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { replaceSpelling } from "../editor/proofing";
|
||||
import { openSpellingMenu, replaceSpelling } from "../editor/proofing";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { onCommand } from "../keys/commands";
|
||||
import { useProofing, type ProofTarget } from "../store/useProofing";
|
||||
import { notify } from "../store/useToast";
|
||||
|
||||
/** Clearance from the word above and from the edges of the window. */
|
||||
const GAP = 6;
|
||||
const MARGIN = 8;
|
||||
|
||||
/** Everything the arrow keys walk, which is every item and not only the suggestions. */
|
||||
const ITEM = ".proof-suggestion, .proof-action";
|
||||
|
||||
/**
|
||||
* The chord's end of the feature, and the one line of explanation it owes when there is nothing
|
||||
* beside the caret to correct.
|
||||
*
|
||||
* Subscribed here rather than run from the command table for the reason that table's own comment
|
||||
* gives: this is the component that draws the menu, so it is the thing that has to be on screen for
|
||||
* the command to mean anything, and src/keys/commands.ts stays free of the editor.
|
||||
*/
|
||||
function correctAtCaret(): void {
|
||||
if (openSpellingMenu()) return;
|
||||
notify(
|
||||
useProofing.getState().enabled
|
||||
? "No misspelled word in this paragraph"
|
||||
: "Spell checking is off",
|
||||
);
|
||||
}
|
||||
|
||||
export function ProofPopover() {
|
||||
const target = useProofing((s) => s.target);
|
||||
|
||||
useEffect(() => onCommand("correct-spelling", correctAtCaret), []);
|
||||
|
||||
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.
|
||||
@@ -44,6 +79,10 @@ function ProofMenu({ target }: { target: ProofTarget }) {
|
||||
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const [at, setAt] = useState({ left: target.left, top: target.bottom + GAP });
|
||||
/** Where focus was when a chord opened the menu, and null when a pointer did, since that press
|
||||
* never moved it and there is nothing to give back. */
|
||||
const returnTo = useRef<HTMLElement | null>(null);
|
||||
const ids = useId();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = popRef.current;
|
||||
@@ -63,14 +102,35 @@ function ProofMenu({ target }: { target: ProofTarget }) {
|
||||
setAt({ left, top });
|
||||
}, [target]);
|
||||
|
||||
useEscapeLayer(true, closeMenu);
|
||||
// The measuring above has already run by the time this does, so the item is focused where it will
|
||||
// be drawn rather than at the corner the popover was first laid out in.
|
||||
useEffect(() => {
|
||||
if (!target.fromKeyboard) return;
|
||||
returnTo.current = document.activeElement as HTMLElement | null;
|
||||
popRef.current?.querySelector<HTMLElement>(ITEM)?.focus();
|
||||
}, [target.fromKeyboard]);
|
||||
|
||||
/** Closes, and hands focus back to whatever the chord took it from. */
|
||||
const dismiss = useCallback(() => {
|
||||
const el = returnTo.current;
|
||||
returnTo.current = null;
|
||||
closeMenu();
|
||||
// Not the body: focusing that is not giving anything back, it is losing the caret quietly.
|
||||
if (el && el !== document.body && el.isConnected) el.focus();
|
||||
}, [closeMenu]);
|
||||
|
||||
useEscapeLayer(true, dismiss);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (popRef.current?.contains(e.target as Node)) return;
|
||||
// Not `dismiss`: the press is putting focus somewhere of its own, and dragging it back to the
|
||||
// document afterwards would undo what the user just did with it.
|
||||
closeMenu();
|
||||
};
|
||||
const close = () => closeMenu();
|
||||
// Scrolling and resizing are the other way round. Neither is anybody moving focus, so if focus
|
||||
// is sitting in a menu that is about to stop existing, it goes back where it came from.
|
||||
const close = () => dismiss();
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("scroll", close, true);
|
||||
window.addEventListener("resize", close);
|
||||
@@ -79,7 +139,7 @@ function ProofMenu({ target }: { target: ProofTarget }) {
|
||||
document.removeEventListener("scroll", close, true);
|
||||
window.removeEventListener("resize", close);
|
||||
};
|
||||
}, [closeMenu]);
|
||||
}, [closeMenu, dismiss]);
|
||||
|
||||
// The caret belongs to the document, not to this menu, so a press on any of these buttons is not
|
||||
// allowed to move it.
|
||||
@@ -88,17 +148,46 @@ function ProofMenu({ target }: { target: ProofTarget }) {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
/**
|
||||
* The walk, and the trap.
|
||||
*
|
||||
* Only ever reached in the keyboard case, because a menu a pointer opened has no focus in it for a
|
||||
* key to be delivered to. Tab is taken along with the arrows rather than left to the browser: the
|
||||
* menu is a portal at the end of the body, so a Tab out of it lands on nothing the user can see,
|
||||
* with an open menu still on the page and a caret they can no longer get back to.
|
||||
*/
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Tab") return;
|
||||
e.preventDefault();
|
||||
const all = Array.from(popRef.current?.querySelectorAll<HTMLElement>(ITEM) ?? []);
|
||||
if (!all.length) return;
|
||||
const current = all.indexOf(document.activeElement as HTMLElement);
|
||||
const back = e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey);
|
||||
const next = back ? (current - 1 + all.length) % all.length : (current + 1) % all.length;
|
||||
all[next]?.focus();
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={popRef}
|
||||
className="proof-pop"
|
||||
role="menu"
|
||||
aria-label={`Spelling suggestions for ${target.word}`}
|
||||
aria-describedby={
|
||||
target.suggestions.length === 0 ? `${ids}-none ${ids}-note` : `${ids}-note`
|
||||
}
|
||||
style={{ left: at.left, top: at.top }}
|
||||
onKeyDown={onKeyDown}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* The two paragraphs and the rule are not items, so a menu announces the things that can be
|
||||
chosen and nothing else. Neither line is thrown away with them: both are named on the
|
||||
menu's own aria-describedby above, which is where a sentence about a menu belongs and is
|
||||
what gets them read once, on the way in, rather than as a row somebody has to arrow past. */}
|
||||
{target.suggestions.length === 0 ? (
|
||||
<p className="proof-none">No suggestions</p>
|
||||
<p className="proof-none" role="presentation" id={`${ids}-none`}>
|
||||
No suggestions
|
||||
</p>
|
||||
) : (
|
||||
target.suggestions.map((suggestion) => (
|
||||
<button
|
||||
@@ -108,7 +197,7 @@ function ProofMenu({ target }: { target: ProofTarget }) {
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => {
|
||||
replaceSpelling(target, suggestion);
|
||||
closeMenu();
|
||||
dismiss();
|
||||
}}
|
||||
>
|
||||
{suggestion}
|
||||
@@ -116,7 +205,7 @@ function ProofMenu({ target }: { target: ProofTarget }) {
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="proof-sep" />
|
||||
<div className="proof-sep" role="separator" />
|
||||
|
||||
<button
|
||||
role="menuitem"
|
||||
@@ -125,7 +214,7 @@ function ProofMenu({ target }: { target: ProofTarget }) {
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => {
|
||||
void learnWord(target.word);
|
||||
closeMenu();
|
||||
dismiss();
|
||||
}}
|
||||
>
|
||||
Learn Spelling
|
||||
@@ -137,13 +226,15 @@ function ProofMenu({ target }: { target: ProofTarget }) {
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => {
|
||||
ignoreWord(target.word);
|
||||
closeMenu();
|
||||
dismiss();
|
||||
}}
|
||||
>
|
||||
Ignore
|
||||
</button>
|
||||
|
||||
<p className="proof-note">Learning a word teaches this Mac, not only Margin Docs.</p>
|
||||
<p className="proof-note" role="presentation" id={`${ids}-note`}>
|
||||
Learning a word teaches this Mac, not only Margin Docs.
|
||||
</p>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -246,6 +246,21 @@ export async function mockCall<T>(command: string, args?: Record<string, unknown
|
||||
return nodeFor(entryAt(root.path)) as unknown as T;
|
||||
}
|
||||
|
||||
case "sweep_documents": {
|
||||
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}`);
|
||||
const limit = a.limit as unknown as number;
|
||||
// One path past the limit, the way `documents_for_sweep` does it, because that is how the
|
||||
// caller tells a folder that exactly fills its budget from one that goes over it. The
|
||||
// fixture has no ignore files in it, so what makes this different from `tree_read` on real
|
||||
// disk does not show here; what a browser and Playwright need is that the command answers at
|
||||
// all, since a sweep that throws reports every move as partial.
|
||||
return subtree(root.path)
|
||||
.filter((e) => !e.dir && kindOf(e) === "markdown")
|
||||
.map((e) => e.path)
|
||||
.slice(0, limit + 1) 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.
|
||||
|
||||
+255
-16
@@ -2,10 +2,14 @@
|
||||
// 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.
|
||||
// Opening writes nothing. There are exactly two calls to `fileWrite` in this file and they are
|
||||
// worth naming. The first is inside `performSave`, which 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. The second is
|
||||
// inside `rewriteOpenDocument` at the bottom, which src/linkRewrite.ts calls when a move has made
|
||||
// the open document's own links wrong. It is reached only for a buffer nobody has typed into, and
|
||||
// only with bytes the caller worked out from the file's own text rather than from the serializer,
|
||||
// so nothing on that path can put a house style rewrite on a document the user has not edited.
|
||||
//
|
||||
// `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
|
||||
@@ -15,6 +19,14 @@
|
||||
// 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.
|
||||
//
|
||||
// One edit is refused rather than written, and the serializer's own comments are where that is
|
||||
// argued out: a raw block beside a list is put back to the bytes the file gave it, because the
|
||||
// edited bytes are read back as part of the list. That decision is not this module's, but saying
|
||||
// so is. The writer marks it on the way past, this module puts it in a toast and holds the buffer
|
||||
// dirty until the edit is taken back or the block is moved, and `refusedOnDisk` below is where it
|
||||
// is kept. It is the one difference between the buffer and the file that comparing two trees
|
||||
// cannot find, since the tree those bytes were written from is the buffer itself.
|
||||
//
|
||||
// `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.
|
||||
@@ -31,6 +43,7 @@ import type { ReadResult, WriteResult } from "./ipc";
|
||||
import {
|
||||
parseMarkdown,
|
||||
parsePlainText,
|
||||
type Refused,
|
||||
serializeMarkdown,
|
||||
serializePlainText,
|
||||
} from "./markdown";
|
||||
@@ -41,6 +54,19 @@ 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;
|
||||
|
||||
/**
|
||||
* Said when the writer hands back bytes an edit is not in, which is the one edit this editor
|
||||
* knowingly does not save.
|
||||
*
|
||||
* It says which block and it says why, because the alternative the user is left with otherwise is a
|
||||
* change that is on screen, is not in the file, and has nothing anywhere to say so. The wording is
|
||||
* about the file rather than about the writer: what has happened to them is that the block on disk
|
||||
* is the one that was there before, and the reason is that a list is the one construct a blank line
|
||||
* does not close.
|
||||
*/
|
||||
const REFUSED_MESSAGE =
|
||||
"The edit to that block was not saved: beside a list those bytes read back as part of the list, so the file keeps the block it had.";
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
@@ -64,11 +90,25 @@ let unsubscribe: (() => void) | null = null;
|
||||
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 {
|
||||
/**
|
||||
* Whether the bytes in `diskText` are missing an edit the buffer still holds.
|
||||
*
|
||||
* `diskDoc` cannot answer this and it is not a bug in it. The tree the writer refused part of is
|
||||
* the same tree the bytes were written from, and serializing it again gives the same bytes back, so
|
||||
* the comparison below says the buffer and the file agree at the one moment the thing on screen is
|
||||
* not in the file at all. The writer says so on the way past and this is where that is kept, for
|
||||
* exactly as long as those bytes are the file's.
|
||||
*/
|
||||
let refusedOnDisk = false;
|
||||
|
||||
/** The only way any of those move, 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. The refusal defaults
|
||||
* to none, because every caller but the serializer's own is saying where the file is rather than
|
||||
* what went into it, and none of those can leave an edit behind. */
|
||||
function rememberDisk(text: string | null, doc: ProseMirrorNode | null, refused = false): void {
|
||||
diskText = text;
|
||||
diskDoc = doc;
|
||||
refusedOnDisk = refused;
|
||||
}
|
||||
|
||||
/** Markdown and plain text are two different round trips and picking the wrong one mangles a .txt. */
|
||||
@@ -192,8 +232,22 @@ function sameToTheSerializer(a: ProseMirrorNode, b: ProseMirrorNode): boolean {
|
||||
* 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.
|
||||
*
|
||||
* The comparison is not the whole answer, because there is one difference no comparison of trees
|
||||
* can find: a refused edit is in the buffer and is not in the file, and the tree the bytes were
|
||||
* written from is the buffer, so the two agree. That is what `refusedOnDisk` is holding and it is
|
||||
* why it comes first.
|
||||
*/
|
||||
export function differsFromDisk(next: ProseMirrorNode): boolean {
|
||||
return refusedOnDisk || wouldWrite(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same question with the refusal left out: whether another write would put different bytes on
|
||||
* the disk. A refused edit is a difference the user has to keep seeing and not one a second write
|
||||
* could fix, so it makes the buffer dirty and it does not arm the debounce.
|
||||
*/
|
||||
function wouldWrite(next: ProseMirrorNode): boolean {
|
||||
return diskDoc === null || !sameToTheSerializer(diskDoc, next);
|
||||
}
|
||||
|
||||
@@ -203,6 +257,12 @@ function bufferDiffersFromDisk(): boolean {
|
||||
return now !== null && differsFromDisk(now);
|
||||
}
|
||||
|
||||
/** And the same again for the debounce, which only ever wants to know about the bytes. */
|
||||
function bufferWouldWrite(): boolean {
|
||||
const now = useDocument.getState().content;
|
||||
return now !== null && wouldWrite(now);
|
||||
}
|
||||
|
||||
function apply(read: ReadResult, document: MarkdownDocument): void {
|
||||
rememberDisk(read.text, document.doc);
|
||||
useDocument.setState({
|
||||
@@ -231,6 +291,21 @@ export function cancelPendingSave(): void {
|
||||
saveTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one edit this editor knowingly does not save, said out loud.
|
||||
*
|
||||
* Once per occurrence rather than once per save. The flag stays up for as long as those bytes are
|
||||
* the file's, so five more laps of the debounce over the same unsavable block say nothing more, and
|
||||
* taking the edit back and making it again is a new occurrence and is worth saying again.
|
||||
*
|
||||
* Read before `rememberDisk`, never after: this is the only place that can tell the refusal that
|
||||
* has just happened from the one that was already standing, and the call that records the new one
|
||||
* is what wipes that difference out.
|
||||
*/
|
||||
function noteRefusal(refused: boolean): void {
|
||||
if (refused && !refusedOnDisk) notify(REFUSED_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -325,9 +400,10 @@ async function performSave(): Promise<"wrote" | "conflict" | "skipped"> {
|
||||
const { serialize } = bridgeFor(path);
|
||||
|
||||
useDocument.setState({ savePhase: "saving", saveError: null });
|
||||
const refused: Refused = { rawBlock: false };
|
||||
let text: string;
|
||||
try {
|
||||
text = serialize(document, content);
|
||||
text = serialize(document, content, refused);
|
||||
} catch (e) {
|
||||
useDocument.setState({ savePhase: "error", saveError: String(e) });
|
||||
throw e;
|
||||
@@ -344,7 +420,11 @@ async function performSave(): Promise<"wrote" | "conflict" | "skipped"> {
|
||||
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);
|
||||
//
|
||||
// This is the branch the refused edit arrives on, and it arrives on it precisely because the
|
||||
// writer put the file's own bytes back, so saying nothing here is saying nothing at all.
|
||||
noteRefusal(refused.rawBlock);
|
||||
rememberDisk(text, content, refused.rawBlock);
|
||||
useDocument.setState({
|
||||
dirty: bufferDiffersFromDisk(),
|
||||
savePhase: "idle",
|
||||
@@ -378,19 +458,23 @@ async function performSave(): Promise<"wrote" | "conflict" | "skipped"> {
|
||||
return "conflict";
|
||||
}
|
||||
|
||||
rememberDisk(text, content);
|
||||
const stillDirty = bufferDiffersFromDisk();
|
||||
noteRefusal(refused.rawBlock);
|
||||
rememberDisk(text, content, refused.rawBlock);
|
||||
const unwritten = bufferWouldWrite();
|
||||
useDocument.setState({
|
||||
modifiedMs: result.modifiedMs,
|
||||
dirty: stillDirty,
|
||||
dirty: bufferDiffersFromDisk(),
|
||||
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();
|
||||
// armed the debounce for it, and this write is what has just made it a difference again. A
|
||||
// refusal is not one of these. Those bytes are already everything the file can hold, so the
|
||||
// buffer stays dirty and the debounce stays down, which is the difference between the two
|
||||
// questions asked above: the flag is about what the user can see, the timer is about the bytes.
|
||||
if (unwritten) scheduleSave();
|
||||
else cancelPendingSave();
|
||||
return "wrote";
|
||||
}
|
||||
@@ -442,6 +526,34 @@ export function abandonDocument(): void {
|
||||
useDocument.getState().close();
|
||||
}
|
||||
|
||||
/**
|
||||
* This app has just renamed or moved the open document's own file, and its buffer holds an edit
|
||||
* nothing else has.
|
||||
*
|
||||
* The buffer follows the file instead of being thrown away and read back from it, because that
|
||||
* edit is the only copy of itself and a reopen is a read. Nothing else here moves. The bytes and
|
||||
* the document this module last saw are still the ones the file held when it was moved, the
|
||||
* timestamp is still that file's, and whatever armed the debounce is still armed. So the save that
|
||||
* follows goes to the new path carrying the old file's timestamp: it lands when nothing touched
|
||||
* the file on the way, and it comes back a conflict when the link rewrite did, which is a question
|
||||
* put to the user rather than either copy being lost.
|
||||
*
|
||||
* What this replaces is worse than it sounds. Left pointed at the path the file has just moved off,
|
||||
* that same save writes a path that no longer exists, and `write_document` falls through its
|
||||
* timestamp check when the file it is checking has gone, because a document deleted under the user
|
||||
* has to have somewhere to land. So the file came back at the old path, and the user was left with
|
||||
* two copies of one document and a sidebar showing both.
|
||||
*/
|
||||
export function documentMovedTo(path: string): void {
|
||||
if (useDocument.getState().path === null) return;
|
||||
// The history entry goes with it. Where the user is standing in the back and forward list is the
|
||||
// document they are looking at, and this app is the thing that made the old spelling of it dead.
|
||||
useDocument.setState((s) => ({
|
||||
path,
|
||||
history: s.history.map((entry, at) => (at === s.historyIndex ? path : entry)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -458,8 +570,11 @@ export async function documentChangedOnDisk(path: string): Promise<void> {
|
||||
// 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);
|
||||
// side effect of clicking into the editor afterwards. The refusal is carried over for the same
|
||||
// reason the document is: a save that left an edit behind still left it behind, and forgetting
|
||||
// that here would let the next keystroke work the buffer out as clean over bytes that never
|
||||
// held it.
|
||||
rememberDisk(null, diskDoc, refusedOnDisk);
|
||||
useDocument.setState({ externalChange: "changed-on-disk" });
|
||||
return;
|
||||
}
|
||||
@@ -485,3 +600,127 @@ export async function documentChangedOnDisk(path: string): Promise<void> {
|
||||
const { parse } = bridgeFor(path);
|
||||
apply(read, parse(read.text, read.path));
|
||||
}
|
||||
|
||||
/**
|
||||
* What one pass of the link rewrite did to the open document.
|
||||
*
|
||||
* `held` is not a failure and not a no-op. It is this module saying the file was not the sweep's to
|
||||
* write at that moment, which the caller has to report rather than count as a file with nothing in
|
||||
* it to change.
|
||||
*/
|
||||
export type OpenRewrite =
|
||||
| { kind: "held" }
|
||||
| { kind: "unchanged" }
|
||||
| { kind: "rewritten" }
|
||||
| { kind: "failed"; reason: string };
|
||||
|
||||
/**
|
||||
* The open document's own file, rewritten by src/linkRewrite.ts after a move made the links in it
|
||||
* wrong, without going through the serializer and without going around the single writer above.
|
||||
*
|
||||
* `rewrite` is handed the bytes this module last saw and returns the bytes to put back, or null
|
||||
* when there was nothing in them to change. It is pure and it is synchronous, and that is the whole
|
||||
* design rather than a convenience. Every question about the buffer, the answer, and the write go
|
||||
* out in one run of the event loop, so there is no window between reading `dirty` and writing for a
|
||||
* keystroke to arrive in. A callback that could await would put the window straight back.
|
||||
*
|
||||
* The buffer takes the rewrite before the bytes leave rather than after they land. A keystroke
|
||||
* arriving while the write is on the wire then lands on top of the new links, and the save it arms
|
||||
* writes the user's own edit over a file that already agrees with what they are looking at. Doing
|
||||
* it the other way round is what put a conflict banner in front of somebody for a change this app
|
||||
* made itself: the write went out carrying the timestamp the buffer had from before they typed, and
|
||||
* the file they were looking at came back looking like somebody else's copy.
|
||||
*
|
||||
* It costs nothing that the success path was not already paying, because that path swaps the
|
||||
* editor's tree either way.
|
||||
*/
|
||||
export async function rewriteOpenDocument(
|
||||
path: string,
|
||||
rewrite: (text: string) => string | null,
|
||||
): Promise<OpenRewrite> {
|
||||
const state = useDocument.getState();
|
||||
// A buffer with an edit in it is the only copy of that edit, and a write already on the wire owns
|
||||
// the file until it lands. Neither is this function's to write over.
|
||||
if (state.path !== path || state.document === null || state.dirty) return { kind: "held" };
|
||||
if (writeInFlight !== null || diskText === null || state.modifiedMs === null) {
|
||||
return { kind: "held" };
|
||||
}
|
||||
|
||||
const was = { document: state.document, text: diskText, modifiedMs: state.modifiedMs };
|
||||
let prepared: { text: string; document: MarkdownDocument };
|
||||
try {
|
||||
const next = rewrite(was.text);
|
||||
if (next === null) return { kind: "unchanged" };
|
||||
prepared = { text: next, document: bridgeFor(path).parse(next, path) };
|
||||
} catch (e) {
|
||||
return { kind: "failed", reason: String(e) };
|
||||
}
|
||||
const { text, document } = prepared;
|
||||
|
||||
apply({ path, text, modifiedMs: was.modifiedMs }, document);
|
||||
|
||||
const run = async (): Promise<OpenRewrite> => {
|
||||
let result: WriteResult;
|
||||
try {
|
||||
result = await fileWrite(path, text, was.modifiedMs);
|
||||
} catch (e) {
|
||||
revert(path, was);
|
||||
return { kind: "failed", reason: String(e) };
|
||||
}
|
||||
// Switched away from while the write was in flight. The bytes did land, so this is not a
|
||||
// failure, and the buffer it would have been reconciled against is not on screen any more.
|
||||
if (useDocument.getState().path !== path) return { kind: "rewritten" };
|
||||
if (result.conflict) {
|
||||
revert(path, was);
|
||||
return { kind: "failed", reason: "it changed on disk part way through" };
|
||||
}
|
||||
useDocument.setState({ modifiedMs: result.modifiedMs });
|
||||
// Typed into while that write was on the wire, on top of the new links. The same belt and
|
||||
// braces `performSave` ends with, since an undo can pass the subscription while it is clean.
|
||||
if (useDocument.getState().dirty) scheduleSave();
|
||||
return { kind: "rewritten" };
|
||||
};
|
||||
|
||||
const inFlight = run();
|
||||
writeInFlight = inFlight.then(() => undefined);
|
||||
const held = writeInFlight;
|
||||
try {
|
||||
return await inFlight;
|
||||
} finally {
|
||||
if (writeInFlight === held) writeInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the buffer back to the document the file still holds, for a rewrite that was installed and
|
||||
* then did not land.
|
||||
*
|
||||
* The same object goes back into the store rather than a fresh parse of the same bytes, which is
|
||||
* what lets src/editor/Editor.tsx see the document it already had instead of a different file: it
|
||||
* stashes the caret by path on the way past and puts it back, so the user is left where they were
|
||||
* typing rather than at the top of a document that flickered.
|
||||
*/
|
||||
function revert(
|
||||
path: string,
|
||||
was: { document: MarkdownDocument; text: string; modifiedMs: number },
|
||||
): void {
|
||||
const now = useDocument.getState();
|
||||
if (now.path !== path) return;
|
||||
if (now.dirty) {
|
||||
// Typed into while the write was on the wire, so the buffer holds an edit on top of the new
|
||||
// links and nothing here gets to throw it away. What is on disk is somebody else's copy this
|
||||
// module has not read, which is exactly what `performSave` says in the same situation. No
|
||||
// refusal is carried over: a null document already makes every question about the buffer come
|
||||
// back dirty, which is the whole of what the flag was holding up.
|
||||
rememberDisk(null, null);
|
||||
useDocument.setState({ externalChange: "changed-on-disk" });
|
||||
return;
|
||||
}
|
||||
// Whatever the watcher found while the write was out lives through this. `apply` says "synced"
|
||||
// because every other caller of it has just read the file, and this one has not: it is putting
|
||||
// back the document the file had before, which is the claim this module was already making when
|
||||
// the rewrite started, and it is not an answer to a change somebody else made in the meantime.
|
||||
const flagged = now.externalChange;
|
||||
apply({ path, text: was.text, modifiedMs: was.modifiedMs }, was.document);
|
||||
if (flagged !== "synced") useDocument.setState({ externalChange: flagged });
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import type { Command, Transaction } from "@tiptap/pm/state";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
CellSelection,
|
||||
TableMap,
|
||||
addColumnAfter,
|
||||
addColumnBefore,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
tableEditing,
|
||||
} from "@tiptap/pm/tables";
|
||||
import type { TableRect } from "@tiptap/pm/tables";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import type { ColumnAlign } from "../../model/doc";
|
||||
import { overCells } from "../fits";
|
||||
import type { TableOp } from "../index";
|
||||
@@ -301,9 +303,71 @@ const typing = new Plugin({
|
||||
key: typingKey,
|
||||
props: {
|
||||
handleTextInput: (view) => overCells(view.state),
|
||||
handleDOMEvents: {
|
||||
compositionstart: collapseRectangle,
|
||||
// The same DOM level insert arriving without a composition behind it. macOS's Replace menu
|
||||
// and its autocorrect send `insertReplacementText`, dictation sends `insertText`, and neither
|
||||
// of them goes anywhere near `handleTextInput`. A printable character over a rectangle never
|
||||
// reaches here, because prosemirror-view's keypress handler calls preventDefault for any
|
||||
// selection that is not one text range inside one textblock, and nor does Backspace, which
|
||||
// the keymap at the end of this file claims first.
|
||||
beforeinput: (view, event) =>
|
||||
event.inputType.startsWith("insert") ? collapseRectangle(view) : false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The same rule for a composition, which is the one gesture a handler cannot refuse.
|
||||
*
|
||||
* `handleTextInput` above is offered a character the browser was about to insert. An IME never
|
||||
* produces one: the browser fires compositionstart, writes the composition into the DOM itself, and
|
||||
* prosemirror-view reads the result back out afterwards. So the guard above is not bypassed by a
|
||||
* mistake in it, it is simply not on the route, and neither is anything else that answers true or
|
||||
* false.
|
||||
*
|
||||
* Claiming the event does not help and it makes things worse. compositionstart is not cancelable,
|
||||
* so preventDefault does nothing and the IME composes whatever a handler returns. What returning
|
||||
* true DOES do is stop prosemirror-view's own compositionstart from running, which leaves
|
||||
* `view.composing` false while a real composition is in progress, and every DOM write the IME makes
|
||||
* then reads back as an ordinary edit.
|
||||
*
|
||||
* What is decidable is where the composition lands, and that is decided before it starts. Left
|
||||
* alone, prosemirror-view's compositionstart replaces the rectangle with whatever
|
||||
* `selectionFromDOM` makes of a DOM range spanning cells, which is a text selection, because
|
||||
* prosemirror-tables only hands back a cell selection while a mouse drag is still down.
|
||||
* prosemirror-tables then either collapses that on to the whole content of one cell, and the
|
||||
* composition replaces it, or leaves it spanning cell boundaries, and prosemirror-view replaces
|
||||
* across them and joins the cells and the rows away. Both are the rectangle destroyed by a key
|
||||
* pressed to type one word, and the second one never reaches `handleTextInput` at all: a change
|
||||
* that crosses a textblock is dispatched without the offer ever being made.
|
||||
*
|
||||
* So the rectangle is collapsed first, to a caret at the end of the cell the drag started in, and
|
||||
* the composition lands there. Nothing is replaced and no boundary is crossed, which is the promise
|
||||
* the guard above makes for an ordinary character: the rows and the columns survive it. False is
|
||||
* returned rather than true, so prosemirror-view's own compositionstart still runs, against the
|
||||
* caret this left it. The dispatch is synchronous for the same reason: the DOM selection has to be
|
||||
* the caret before the browser applies the first composed character, and a microtask is already
|
||||
* too late.
|
||||
*
|
||||
* `instanceof` rather than `overCells`, which asks this exact question in src/editor/fits.ts and is
|
||||
* what the guard above uses. What is needed here is not the boolean but the narrowing that comes
|
||||
* with it, since `$anchorCell` only exists on the cell selection.
|
||||
*/
|
||||
function collapseRectangle(view: EditorView): false {
|
||||
const { selection } = view.state;
|
||||
if (!(selection instanceof CellSelection)) return false;
|
||||
const cell = selection.$anchorCell.nodeAfter;
|
||||
if (!cell) return false;
|
||||
// The end of the anchor cell's content, so the composition appends rather than replacing. A cell
|
||||
// holds inline content in src/model/schema.ts and no blocks at all, which is what makes that
|
||||
// position a caret already: `near` takes it as it stands rather than searching forward out of the
|
||||
// cell for one, which is where it would have to go if a paragraph closed in front of it.
|
||||
const at = selection.$anchorCell.pos + 1 + cell.content.size;
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(at))));
|
||||
return false;
|
||||
}
|
||||
|
||||
export const Tables = Extension.create({
|
||||
name: "tables",
|
||||
|
||||
|
||||
+50
-16
@@ -68,6 +68,12 @@ const OPAQUE = ["callout", "toggle"] as const;
|
||||
/** A line with nothing but spaces on it, which is what ends an html block in markdown. */
|
||||
const BLANK_LINE = /\n[ \t]*\n/;
|
||||
|
||||
/**
|
||||
* Every line ending in a piece of text, which since the heading became `whitespace: "pre"` is the
|
||||
* other way a second line gets into a block that has nowhere to put one on its own.
|
||||
*/
|
||||
const LINE_ENDING = /\r\n?|\n/g;
|
||||
|
||||
/**
|
||||
* The rectangle of whole cells a drag across a table makes, named once so that the four questions
|
||||
* below and src/editor/blocks/tables.ts ask it in the same words.
|
||||
@@ -172,7 +178,7 @@ export function place(
|
||||
export type Change = "convert" | "wrap" | "unwrap";
|
||||
|
||||
/** Whether this position is inside the one block whose bytes are the file's own. */
|
||||
function inRaw($pos: ResolvedPos): boolean {
|
||||
export function inRaw($pos: ResolvedPos): boolean {
|
||||
for (let depth = $pos.depth; depth > 0; depth -= 1) {
|
||||
if ($pos.node(depth).type.name === RAW) return true;
|
||||
}
|
||||
@@ -180,7 +186,7 @@ function inRaw($pos: ResolvedPos): boolean {
|
||||
}
|
||||
|
||||
/** Whether the selection is inside, or reaches across, a raw block. */
|
||||
function touchesRaw(state: EditorState): boolean {
|
||||
export function touchesRaw(state: EditorState): boolean {
|
||||
return state.selection.ranges.some(({ $from, $to }) => {
|
||||
if (inRaw($from) || inRaw($to)) return true;
|
||||
let found = false;
|
||||
@@ -225,12 +231,17 @@ export function changeable(state: EditorState): boolean {
|
||||
*
|
||||
* A GFM cell is one line, and src/markdown/serialize.ts flattens a break inside one into the space
|
||||
* a cell can hold. A heading is one line too, except at the two levels that have a setext
|
||||
* spelling: `#### a` has nowhere to put the second line, so mdast writes the break out as a space,
|
||||
* spelling: `#### a` has nowhere to put a second line, so mdast writes the break out as a space,
|
||||
* while an underlined heading keeps it.
|
||||
*
|
||||
* Either way nothing is lost that the user typed, and either way the editor is drawing a line the
|
||||
* next open of the file will not have. An editor showing a construct the file silently swallows is
|
||||
* an editor lying about what was saved.
|
||||
*
|
||||
* `strandedBreaks` asks this of a newline in the text as well, which since the heading became
|
||||
* `whitespace: "pre"` is the other way a second line arrives. That one the writer can spell, as
|
||||
* `
`, and it comes back, so the no there is not about losing it: a heading below the two
|
||||
* underlined levels is one line of the file, and this editor does not put a second one in it.
|
||||
*/
|
||||
function holdsBreak(parent: ProseMirrorNode): boolean {
|
||||
const role = parent.type.spec.tableRole;
|
||||
@@ -259,7 +270,8 @@ function holdsTrailingBreak(parent: ProseMirrorNode): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* The breaks in this document that the next save will swallow or spell wrong.
|
||||
* The breaks in this document that the next save will swallow or spell wrong, and the newlines in a
|
||||
* block that is not allowed a second line at all.
|
||||
*
|
||||
* A break with nothing after it in its block is usually not one of them: that is the half typed
|
||||
* line somebody is in the middle of, it has no spelling either, and the next character they type
|
||||
@@ -273,6 +285,14 @@ function strandedBreaks(doc: ProseMirrorNode): number {
|
||||
const trailing = holdsTrailingBreak(parent);
|
||||
if (held && trailing) return;
|
||||
parent.forEach((child, _offset, index) => {
|
||||
// A newline in the text is the same second line arriving by the other door. It used to be
|
||||
// impossible to build one here, because a conversion into a heading rewrote every newline as
|
||||
// a break on the way in; now that a heading is `whitespace: "pre"` the newline survives, and
|
||||
// a block that cannot hold a break does not get to hold this either.
|
||||
if (child.isText) {
|
||||
if (!held) total += (child.text?.match(LINE_ENDING) ?? []).length;
|
||||
return;
|
||||
}
|
||||
if (child.type.name !== "hardBreak") return;
|
||||
const last = index === parent.childCount - 1;
|
||||
if (last ? !trailing : !held) total += 1;
|
||||
@@ -329,11 +349,12 @@ export function change(
|
||||
if (kind !== "unwrap" && OPAQUE.some((name) => countOf(tr.doc, name) < countOf(before, name))) {
|
||||
return false;
|
||||
}
|
||||
// And the block the content lands in has to be able to write down what is in it. A fence of two
|
||||
// lines turned into a heading is two lines in a heading, which only the two levels with an
|
||||
// underlined spelling can hold: the deeper four write the break out as a space, so the second
|
||||
// line would be on screen and gone from the file, which is `breakable` refusing Shift+Enter in
|
||||
// the same block, arrived at from the other direction.
|
||||
// And the block the content lands in has to be able to hold what is in it. A fence of two lines
|
||||
// turned into a heading is two lines in a heading, which only the two levels with an underlined
|
||||
// spelling have: the deeper four are one line of the file, whether the second line arrives as a
|
||||
// break, which they write out as a space and lose, or as a newline in the text, which they can
|
||||
// spell but which this editor still does not put there. That is `breakable` refusing Shift+Enter
|
||||
// in the same block, arrived at from the other direction.
|
||||
if (strandedBreaks(tr.doc) > strandedBreaks(before)) return false;
|
||||
|
||||
editor.view.dispatch(tr);
|
||||
@@ -369,15 +390,28 @@ export function markable(state: EditorState, type: MarkType): boolean {
|
||||
* `<Chart data={points} title="Sales" />` put those bytes through the middle of the tag, and the
|
||||
* file reopened as a heading, two paragraphs and a list with no raw block anywhere in it.
|
||||
*
|
||||
* Text with no blank line in it is fine everywhere, which is what the first line says: a fence and
|
||||
* a raw block are `whitespace: "pre"` so an ordinary newline stays a newline, and the serializer
|
||||
* writes a newline inside a table cell as the space a GFM cell can hold.
|
||||
* Text with no blank line in it is fine everywhere else, and everywhere else is what the first line
|
||||
* answers: a fence and a raw block are `whitespace: "pre"` so an ordinary newline stays a newline,
|
||||
* and the serializer writes a newline inside a table cell as the space a GFM cell can hold.
|
||||
*
|
||||
* Inside a raw block the question is asked of the RESULT, because the blank line does not have to
|
||||
* be in the paste. Text copied as whole lines carries the line ending they were copied with, and
|
||||
* one of those pasted at the end of a line puts its newline against the newline already there.
|
||||
* Neither string has a blank line in it and the block has one afterwards, which is the same lost
|
||||
* construct arriving by arithmetic instead of by content: `<div class="x">` with "hello\n" pasted
|
||||
* at the end of that first line reopened as a raw block, a paragraph and a second raw block, with
|
||||
* no toast and nothing refused.
|
||||
*/
|
||||
export function holdsText(state: EditorState, text: string): boolean {
|
||||
if (!BLANK_LINE.test(text.replace(/\r\n?/g, "\n"))) return true;
|
||||
return state.selection.ranges.every(
|
||||
({ $from, $to }) => $from.parent.type.name !== RAW && $to.parent.type.name !== RAW,
|
||||
);
|
||||
const value = text.replace(/\r\n?/g, "\n");
|
||||
return state.selection.ranges.every(({ $from, $to }) => {
|
||||
if ($from.parent.type.name !== RAW && $to.parent.type.name !== RAW) return true;
|
||||
if (!$from.sameParent($to)) return !BLANK_LINE.test(value);
|
||||
const parent = $from.parent;
|
||||
const head = parent.textBetween(0, $from.parentOffset);
|
||||
const tail = parent.textBetween($to.parentOffset, parent.content.size);
|
||||
return !BLANK_LINE.test(head + value + tail);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+34
-4
@@ -38,6 +38,13 @@
|
||||
// line is what ends an html block, so a paste of two paragraphs into one wrote them through the
|
||||
// middle of a preserved `<Chart ... />` and the file came back as a heading, two paragraphs and a
|
||||
// list with no raw block in it. `holdsText` in src/editor/fits.ts is that question.
|
||||
//
|
||||
// And the sixth is that question asked of the wrong string. It read the clipboard and not the block
|
||||
// the clipboard was going into, so a paste of whole lines, which carries the line ending they were
|
||||
// copied with, put its newline against the newline already at the end of the line it landed on: no
|
||||
// blank line in the payload, a blank line in the raw block, nothing refused and no toast. A drop
|
||||
// was worse and asked nothing at all, and it is the one route that cannot be talked round, since a
|
||||
// drop is the dragged content itself rather than something reparsed against where it lands.
|
||||
|
||||
import { Extension } from "@tiptap/core";
|
||||
import type { Editor, JSONContent } from "@tiptap/core";
|
||||
@@ -47,7 +54,7 @@ import type { EditorState } from "@tiptap/pm/state";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import { __pastedCells as pastedCells, isInTable } from "@tiptap/pm/tables";
|
||||
import { assetWrite } from "../api/files";
|
||||
import { carriesBlocks, fits, holdsText, place, placeable } from "./fits";
|
||||
import { carriesBlocks, fits, holdsText, inRaw, place, placeable, touchesRaw } from "./fits";
|
||||
|
||||
/**
|
||||
* This app's own paste handler, named so that a test can find it in the plugin list and say where
|
||||
@@ -270,7 +277,18 @@ export function createPaste(context: PasteContext): Extension {
|
||||
// a raw block arrives as one text node with the newlines still in it and no block
|
||||
// boundary anywhere: `carriesBlocks` says no, the insert is ProseMirror's, and the
|
||||
// bytes it writes end the html block halfway through the user's tag.
|
||||
if (!carriesBlocks(slice) && takesText(view.state)) {
|
||||
//
|
||||
// A raw block never stands aside, whatever the slice looks like. "Better" above
|
||||
// means marks, lists and tables kept, and a raw block holds text and nothing else,
|
||||
// so what is better there is a hard break put into a node whose content is `text*`
|
||||
// and a mark applied where `marks` is "", neither of which ProseMirror refuses: an
|
||||
// inline slice carrying one break turned somebody's `<div>` into two raw blocks with
|
||||
// a stray backslash written into the file. What this branch measures is
|
||||
// `sliceText`, which is only what ProseMirror inserts when the slice is a bare text
|
||||
// node, so standing aside on the strength of it is answering for a different insert
|
||||
// from the one that happens. The text path below is the only one that leaves the
|
||||
// block whole, and a raw target falls through to it.
|
||||
if (!carriesBlocks(slice) && takesText(view.state) && !touchesRaw(view.state)) {
|
||||
if (holdsText(view.state, sliceText(slice))) return false;
|
||||
event.preventDefault();
|
||||
context.onError(BLANK_LINE);
|
||||
@@ -300,10 +318,22 @@ export function createPaste(context: PasteContext): Extension {
|
||||
* meant by pointing at the middle of a fence.
|
||||
*/
|
||||
handleDrop(view: EditorView, event: DragEvent, slice: Slice) {
|
||||
if (!carriesBlocks(slice)) return false;
|
||||
const at = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||
if (!at) return false;
|
||||
if (fits(view.state.doc.resolve(at.pos), view.state.schema.nodes.paragraph)) return false;
|
||||
const $at = view.state.doc.resolve(at.pos);
|
||||
// A drop into a raw block is refused whatever it carries, and refused before the
|
||||
// slice is looked at. A paste is reparsed against the block the caret is in, so one
|
||||
// that lands in a raw block arrives as text; a drop is not, it is the dragged
|
||||
// content itself, so an inline one carries hard breaks and marks into a node whose
|
||||
// content is text and nothing else, and a plain text one carries whatever blank
|
||||
// lines were in it. The block is the file's own bytes and a drop is a pointer
|
||||
// gesture with nothing to ask, so nothing goes in.
|
||||
if (inRaw($at)) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (!carriesBlocks(slice)) return false;
|
||||
if (fits($at, view.state.schema.nodes.paragraph)) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
|
||||
+61
-11
@@ -296,17 +296,16 @@ async function pass(view: EditorView): Promise<void> {
|
||||
prune(blocks);
|
||||
}
|
||||
|
||||
/** The misspelling under a position, if the menu should open over one. */
|
||||
function menuAt(view: EditorView, pos: number): boolean {
|
||||
// A document held open while a conflict is resolved is one nothing may edit, and a menu whose
|
||||
// every item is an edit has nothing to offer there. The underlines stay; the menu does not open.
|
||||
if (!view.editable) return false;
|
||||
const decorations = proofingKey.getState(view.state);
|
||||
if (!decorations) return false;
|
||||
const found = decorations.find(pos, pos);
|
||||
if (found.length === 0) return false;
|
||||
// A position at the seam between two words touches both, so a hit that actually contains it wins.
|
||||
const hit = found.find((deco) => deco.from < pos && pos < deco.to) ?? found[0];
|
||||
/**
|
||||
* Puts the menu over one underlined word, whichever of the three ways in found it.
|
||||
*
|
||||
* `fromKeyboard` is the only thing that separates a chord from a pointer once the word is known,
|
||||
* and the menu reads it to decide whether to take focus. A click has already put the caret where
|
||||
* the user wanted it, so a menu that took focus from that click would have broken the commoner half
|
||||
* of what clicking a word means; a chord has no other way of reaching the items it has just
|
||||
* offered.
|
||||
*/
|
||||
function openFor(view: EditorView, hit: Decoration, fromKeyboard: boolean): boolean {
|
||||
const spec = hit.spec as { word?: unknown; suggestions?: unknown };
|
||||
if (typeof spec.word !== "string") return false;
|
||||
|
||||
@@ -324,10 +323,61 @@ function menuAt(view: EditorView, pos: number): boolean {
|
||||
top: start.top,
|
||||
// A word that wraps across two lines ends on the lower one, which is where the menu belongs.
|
||||
bottom: Math.max(start.bottom, end.bottom),
|
||||
fromKeyboard,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The misspelling under a position, if the menu should open over one. */
|
||||
function menuAt(view: EditorView, pos: number): boolean {
|
||||
// A document held open while a conflict is resolved is one nothing may edit, and a menu whose
|
||||
// every item is an edit has nothing to offer there. The underlines stay; the menu does not open.
|
||||
if (!view.editable) return false;
|
||||
const decorations = proofingKey.getState(view.state);
|
||||
if (!decorations) return false;
|
||||
const found = decorations.find(pos, pos);
|
||||
if (found.length === 0) return false;
|
||||
// A position at the seam between two words touches both, so a hit that actually contains it wins.
|
||||
const hit = found.find((deco) => deco.from < pos && pos < deco.to) ?? found[0];
|
||||
return openFor(view, hit, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same menu, opened by a chord instead of by a pointer, over the misspelling at the caret or
|
||||
* the nearest one to it.
|
||||
*
|
||||
* Nearest within the caret's own paragraph and no further. The menu is placed at the viewport
|
||||
* coordinates of the word it is about, so a search that ran to the end of the document would open a
|
||||
* menu somewhere off screen about a mistake the user cannot see, and correcting a word you are not
|
||||
* looking at is not what the key was pressed for. Inside a paragraph the distance is almost always
|
||||
* zero or a character or two: this is the word just typed, with the caret still sitting against its
|
||||
* end.
|
||||
*
|
||||
* False when there is nothing to offer, which is what the caller turns into a line of explanation.
|
||||
*/
|
||||
export function openSpellingMenu(): boolean {
|
||||
const view = activeView;
|
||||
if (!view || view.isDestroyed || !view.editable) return false;
|
||||
const decorations = proofingKey.getState(view.state);
|
||||
if (!decorations) return false;
|
||||
|
||||
const $head = view.state.selection.$head;
|
||||
if (!$head.parent.isTextblock) return false;
|
||||
|
||||
let best: Decoration | null = null;
|
||||
let nearest = Infinity;
|
||||
for (const deco of decorations.find($head.start(), $head.end())) {
|
||||
const gap = $head.pos < deco.from ? deco.from - $head.pos : Math.max($head.pos - deco.to, 0);
|
||||
// Strictly nearer, so a caret sitting exactly between two of them takes the earlier word,
|
||||
// which is the one it was most likely just finished typing.
|
||||
if (gap >= nearest) continue;
|
||||
best = deco;
|
||||
nearest = gap;
|
||||
}
|
||||
if (!best) return false;
|
||||
return openFor(view, best, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The debounce, the store subscription and the one view a menu can act on, for as long as there is
|
||||
* an editor to act on.
|
||||
|
||||
+22
-2
@@ -20,7 +20,7 @@ import type { CommandId } from "./commands";
|
||||
*/
|
||||
export type KeyContext = "global" | "document" | "overlay";
|
||||
|
||||
export type BindingGroup = "File" | "Navigation" | "Search" | "View" | "App";
|
||||
export type BindingGroup = "File" | "Navigation" | "Search" | "Editing" | "View" | "App";
|
||||
|
||||
interface BindingBase {
|
||||
/** Every combo that runs it. The sheet shows them all; the dispatcher accepts any. */
|
||||
@@ -81,6 +81,19 @@ export const BINDINGS: readonly Binding[] = [
|
||||
allowInInput: true,
|
||||
},
|
||||
|
||||
// The Mac's own key for this. AppKit gives every NSTextView Cmd+; for "Check Spelling", so it is
|
||||
// the one chord a user is liable to try before reading anything, and it is free: nothing else in
|
||||
// this table binds it and neither does any extension in src/editor, whose chords are all Mod with
|
||||
// a letter, a digit or an editing key (src/editor/fits.test.ts enumerates them). Marked for input
|
||||
// because the caret is in the contenteditable document every time this is pressed.
|
||||
{
|
||||
keys: ["cmd+;"],
|
||||
command: "correct-spelling",
|
||||
context: "document",
|
||||
group: "Editing",
|
||||
allowInInput: true,
|
||||
},
|
||||
|
||||
{
|
||||
keys: ["cmd+\\"],
|
||||
command: "toggle-sidebar",
|
||||
@@ -104,7 +117,14 @@ export const BINDINGS: readonly Binding[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const GROUPS: readonly BindingGroup[] = ["File", "Navigation", "Search", "View", "App"];
|
||||
export const GROUPS: readonly BindingGroup[] = [
|
||||
"File",
|
||||
"Navigation",
|
||||
"Search",
|
||||
"Editing",
|
||||
"View",
|
||||
"App",
|
||||
];
|
||||
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" && /mac|iphone|ipad/i.test(navigator.userAgent ?? "");
|
||||
|
||||
@@ -44,6 +44,7 @@ export type CommandId =
|
||||
| "editor-width-normal"
|
||||
| "editor-width-wide"
|
||||
| "toggle-spelling"
|
||||
| "correct-spelling"
|
||||
| "shortcuts"
|
||||
| "settings"
|
||||
| "check-updates"
|
||||
@@ -319,6 +320,18 @@ const TABLE: Record<CommandId, Omit<Command, "id">> = {
|
||||
run: () => useProofing.getState().toggle(),
|
||||
},
|
||||
|
||||
// Dispatched, unlike the one above it, because its whole result is the correction menu on screen
|
||||
// and the component that draws that menu is the only thing here that knows where the caret is.
|
||||
//
|
||||
// Not in the palette either. What it acts on is the misspelling beside the caret, which is a
|
||||
// target the palette cannot show a row for, and by the time a command name has been typed at a
|
||||
// field the caret is no longer the one the user meant.
|
||||
"correct-spelling": {
|
||||
label: "Correct Spelling",
|
||||
palette: false,
|
||||
run: () => dispatch("correct-spelling"),
|
||||
},
|
||||
|
||||
shortcuts: { label: "Keyboard Shortcuts", palette: true, run: () => dispatch("shortcuts") },
|
||||
settings: { label: "Settings…", palette: true, run: () => dispatch("settings") },
|
||||
"check-updates": {
|
||||
|
||||
+107
-38
@@ -33,23 +33,25 @@
|
||||
// And every write is proved before it goes out. The spliced text is parsed again and compared
|
||||
// against what the splice was meant to do: the same destinations in the same order, each holding
|
||||
// the href it was given, each sitting at the offset the replacements before it shift it to. A file
|
||||
// that does not answer exactly that is not written at all. The proof sits between the splice and
|
||||
// the only call to `fileWrite` in this module, with no condition in front of it, so there is no
|
||||
// path to disk that goes around it.
|
||||
// that does not answer exactly that is not written at all. The proof sits inside `rewriteLinksIn`,
|
||||
// between the splice and the bytes coming back out of it, with no condition in front of it, so
|
||||
// neither the write below nor the one src/document.ts makes for the open document has a path to
|
||||
// disk that goes around it.
|
||||
//
|
||||
// Which files get looked at is decided by walking the open roots rather than by asking the index.
|
||||
// `backlinksFor` is the cheap route and it is deliberately unused: the index is derived state with
|
||||
// no freshness this module can check, and a stale answer is a file quietly left broken, which is
|
||||
// the one outcome this project ranks below doing nothing. The sweep reads every markdown document
|
||||
// in every open root, skipping the parse for text that cannot name the thing that moved, and it
|
||||
// gives up and says so rather than reading more documents than `MAX_SWEEP_DOCUMENTS`. A file
|
||||
// outside every open root is never seen by anything here and never will be.
|
||||
// in every open root, a gitignored one included, since the tree's filter is about what a sidebar
|
||||
// should show and not about whether a link is worth keeping. It skips the parse for text that
|
||||
// cannot name the thing that moved, and it gives up and says so rather than reading more documents
|
||||
// than `MAX_SWEEP_DOCUMENTS`. A file outside every open root is never seen by anything here and
|
||||
// never will be.
|
||||
|
||||
import type { Root } from "mdast";
|
||||
import { fileRead, fileWrite } from "./api/files";
|
||||
import { treeRead } from "./api/roots";
|
||||
import { documentChangedOnDisk } from "./document";
|
||||
import type { FileNode } from "./ipc";
|
||||
import { sweepDocuments } from "./api/roots";
|
||||
import { rewriteOpenDocument } from "./document";
|
||||
import { relativeFrom, resolveRelative } from "./links";
|
||||
import { BOM } from "./markdown/frontmatter";
|
||||
import { parseToMdast } from "./markdown/handlers";
|
||||
@@ -79,7 +81,11 @@ export interface LinkRewriteReport {
|
||||
refused: number;
|
||||
/** Files that could not be read, could not be written, or that another writer reached first. */
|
||||
failed: readonly FailedFile[];
|
||||
/** The open document, when it was left alone because its buffer holds an edit nothing else has. */
|
||||
/**
|
||||
* The open document, when writing it would have gone under what the user is looking at: an
|
||||
* unsaved edit, a save already on the wire, or bytes the editor cannot vouch for because the
|
||||
* file moved on under it.
|
||||
*/
|
||||
heldBack: string | null;
|
||||
/**
|
||||
* Whether every document that could hold a link into the move was actually read. `partial` means
|
||||
@@ -603,30 +609,79 @@ async function rewriteFile(path: string, move: Move, marker: string | null): Pro
|
||||
return { kind: "rewritten", path: newPath, refused: outcome.refused };
|
||||
}
|
||||
|
||||
function documentsUnder(node: FileNode, into: string[]): void {
|
||||
if (node.kind === "dir") {
|
||||
for (const child of node.children) documentsUnder(child, into);
|
||||
return;
|
||||
/**
|
||||
* The same work for the file the user is looking at, handed to src/document.ts rather than done
|
||||
* here.
|
||||
*
|
||||
* That module owns the one write the open document is allowed, and it is the only place that can
|
||||
* ask whether the buffer is clean and write in the same run of the event loop. Doing it from out
|
||||
* here meant reading `dirty`, then two parses of the file, then a write, which on a document of any
|
||||
* size is long enough for a keystroke to land in the middle and come back at the user as a conflict
|
||||
* over a change this app made itself.
|
||||
*
|
||||
* `rewriteLinksIn` stays here, because what a file's new bytes are is this module's question and
|
||||
* nothing else's. It is handed over as a callback, and everything it works out other than the bytes
|
||||
* comes back out of the closure.
|
||||
*
|
||||
* Only for the document that did not move. One that did is at a path the store does not have open
|
||||
* yet, and its caller reopens it there the moment this returns.
|
||||
*/
|
||||
async function rewriteOpenFile(
|
||||
path: string,
|
||||
move: Move,
|
||||
marker: string | null,
|
||||
): Promise<FileResult | "held"> {
|
||||
const outcomes: TextOutcome[] = [];
|
||||
const result = await rewriteOpenDocument(path, (text) => {
|
||||
if (marker !== null && !text.includes(marker) && !text.includes("%")) return null;
|
||||
const outcome = rewriteLinksIn(text, path, move);
|
||||
outcomes.push(outcome);
|
||||
return outcome.text;
|
||||
});
|
||||
|
||||
const outcome = outcomes.length === 0 ? null : outcomes[0];
|
||||
// Asked before the write's own answer, and it has to be. A file whose links could not be matched
|
||||
// to its text is one the callback returned null for, and null is also how it says there was
|
||||
// nothing to do, so the outcome is the only thing that can tell those two apart.
|
||||
if (outcome !== null && outcome.unprovable) {
|
||||
return { kind: "failed", path, reason: "its links could not be matched to its text" };
|
||||
}
|
||||
// Markdown only. A .txt holding something that looks like a link is not markdown, and parsing one
|
||||
// as markdown to edit it would be this module deciding what a file is against its own extension.
|
||||
if (documentKindForPath(node.path) === "markdown") into.push(node.path);
|
||||
if (result.kind === "held") return "held";
|
||||
if (result.kind === "failed") return { kind: "failed", path, reason: result.reason };
|
||||
const refused = outcome === null ? 0 : outcome.refused;
|
||||
return result.kind === "rewritten"
|
||||
? { kind: "rewritten", path, refused }
|
||||
: { kind: "unchanged", refused };
|
||||
}
|
||||
|
||||
/** Every markdown document in every open root, read fresh so the move is already in it. */
|
||||
async function sweepCandidates(): Promise<string[] | null> {
|
||||
/**
|
||||
* Every markdown document in every open root, read fresh so the move is already in it, and whether
|
||||
* that is all of them.
|
||||
*
|
||||
* `sweep_documents` and not `tree_read`: the tree hides what the folder's gitignore hides, which is
|
||||
* the right answer for a sidebar and the wrong one for a writer. `complete` is false when a root
|
||||
* came back holding more documents than the sweep will read, which the backend says by handing back
|
||||
* one path past the cap.
|
||||
*/
|
||||
async function sweepCandidates(): Promise<{ paths: string[]; complete: boolean } | null> {
|
||||
const roots = useWorkspace.getState().roots;
|
||||
const found: string[] = [];
|
||||
const paths: string[] = [];
|
||||
let complete = true;
|
||||
for (const root of roots) {
|
||||
let batch: string[];
|
||||
try {
|
||||
documentsUnder(await treeRead(root.id), found);
|
||||
// One past the cap, so a root that goes over costs the cap rather than the folder and is
|
||||
// still visible as having gone over.
|
||||
batch = await sweepDocuments(root.id, MAX_SWEEP_DOCUMENTS + 1);
|
||||
} catch {
|
||||
// A root that will not answer is a root whose documents were not looked at, and the report
|
||||
// has to say so rather than count the ones that did answer as the whole workspace.
|
||||
return null;
|
||||
}
|
||||
if (batch.length > MAX_SWEEP_DOCUMENTS) complete = false;
|
||||
paths.push(...batch);
|
||||
}
|
||||
return found;
|
||||
return { paths, complete };
|
||||
}
|
||||
|
||||
async function inParallel<T>(items: readonly T[], run: (item: T) => Promise<void>): Promise<void> {
|
||||
@@ -650,7 +705,10 @@ function describe(report: LinkRewriteReport): string | null {
|
||||
trouble.push(`${count(report.failed.length, "file")} could not be updated`);
|
||||
}
|
||||
if (report.heldBack !== null) {
|
||||
trouble.push(`${baseName(report.heldBack)} has unsaved changes and was left alone`);
|
||||
// Not "has unsaved changes" any more. That is the usual reason and it is no longer the only
|
||||
// one: a save still on the wire and a file the editor has lost track of hold it back too, and
|
||||
// naming a cause the user can check and find is not true is worse than naming none.
|
||||
trouble.push(`${baseName(report.heldBack)} is open and was left as it is`);
|
||||
}
|
||||
if (report.refused > 0) {
|
||||
trouble.push(`${count(report.refused, "link")} could not be matched exactly`);
|
||||
@@ -687,8 +745,12 @@ function describe(report: LinkRewriteReport): string | null {
|
||||
* a failure part way through leaves the files before it correctly rewritten, the file that failed
|
||||
* with every byte it had, and the ones after it untouched; that is what `failed` is for, and there
|
||||
* is no rollback because a rollback is another round of writes that can fail in the same way. And
|
||||
* the open document is skipped outright when its buffer is dirty, because the buffer is the only
|
||||
* copy of that edit and writing under it would put the two on a collision the user has to resolve.
|
||||
* the open document is skipped when its buffer is dirty, and equally when a save of it is already
|
||||
* on the wire, because in both cases the bytes in front of the user are the ones that count and
|
||||
* writing under them would put the two on a collision the user has to resolve. When it is written
|
||||
* it goes through src/document.ts rather than around it, and the buffer is handed the new links
|
||||
* before the bytes leave, so the file and the document on screen cannot come out of this as two
|
||||
* copies of the same move.
|
||||
*/
|
||||
export async function rewriteLinksForMove(move: Move): Promise<LinkRewriteReport> {
|
||||
const rewritten: string[] = [];
|
||||
@@ -704,14 +766,18 @@ export async function rewriteLinksForMove(move: Move): Promise<LinkRewriteReport
|
||||
}
|
||||
|
||||
const candidates = await sweepCandidates();
|
||||
const documents = candidates ?? [];
|
||||
const documents = candidates?.paths ?? [];
|
||||
const inside = new Set(documents.filter((path) => path === move.to || isUnder(path, move.to)));
|
||||
// A document dragged into a folder the tree does not list, an ignored one, is not in the sweep at
|
||||
// all, and its own links are the half of this that needs no sweep to be answered.
|
||||
// An ignored document is in the sweep now, so what is left for this line is the case that still
|
||||
// is not: a document moved to somewhere outside every open root is in no list this module can
|
||||
// ask for, and its own links are the half of this that needs no sweep to be answered.
|
||||
if (documentKindForPath(move.to) === "markdown") inside.add(move.to);
|
||||
let outside = documents.filter((path) => !inside.has(path));
|
||||
|
||||
if (candidates === null || outside.length > MAX_SWEEP_DOCUMENTS) {
|
||||
// `complete` is not something `outside.length` can stand in for. A list cut off at the cap whose
|
||||
// every entry landed inside the move leaves nothing outside at all, which reads exactly like a
|
||||
// workspace that had nothing to update while documents were quietly dropped.
|
||||
if (candidates === null || !candidates.complete || outside.length > MAX_SWEEP_DOCUMENTS) {
|
||||
coverage = "partial";
|
||||
outside = [];
|
||||
}
|
||||
@@ -743,17 +809,20 @@ export async function rewriteLinksForMove(move: Move): Promise<LinkRewriteReport
|
||||
);
|
||||
|
||||
if (open !== undefined) {
|
||||
if (useDocument.getState().dirty) {
|
||||
// A document that stayed where it is goes through src/document.ts, which gives the buffer the
|
||||
// new links before the bytes leave and so needs nothing here to tell the editor afterwards. One
|
||||
// that moved cannot: the store is still holding it under the path it came from, and the caller
|
||||
// reopens it at the new one, which reads the file again anyway. That one keeps the old shape,
|
||||
// dirty check and all, because writing under a buffer nobody has landed is the collision this
|
||||
// whole module exists to avoid.
|
||||
if (mapped(open.path, move) === openPath) {
|
||||
const result = await rewriteOpenFile(open.path, move, open.marker);
|
||||
if (result === "held") heldBack = open.path;
|
||||
else take(result);
|
||||
} else if (useDocument.getState().dirty) {
|
||||
heldBack = open.path;
|
||||
} else {
|
||||
const result = await rewriteFile(open.path, move, open.marker);
|
||||
take(result);
|
||||
// Only for a document that did not move. The watcher drops this app's own writes, so nothing
|
||||
// else is going to tell the editor its file changed underneath it. A document that did move
|
||||
// is about to be reopened at its new path by the caller, which reads the file again anyway.
|
||||
if (result.kind === "rewritten" && mapped(open.path, move) === openPath) {
|
||||
await documentChangedOnDisk(openPath).catch(() => {});
|
||||
}
|
||||
take(await rewriteFile(open.path, move, open.marker));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,9 @@ describe("the modelled inventory", () => {
|
||||
expect([...marks].sort()).toEqual(["code", "em", "link", "strikethrough", "strong"]);
|
||||
|
||||
const link = paragraph.child(paragraph.childCount - 2);
|
||||
expect(link.marks[0].attrs).toEqual({ href: "./a.md", title: "T" });
|
||||
// `run` is 0 because the link has no link beside it. It is only ever the other value on a link
|
||||
// whose immediate neighbour goes to the same place, which is what keeps the pair two marks.
|
||||
expect(link.marks[0].attrs).toEqual({ href: "./a.md", title: "T", run: 0 });
|
||||
});
|
||||
|
||||
it("maps images, hard breaks and inline math", () => {
|
||||
|
||||
+28
-7
@@ -33,9 +33,9 @@ import { rawNode } from "../model/doc";
|
||||
import { BOM, normaliseSource, splitFrontmatter, withFrontmatter } from "./frontmatter";
|
||||
import { parseToMdast } from "./handlers";
|
||||
import { buildDoc } from "./parse";
|
||||
import { serializeBody } from "./serialize";
|
||||
import { type Refused, serializeBody } from "./serialize";
|
||||
|
||||
export type { MarkdownDocument };
|
||||
export type { MarkdownDocument, Refused };
|
||||
|
||||
/**
|
||||
* Parses one markdown file into the document the editor edits.
|
||||
@@ -75,9 +75,21 @@ export function parseMarkdown(source: string, path: string): MarkdownDocument {
|
||||
* first byte, and that is the only thing it is told. A block whose bytes open `---` or `+++` is
|
||||
* read back as frontmatter when it sits at the top of a file and is perfectly ordinary two lines
|
||||
* down, so the defence against it has to be skipped for a body that already has a prefix coming.
|
||||
*
|
||||
* `refused` is the traffic in the other direction and the only thing that goes back up: a caller
|
||||
* that hands one in is told, by the time this returns, whether the bytes it is getting are missing
|
||||
* an edit the document still holds. Nothing else about it is optional, so a caller with no use for
|
||||
* the answer leaves it out.
|
||||
*/
|
||||
export function serializeMarkdown(document: MarkdownDocument, doc: ProseMirrorNode): string {
|
||||
return withFrontmatter(document.frontmatter, serializeBody(doc, document.frontmatter === null));
|
||||
export function serializeMarkdown(
|
||||
document: MarkdownDocument,
|
||||
doc: ProseMirrorNode,
|
||||
refused?: Refused,
|
||||
): string {
|
||||
return withFrontmatter(
|
||||
document.frontmatter,
|
||||
serializeBody(doc, document.frontmatter === null, refused),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,14 +134,23 @@ export function parsePlainText(source: string, path: string): MarkdownDocument {
|
||||
}
|
||||
|
||||
/**
|
||||
* The counterpart of `parsePlainText`, taking the same pair as `serializeMarkdown` so a caller can
|
||||
* pick the two functions by `documentKindForPath` and then stop caring which it got.
|
||||
* The counterpart of `parsePlainText`, taking the same arguments as `serializeMarkdown` so a caller
|
||||
* can pick the two functions by `documentKindForPath` and then stop caring which it got.
|
||||
*
|
||||
* No escaping, no reflowing, no normalising: the characters in the document are the bytes of the
|
||||
* file.
|
||||
*/
|
||||
export function serializePlainText(document: MarkdownDocument, doc: ProseMirrorNode): string {
|
||||
export function serializePlainText(
|
||||
document: MarkdownDocument,
|
||||
doc: ProseMirrorNode,
|
||||
refused?: Refused,
|
||||
): string {
|
||||
void document;
|
||||
// Taken and ignored so the two serializers stay one type. `bridgeFor` in src/document.ts hands
|
||||
// its caller whichever of the pair the path calls for, and a call the other half has no parameter
|
||||
// for does not typecheck against the union. There is nothing here to refuse either way: plain
|
||||
// text has no raw blocks and no lists, only lines.
|
||||
void refused;
|
||||
const lines: string[] = [];
|
||||
doc.forEach((block) => lines.push(block.textContent));
|
||||
return lines.join("\n");
|
||||
|
||||
+27
-10
@@ -13,7 +13,7 @@
|
||||
// is reopened.
|
||||
|
||||
import type { Mark, Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import type { BlockContent, DefinitionContent, List, ListItem, PhrasingContent, RootContent, Table } from "mdast";
|
||||
import type { BlockContent, DefinitionContent, Link, List, ListItem, PhrasingContent, RootContent, Table } from "mdast";
|
||||
import { calloutKindFromLabel, rawNode } from "../model/doc";
|
||||
import { isFrontmatterNode } from "./frontmatter";
|
||||
import type { ColumnAlign, HeadingLevel } from "../model/doc";
|
||||
@@ -424,9 +424,13 @@ function toggleFrom(children: RootContent[], opening: number, closing: number, t
|
||||
* Soft line breaks stay inside the text as the newlines mdast gives them, so a paragraph that was
|
||||
* hard wrapped at some column on disk is written back wrapped at the same places. Rewrapping is a
|
||||
* whole file diff on a document nobody meaningfully edited.
|
||||
*
|
||||
* The leaves go into one accumulator shared by every level of the mark nesting, rather than each
|
||||
* level building its own and the caller spreading it in. A link has to know what leaf is
|
||||
* immediately to its left to answer `runAfter`, and the emphasis or strikethrough that leaf came
|
||||
* out of is no part of that question, so the walk cannot be the thing that hides it.
|
||||
*/
|
||||
function inlineFrom(nodes: PhrasingContent[], marks: readonly Mark[]): ProseMirrorNode[] | null {
|
||||
const out: ProseMirrorNode[] = [];
|
||||
function inlineFrom(nodes: PhrasingContent[], marks: readonly Mark[], out: ProseMirrorNode[] = []): ProseMirrorNode[] | null {
|
||||
for (const node of nodes) {
|
||||
switch (node.type) {
|
||||
case "text": {
|
||||
@@ -440,9 +444,7 @@ function inlineFrom(nodes: PhrasingContent[], marks: readonly Mark[]): ProseMirr
|
||||
// addToSet, not a spread: Mark.setFrom sorts but does not deduplicate, and GFM really does
|
||||
// nest a mark inside itself for "~~a ~~b~~ c~~". Two identical marks on one text node
|
||||
// serialize as four tildes, which reopen as literal text and then grow on every save.
|
||||
const inner = inlineFrom(node.children, mark.create().addToSet(marks));
|
||||
if (!inner) return null;
|
||||
out.push(...inner);
|
||||
if (!inlineFrom(node.children, mark.create().addToSet(marks), out)) return null;
|
||||
break;
|
||||
}
|
||||
case "inlineCode": {
|
||||
@@ -458,12 +460,11 @@ function inlineFrom(nodes: PhrasingContent[], marks: readonly Mark[]): ProseMirr
|
||||
// The block goes back as raw source instead: keeping the bytes is the honest answer, and
|
||||
// dropping a destination quietly is the one thing this bridge is here not to do.
|
||||
if (marks.some((mark) => mark.type === m.link)) return null;
|
||||
const mark = m.link.create({ href: node.url, title: node.title ?? null });
|
||||
const inner = inlineFrom(node.children, mark.addToSet(marks));
|
||||
const mark = m.link.create({ href: node.url, title: node.title ?? null, run: runAfter(out, node) });
|
||||
const at = out.length;
|
||||
// A mark needs something to sit on, so a link with no text at all, `[](./x.md)`, has
|
||||
// nowhere to live in the document and would be dropped along with its destination.
|
||||
if (!inner || inner.length === 0) return null;
|
||||
out.push(...inner);
|
||||
if (!inlineFrom(node.children, mark.addToSet(marks), out) || out.length === at) return null;
|
||||
break;
|
||||
}
|
||||
case "image":
|
||||
@@ -481,3 +482,19 @@ function inlineFrom(nodes: PhrasingContent[], marks: readonly Mark[]): ProseMirr
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this link has to be told apart from the one before it, and how.
|
||||
*
|
||||
* ProseMirror has no way to hold two adjacent leaves whose marks compare equal: `Fragment.fromArray`
|
||||
* welds the text nodes together, and the serializer's own fold groups by the same equality, so
|
||||
* `[a](/)[b](/)` was one link by the time anything could see it was two. `run` is what makes the
|
||||
* two marks unequal, and it alternates rather than counting so that it is 0 on every link in every
|
||||
* file that does not have a neighbour to be told apart from.
|
||||
*/
|
||||
function runAfter(out: ProseMirrorNode[], node: Link): number {
|
||||
const before = out[out.length - 1]?.marks.find((mark) => mark.type === m.link);
|
||||
if (!before) return 0;
|
||||
if (before.attrs.href !== node.url || before.attrs.title !== (node.title ?? null)) return 0;
|
||||
return before.attrs.run === 0 ? 1 : 0;
|
||||
}
|
||||
+69
-12
@@ -89,6 +89,19 @@ const EMAIL_SHAPED = /^[^\s<>@]+@[^\s<>@]+$/;
|
||||
*/
|
||||
const ANGLE_SHAPED = /^[A-Za-z][A-Za-z0-9+.-]+:[^\s<>]*$/;
|
||||
|
||||
/**
|
||||
* What the writer could not put in the file, filled in on the way past.
|
||||
*
|
||||
* There is one of these and the comment above `separate` is the whole of why: a raw block beside a
|
||||
* list whose edit no spelling can hold is written from the bytes the file gave it. That decision
|
||||
* stands. What this adds is that it is not made in silence, because a caller that cannot see it
|
||||
* marks the buffer clean over bytes the user's edit is not in. A caller that does not care passes
|
||||
* nothing.
|
||||
*/
|
||||
export interface Refused {
|
||||
rawBlock: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The document as the body of a file, which is one question more than the document as blocks.
|
||||
*
|
||||
@@ -112,8 +125,8 @@ const ANGLE_SHAPED = /^[A-Za-z][A-Za-z0-9+.-]+:[^\s<>]*$/;
|
||||
* first save and then gained a byte every save for the rest of its life. `serializeMarkdown` knows
|
||||
* whether it is about to put a prefix in front and is the only thing that can answer, so it does.
|
||||
*/
|
||||
export function serializeBody(doc: ProseMirrorNode, atStartOfFile = true): string {
|
||||
const tree = docToMdast(doc);
|
||||
export function serializeBody(doc: ProseMirrorNode, atStartOfFile = true, refused?: Refused): string {
|
||||
const tree = docToMdast(doc, refused);
|
||||
const body = stringifyMdast(tree);
|
||||
if (!atStartOfFile || !swallowed(body)) return body;
|
||||
|
||||
@@ -133,14 +146,14 @@ function swallowed(body: string): boolean {
|
||||
return isFrontmatterNode(parseToMdast(body).children[0]);
|
||||
}
|
||||
|
||||
export function docToMdast(doc: ProseMirrorNode): Root {
|
||||
export function docToMdast(doc: ProseMirrorNode, refused?: Refused): Root {
|
||||
const blocks: RootContent[][] = [];
|
||||
const nodes: ProseMirrorNode[] = [];
|
||||
doc.forEach((child) => {
|
||||
nodes.push(child);
|
||||
blocks.push(verifiedBlock(child));
|
||||
});
|
||||
separate(blocks, nodes);
|
||||
separate(blocks, nodes, refused);
|
||||
return { type: "root", children: blocks.flat() };
|
||||
}
|
||||
|
||||
@@ -231,19 +244,38 @@ function listToMdast(node: ProseMirrorNode, ordered: boolean, start: number): Ro
|
||||
// cannot be written a single line apart whatever the attribute says, and one of those makes the
|
||||
// whole list loose here rather than on the next save.
|
||||
const loose = node.attrs.tight !== true || contents.some(runsTogether);
|
||||
if (!loose) contents.forEach(respellRules);
|
||||
const children: ListItem[] = contents.map((content, index) => ({ type: "listItem", spread: loose, checked: checks[index], children: content }));
|
||||
return ordered ? { type: "list", ordered: true, start, spread: loose, children } : { type: "list", ordered: false, start: null, spread: loose, children };
|
||||
}
|
||||
|
||||
/**
|
||||
* The rules an item has to spell the other way, asked only of an item about to be written tight.
|
||||
*
|
||||
* `---` is the house rule and is a setext underline wherever a paragraph is still open above it,
|
||||
* which inside a tight item is the only place the line under a paragraph can be. `***` is the same
|
||||
* rule in the one spelling that interrupts a paragraph, and it is the same swap `serializeBody`
|
||||
* makes for a rule that lands on the first byte of a file. A loose item keeps the house spelling,
|
||||
* because the blank line in front of it has already closed the paragraph.
|
||||
*/
|
||||
function respellRules(blocks: Array<BlockContent | DefinitionContent>): void {
|
||||
for (let index = 1; index < blocks.length; index += 1) {
|
||||
const right = blocks[index];
|
||||
if (right.type === "thematicBreak" && leftOpen(blocks[index - 1]) === "paragraph") withOtherRule(right, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an item's blocks would run into one another if they were written a single line apart.
|
||||
*
|
||||
* Two blocks written flush are two blocks only when the second one opens a block of its own, which
|
||||
* is less often than it looks. A paragraph is still open at the end of its last line, so the line
|
||||
* under it joins it: a second paragraph runs on into the first, and `---` under a paragraph is a
|
||||
* setext heading rather than a rule. A table and an html block are worse than open: every line up
|
||||
* to the next blank one is another row or more html, whatever it says. And two quotes written
|
||||
* flush are one quote, because the second one's markers are read as more of the first.
|
||||
* under it joins it, and a second paragraph runs on into the first. A rule is the one construct
|
||||
* that is only half caught by that: `---` under a paragraph is a setext heading rather than a rule,
|
||||
* but `***` is the same rule in a spelling that interrupts, and `respellRules` has already put the
|
||||
* item's rules into it by the time this is asked. A table and an html block are worse than open:
|
||||
* every line up to the next blank one is another row or more html, whatever it says. And two quotes
|
||||
* written flush are one quote, because the second one's markers are read as more of the first.
|
||||
*
|
||||
* Only neighbours are asked about, because the blocks are written in order and a pair of them is
|
||||
* the whole of what stands between one block and the next.
|
||||
@@ -312,6 +344,10 @@ function opensBlock(block: BlockContent | DefinitionContent): boolean {
|
||||
case "blockquote":
|
||||
case "table":
|
||||
case "html":
|
||||
// A rule interrupts a paragraph in every spelling markdown has for it except `---`, which is a
|
||||
// setext underline there, and `respellRules` has already put the item's rules into the other
|
||||
// one by the time this is asked.
|
||||
case "thematicBreak":
|
||||
return true;
|
||||
case "list": {
|
||||
// A list interrupts a paragraph only when it counts from one and its first item says
|
||||
@@ -765,20 +801,24 @@ function verifiedBlock(node: ProseMirrorNode): RootContent[] {
|
||||
* block, in every document that has no list beside it, is written exactly as it always was.
|
||||
* src/editor/ owns whether the edit should have been possible at all.
|
||||
*/
|
||||
function separate(blocks: RootContent[][], nodes: ProseMirrorNode[]): void {
|
||||
function separate(blocks: RootContent[][], nodes: ProseMirrorNode[], refused?: Refused): void {
|
||||
// Twice at the most: the second pass is for the lists settled against bytes the first pass then
|
||||
// put back, and a block already holding its source is not put back again, so it cannot go round.
|
||||
if (spellEveryList(blocks, nodes)) spellEveryList(blocks, nodes);
|
||||
if (spellEveryList(blocks, nodes, refused)) spellEveryList(blocks, nodes, refused);
|
||||
}
|
||||
|
||||
/** One pass over every list, and whether any raw block beside one gave its edit up to it. */
|
||||
function spellEveryList(blocks: RootContent[][], nodes: ProseMirrorNode[]): boolean {
|
||||
function spellEveryList(blocks: RootContent[][], nodes: ProseMirrorNode[], refused?: Refused): boolean {
|
||||
let gaveUp = false;
|
||||
|
||||
for (let index = 0; index < blocks.length; index += 1) {
|
||||
if (listIn(blocks[index]) === null || spelledApart(blocks, index)) continue;
|
||||
if (keepSource(blocks, nodes, index - 1) || keepSource(blocks, nodes, index + 1)) {
|
||||
gaveUp = true;
|
||||
// A flag rather than a count, and the second pass above is why: what the caller has to be
|
||||
// told is that this save is not carrying an edit somebody made, and raising the same flag
|
||||
// twice over the same block says exactly that once.
|
||||
if (refused) refused.rawBlock = true;
|
||||
spelledApart(blocks, index);
|
||||
}
|
||||
}
|
||||
@@ -981,7 +1021,24 @@ function sameAttrs(a: Record<string, unknown>, b: Record<string, unknown>): bool
|
||||
}
|
||||
|
||||
function sameMarks(a: readonly Mark[], b: readonly Mark[]): boolean {
|
||||
return a.length === b.length && a.every((mark, at) => mark.type.name === b[at].type.name && sameAttrs(mark.attrs, b[at].attrs));
|
||||
return a.length === b.length && a.every((mark, at) => mark.type.name === b[at].type.name && sameAttrs(spelled(mark), spelled(b[at])));
|
||||
}
|
||||
|
||||
/**
|
||||
* A mark's attributes minus the ones no spelling of it carries, which is `run` and nothing else.
|
||||
*
|
||||
* `run` is not in the file. It exists so that two adjacent links to one destination are two marks
|
||||
* rather than one, and the parser numbers it from the block it has just read, so a link whose
|
||||
* neighbour has since been deleted carries a number the re-parse of it will not produce. Comparing
|
||||
* it here fails a block that came back perfectly, and the ladder answers that by writing the block
|
||||
* at a plainer fidelity: a code span holding a line ending loses it, on a paragraph nobody edited.
|
||||
* The difference `run` exists to make is two links written as one, and that is a difference in the
|
||||
* text and in the child count, which this walk already sees.
|
||||
*/
|
||||
function spelled(mark: Mark): Record<string, unknown> {
|
||||
if (mark.type.name !== "link") return mark.attrs;
|
||||
const { run: _run, ...rest } = mark.attrs;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -192,6 +192,7 @@ describe("a list written tight", () => {
|
||||
["a table", [paragraph(text("a")), TABLE]],
|
||||
["an equation", [paragraph(text("a")), n.mathBlock.createChecked({ latex: "y" })]],
|
||||
["a quote", [paragraph(text("a")), n.blockquote.createChecked(null, paragraph(text("q")))]],
|
||||
["a rule, which is written as the spelling that interrupts a paragraph", [paragraph(text("a")), n.horizontalRule.createChecked()]],
|
||||
];
|
||||
|
||||
for (const [what, content] of tight) {
|
||||
@@ -206,7 +207,6 @@ describe("a list written tight", () => {
|
||||
it("writes an item loose when its blocks would run into each other", () => {
|
||||
const loose: Array<[string, ProseMirrorNode[]]> = [
|
||||
["two paragraphs run together into one", [paragraph(text("a")), paragraph(text("b"))]],
|
||||
["a rule under a paragraph is a setext heading", [paragraph(text("a")), n.horizontalRule.createChecked()]],
|
||||
["a paragraph under a nested list is a lazy continuation of it", [paragraph(text("a")), NESTED, paragraph(text("e"))]],
|
||||
["a paragraph under a quote is a lazy continuation of it", [paragraph(text("a")), n.blockquote.createChecked(null, paragraph(text("q"))), paragraph(text("e"))]],
|
||||
["a paragraph under a table is another row of it", [paragraph(text("a")), TABLE, paragraph(text("e"))]],
|
||||
|
||||
@@ -235,9 +235,12 @@ describe("callout and toggle", () => {
|
||||
describe("inline", () => {
|
||||
it("keeps image and link attributes", () => {
|
||||
expect(n.image.create().attrs).toEqual({ src: "", alt: null, title: null });
|
||||
expect(m.link.create().attrs).toEqual({ href: null, title: null });
|
||||
// `run` is the third one, and it is 0 here because it is 0 on every link that does not sit
|
||||
// next to another link to the same place. It is what makes two of those two marks instead of
|
||||
// one, and src/markdown/parse.ts is where it gets a value other than this.
|
||||
expect(m.link.create().attrs).toEqual({ href: null, title: null, run: 0 });
|
||||
const link = m.link.create({ href: "./a.md", title: "A" });
|
||||
expect(link.toJSON()).toEqual({ type: "link", attrs: { href: "./a.md", title: "A" } });
|
||||
expect(link.toJSON()).toEqual({ type: "link", attrs: { href: "./a.md", title: "A", run: 0 } });
|
||||
});
|
||||
|
||||
it("lets a code span sit inside a link and inside emphasis", () => {
|
||||
|
||||
+34
-3
@@ -102,12 +102,31 @@ export const nodes: { [name in NodeName]: NodeSpec } = {
|
||||
toDOM: () => ["p", 0],
|
||||
},
|
||||
|
||||
// `whitespace: "pre"` is the same three part sentence as the paragraph's, for the same reason: a
|
||||
// setext heading is the one heading markdown lets an author wrap by hand, and the wrap is theirs.
|
||||
// src/markdown/parse.ts builds a heading through the same call a paragraph goes through, so the
|
||||
// soft break stays a newline in the text, and prose.css draws the whole surface pre-wrap, so the
|
||||
// newline is on screen where the author put it. Left at the default, prosemirror-view read every
|
||||
// one of those wraps back out of the editor's own DOM as a `hardBreak` on the next keystroke, and
|
||||
// mdast writes a break inside a setext heading as a backslash and a line ending, so one character
|
||||
// typed into a two line heading put a backslash into the user's own words.
|
||||
//
|
||||
// It holds at every level and not only at the two underlined ones. The deeper four have no setext
|
||||
// spelling and so no hand wrap to keep, but they can still hold a line ending, spelled `
` on
|
||||
// disk, and it round trips; left at the default a keystroke turned that into a break, which those
|
||||
// levels write out as a space. On screen until the next save, and gone after it.
|
||||
//
|
||||
// The parse rules say the opposite on purpose, and a rule's own answer outranks the node's, which
|
||||
// is the paragraph's reasoning verbatim: whitespace is significant in a heading THIS editor
|
||||
// rendered, and meaningless in an `<h2>` off somebody else's page, where the newlines and the
|
||||
// runs of spaces are the html source's own indentation.
|
||||
heading: {
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
whitespace: "pre",
|
||||
attrs: { level: { default: 1, validate: "number" } },
|
||||
parseDOM: HEADING_LEVELS.map((level) => ({ tag: `h${level}`, attrs: { level } })),
|
||||
parseDOM: HEADING_LEVELS.map((level) => ({ tag: `h${level}`, attrs: { level }, preserveWhitespace: false })),
|
||||
toDOM: (node) => [`h${node.attrs.level}`, 0],
|
||||
},
|
||||
|
||||
@@ -400,19 +419,31 @@ export const nodes: { [name in NodeName]: NodeSpec } = {
|
||||
};
|
||||
|
||||
export const marks: { [name in MarkName]: MarkSpec } = {
|
||||
// `run` is not a property of the link, it is what tells one link from the one beside it. Marks
|
||||
// compare by type and attributes, and a document has no way to hold two adjacent leaves whose
|
||||
// marks compare equal: `Fragment.fromArray` welds the text nodes into one and the serializer's
|
||||
// own fold groups by the same equality, so `[a](/)[b](/)` was a single link before anything in
|
||||
// the app could see it was two. It alternates between 0 and 1 rather than counting, so it leaves
|
||||
// the default on every link that has no neighbour to be told apart from, which is every link in
|
||||
// very nearly every file, and so a block re-parsed on its own gives the same number back.
|
||||
//
|
||||
// The DOM half is not decoration. prosemirror-view re-parses the editor's own DOM after some
|
||||
// input, and a pair that came out of that parse without `data-run` is a pair merged back into
|
||||
// one between the keystroke and the save.
|
||||
link: {
|
||||
inclusive: false,
|
||||
attrs: {
|
||||
href: { default: null, validate: "string|null" },
|
||||
title: { default: null, validate: "string|null" },
|
||||
run: { default: 0, validate: "number" },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "a[href]",
|
||||
getAttrs: (dom) => ({ href: dom.getAttribute("href"), title: dom.getAttribute("title") }),
|
||||
getAttrs: (dom) => ({ href: dom.getAttribute("href"), title: dom.getAttribute("title"), run: dom.getAttribute("data-run") === "1" ? 1 : 0 }),
|
||||
},
|
||||
],
|
||||
toDOM: (mark) => ["a", { href: mark.attrs.href, title: mark.attrs.title }, 0],
|
||||
toDOM: (mark) => ["a", { href: mark.attrs.href, title: mark.attrs.title, "data-run": mark.attrs.run ? "1" : null }, 0],
|
||||
},
|
||||
|
||||
strong: {
|
||||
|
||||
@@ -43,6 +43,9 @@ export interface ProofTarget {
|
||||
left: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
/** True when a chord opened the menu rather than a pointer, which is the whole of what decides
|
||||
* whether the menu takes focus. src/components/ProofPopover.tsx says why a click must not. */
|
||||
fromKeyboard: boolean;
|
||||
}
|
||||
|
||||
/** "missing" is both a checker that said no and a command that was not there to ask. */
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock("../api/roots", () => ({
|
||||
rootOpen: vi.fn(),
|
||||
rootClose: vi.fn(),
|
||||
treeRead: vi.fn(),
|
||||
sweepDocuments: vi.fn(),
|
||||
revealInFinder: vi.fn(),
|
||||
openExternal: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -9,6 +9,7 @@ const roots = vi.hoisted(() => ({
|
||||
rootOpen: vi.fn(),
|
||||
rootClose: vi.fn(),
|
||||
treeRead: vi.fn(),
|
||||
sweepDocuments: vi.fn(),
|
||||
revealInFinder: vi.fn(),
|
||||
openExternal: vi.fn(),
|
||||
}));
|
||||
@@ -110,6 +111,10 @@ beforeEach(() => {
|
||||
file(`${path}/logo.png`, "other"),
|
||||
]);
|
||||
});
|
||||
roots.sweepDocuments.mockImplementation(async (rootId: string) => {
|
||||
const path = rootId.slice("id-".length);
|
||||
return [`${path}/README.md`, `${path}/guides/writing.md`];
|
||||
});
|
||||
roots.rootClose.mockResolvedValue(undefined);
|
||||
roots.revealInFinder.mockResolvedValue(undefined);
|
||||
watch.watchStart.mockResolvedValue(undefined);
|
||||
|
||||
+14
-1
@@ -57,11 +57,24 @@
|
||||
}
|
||||
|
||||
.proof-suggestion:hover,
|
||||
.proof-action:hover {
|
||||
.proof-action:hover,
|
||||
.proof-suggestion:focus-visible,
|
||||
.proof-action:focus-visible {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* The keyboard's item is drawn as the pointer's is, plus the app's own focus ring from app.css,
|
||||
because a menu that marked the focused row differently from the hovered one would be claiming
|
||||
there are two selections in it. Only the offset is changed: at the shared 2px the ring sits
|
||||
outside the five pixels of padding the popover has and is clipped by its own border, so it is
|
||||
turned inward to the same distance instead. The colour stays --accent, which is the token, and
|
||||
is what makes this legible on both papers. */
|
||||
.proof-suggestion:focus-visible,
|
||||
.proof-action:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.proof-none {
|
||||
margin: 0;
|
||||
padding: 7px 10px;
|
||||
|
||||
+32
-9
@@ -21,7 +21,12 @@ import {
|
||||
} from "./api/files";
|
||||
import { revealInFinder, rootClose, rootOpen, rootsList, treeRead } from "./api/roots";
|
||||
import { watchStart, watchStop } from "./api/watch";
|
||||
import { abandonDocument, documentChangedOnDisk, flushPendingSave } from "./document";
|
||||
import {
|
||||
abandonDocument,
|
||||
documentChangedOnDisk,
|
||||
documentMovedTo,
|
||||
flushPendingSave,
|
||||
} from "./document";
|
||||
import { rewriteLinksForMove } from "./linkRewrite";
|
||||
import {
|
||||
INDEX_PROGRESS_EVENT,
|
||||
@@ -243,11 +248,31 @@ export async function renamePath(path: string, name: string): Promise<void> {
|
||||
if (useWorkspace.getState().selectedPath === path) {
|
||||
useWorkspace.getState().select(node.path);
|
||||
}
|
||||
// Still dirty means the flush conflicted, so the buffer is the only copy of that edit and
|
||||
// reopening at the new name would throw it away. It stays where it is and stays flagged.
|
||||
if (affected && open !== null && !useDocument.getState().dirty) {
|
||||
await useDocument.getState().open(node.path + open.slice(path.length));
|
||||
}
|
||||
await followTheFile(open, affected, path, node.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the open document at where its file went, either way round.
|
||||
*
|
||||
* A clean buffer is reopened, which is what puts the rewritten links in front of the user. A dirty
|
||||
* one is not, because the flush at the top of the caller conflicted or the user typed while the
|
||||
* sweep was running, and either way the buffer is the only copy of that edit and a reopen is a
|
||||
* read that would throw it away. What it is not allowed to be is left where it was: a sweep can
|
||||
* read and write five thousand documents, which is a long time to be holding a buffer over a path
|
||||
* this app has already moved the file off, and the save on the other end of that would have put a
|
||||
* second copy of the document back at the old path. `documentMovedTo` is the whole of the fix and
|
||||
* it says why there.
|
||||
*/
|
||||
async function followTheFile(
|
||||
open: string | null,
|
||||
affected: boolean,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<void> {
|
||||
if (!affected || open === null) return;
|
||||
const moved = to + open.slice(from.length);
|
||||
if (useDocument.getState().dirty) documentMovedTo(moved);
|
||||
else await useDocument.getState().open(moved);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,9 +294,7 @@ export async function movePath(path: string, destDir: string): Promise<string> {
|
||||
if (useWorkspace.getState().selectedPath === path) {
|
||||
useWorkspace.getState().select(node.path);
|
||||
}
|
||||
if (affected && open !== null && !useDocument.getState().dirty) {
|
||||
await useDocument.getState().open(node.path + open.slice(path.length));
|
||||
}
|
||||
await followTheFile(open, affected, path, node.path);
|
||||
return node.path;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Proof that the dev server on the port is serving this checkout, run once before any spec.
|
||||
//
|
||||
// playwright.config.ts sets `reuseExistingServer`, which is what makes the suite quick to run
|
||||
// against a server that is already up, and it is also what lets the suite talk to a server another
|
||||
// checkout left behind. A stale server serves that checkout, so every assertion afterwards is about
|
||||
// code this working tree does not contain: a pass proves nothing and a failure sends you looking
|
||||
// for a bug in a file you never changed. `bytes.spec.ts` has carried a check of its own for that
|
||||
// since it caught one, but it was the only suite that had it, and the four beside it were happy to
|
||||
// pass against anybody's copy.
|
||||
//
|
||||
// Vite serves any module with `?raw` as its own source, so the check is a byte comparison rather
|
||||
// than a guess: read the file here, ask the server for the same file, and require the two to be
|
||||
// the same string. The files are the load bearing ones, the schema the editor is built on, the save
|
||||
// path, the writer, the fixture the specs read and the seam they mock, so a server old enough to
|
||||
// matter differs in at least one of them.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { join } from "node:path";
|
||||
import type { FullConfig } from "@playwright/test";
|
||||
|
||||
/** The repository root, resolved off this file rather than off the working directory. */
|
||||
const ROOT = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
const WITNESSES = [
|
||||
"src/model/schema.ts",
|
||||
"src/document.ts",
|
||||
"src/markdown/serialize.ts",
|
||||
"src/dev/fixture.ts",
|
||||
"src/ipc.ts",
|
||||
];
|
||||
|
||||
/**
|
||||
* Reads back the string in `export default '...'`, which is the whole body of a `?raw` module.
|
||||
* Written out rather than evaluated, because the point of this file is that the server is not
|
||||
* trusted yet, and running what it sends to find out whether to trust it has the order wrong.
|
||||
*/
|
||||
function decode(module: string): string {
|
||||
const body = module
|
||||
// Vite appends an inline source map to the module it serves, which is a comment about the
|
||||
// module rather than part of it.
|
||||
.replace(/\n\/\/# sourceMappingURL=[\s\S]*$/, "")
|
||||
.trim()
|
||||
.replace(/^export default\s*/, "")
|
||||
.replace(/;$/, "");
|
||||
const quote = body[0];
|
||||
if ((quote !== "'" && quote !== '"') || body[body.length - 1] !== quote) {
|
||||
throw new Error(`the server answered ?raw with something that is not a string literal: ${body.slice(0, 80)}`);
|
||||
}
|
||||
|
||||
let out = "";
|
||||
for (let i = 1; i < body.length - 1; i++) {
|
||||
if (body[i] !== "\\") {
|
||||
out += body[i];
|
||||
continue;
|
||||
}
|
||||
const escape = body[++i];
|
||||
if (escape === "n") out += "\n";
|
||||
else if (escape === "r") out += "\r";
|
||||
else if (escape === "t") out += "\t";
|
||||
else if (escape === "b") out += "\b";
|
||||
else if (escape === "f") out += "\f";
|
||||
else if (escape === "v") out += "\v";
|
||||
else if (escape === "0") out += "\0";
|
||||
else if (escape === "u" && body[i + 1] === "{") {
|
||||
const end = body.indexOf("}", i);
|
||||
out += String.fromCodePoint(parseInt(body.slice(i + 2, end), 16));
|
||||
i = end;
|
||||
} else if (escape === "u") {
|
||||
out += String.fromCharCode(parseInt(body.slice(i + 1, i + 5), 16));
|
||||
i += 4;
|
||||
} else if (escape === "x") {
|
||||
out += String.fromCharCode(parseInt(body.slice(i + 1, i + 3), 16));
|
||||
i += 2;
|
||||
} else if (escape === "\n") {
|
||||
// A line continuation stands for nothing at all.
|
||||
} else out += escape;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default async function identity(config: FullConfig): Promise<void> {
|
||||
const baseURL = config.projects[0]?.use?.baseURL;
|
||||
if (!baseURL) throw new Error("no baseURL to check: playwright.config.ts stopped setting one.");
|
||||
|
||||
for (const witness of WITNESSES) {
|
||||
const url = `${baseURL}/${witness}?raw`;
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`${url} answered ${response.status}. Whatever is on this port is not the Vite dev server ` +
|
||||
`for this checkout. Stop it and let \`pnpm test:ui\` start its own.`,
|
||||
);
|
||||
}
|
||||
|
||||
const served = decode(await response.text());
|
||||
const here = readFileSync(join(ROOT, witness), "utf8");
|
||||
if (served !== here) {
|
||||
throw new Error(
|
||||
`The dev server on this port is serving a different ${witness} from the one in this ` +
|
||||
`working tree, so it is serving somebody else's checkout and this suite would be ` +
|
||||
`testing their code. Stop that server and run \`pnpm test:ui\` again.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,5 +37,35 @@ export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
environment: "node",
|
||||
|
||||
// The markdown suites are thousands of generated documents pushed through the writer inside one
|
||||
// synchronous loop, and the worst of them holds a worker's event loop for the better part of
|
||||
// half a minute even on a fast machine. Everything below exists because of that.
|
||||
//
|
||||
// A worker tells the main process about each test it starts, over an RPC call it then waits on,
|
||||
// and that call is given sixty seconds before it gives up with `Timeout calling "onTaskUpdate"`.
|
||||
// A test that blocks the loop cannot take delivery of the answer while it is running, so what
|
||||
// the sixty seconds really bounds is the length of the slowest single test, not the round trip.
|
||||
// Vitest does not expose that timeout, so the only lever left is keeping the tests from getting
|
||||
// slower, and what makes them slower is contention. The default is one worker per core bar one,
|
||||
// which on a two or three core hosted runner has the heaviest suites fighting each other and the
|
||||
// main process for the same cores. Half the cores gives each worker a core to itself and still
|
||||
// leaves one for the main process to answer on. This is a percentage rather than a number
|
||||
// behind a CI check because it lands on the right answer for a two core runner, a three core
|
||||
// one and a developer's laptop without anyone having to know which one the job got, and it
|
||||
// costs nothing locally: the run is bounded by its slowest single file either way.
|
||||
maxWorkers: "50%",
|
||||
|
||||
// Five seconds is the default and nothing in the markdown suites honours it. The slow tests
|
||||
// pass their own timeout to `it` as a third argument. The merely slow-ish ones do not, and they
|
||||
// are the ones that go red on a loaded runner having done nothing wrong. Thirty seconds is what
|
||||
// most of the annotated tests already ask for, and it stays under the sixty the RPC allows, so
|
||||
// this cannot itself cause the failure described above.
|
||||
testTimeout: 30_000,
|
||||
hookTimeout: 30_000,
|
||||
|
||||
// How long a worker is given to shut down before it is killed. Ten seconds is plenty when the
|
||||
// machine is idle and is not when several forks are all winding up at once.
|
||||
teardownTimeout: 30_000,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user