Repository navigation
Audit hardening: dependencies, TUI correctness and speed, query fixes, CI and release - #94
Closed
nicholasjpaterno wants to merge 90 commits into
Closed
nicholasjpaterno wants to merge 90 commits into
nicholasjpaterno wants to merge 90 commits into
Conversation
turbovault-parser 2.2.0 makes a nested list a real block in its item (turbovault#79) and keeps a quoted item's continuation as its own paragraph (turbovault#77). The 3.0.0 major is the MCP wire format and leaves the parser API alone. The item's inline run still carries everything nested in it, which the top-level renderer drew on the item's row, so a nested list printed `outerinnerdeep`. own_inline takes the nested part back off, both list renderers draw from it, and a nested task keeps its checkbox. A selected link in a nested item gets its pointer on its own row. .list pushed a list after walking its items, so nested lists came out innermost first. It pushes first now, as a blockquote already did. The contract fixture gains a nested list and a quoted continuation, the two shapes this upgrade changed and the snapshot had no way to see. Supersedes #91, #92 and #93. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lockfile refresh moved wayland-scanner to quick-xml 0.41, which RUSTSEC-2026-0194 and RUSTSEC-2026-0195 do not cover, and no 0.39 copy remains. cargo audit against this lockfile reports no vulnerabilities with nothing ignored, so the CI job and the justfile stop ignoring them and any advisory fails the build again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The startup file picker, added in 0.5.5, took the no-argument branch before anything checked stdin. From a directory holding markdown it opened the picker, so `cat doc.md | treemd -q '.h'` ran the query against the picker's placeholder and printed `# Select a file`. From any other directory it printed usage and exited 0. Only `treemd -` still read stdin. Piped stdin now wins when no file is named, ahead of the picker, which is what the README, `--help` and the skill all document. The test runs from a directory with markdown in it and from one without, the two ways this failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CLI half of the audit, each confirmed against the built binary: - Every heading's line was counted from the start of the document, quadratic in headings: 16k headings took 20 s to query and as long for -o json. Document::heading_lines is the one sequential pass the line-range code already did, and the query engine and the JSON builder use it. It also counts newlines rather than lines(), which was one too many for `> # quoted`. - Rust ignores SIGPIPE, so `-l big.md | head -1` panicked. The default disposition is restored for the process. - ureq has no timeouts by default. The fetch gets 10 s to connect and 30 s overall, and https stays https through redirects. - The `# Input` heading for a heading-less document is TUI-only now, decided by the same launches_tui test that starts the TUI. - A UTF-8 BOM hid the first heading. Parsing skips it and offsets still index the content with it. - Scoped `.code` re-parsed the section and numbered blocks from its first line. Block lines are document-true since parser 2.1, so the global list is filtered by line range instead. - -L silently dropped --filter; both apply. -o json ignores both and now says so. - jsonp is accepted, langs and types are sorted, and whole numbers print as integers without clamping large ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A document is untrusted, and -l, --tree, -s and -q printed its text verbatim. On a terminal, `# Title ESC]0;PWNED BEL ESC[2J` retitled the window and cleared the screen. The TUI never had this, because ratatui drops control characters from its buffer. cli::terminal::display caret-escapes C0, DEL and C1 the way `cat -v` does when stdout is a terminal, keeping newlines, tabs and CRLF, and passes piped output through untouched. Checked under a pty: no raw ESC reaches the terminal, and a pipe still gets all three. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The panic hook restored the terminal before anything else, and Rust runs the hook before catch_unwind regains control. So a panic that the image worker or the mermaid renderer was written to absorb still left the alternate screen, dropped raw mode, and printed its message over the frame, and treemd went on drawing into a terminal in cooked mode. catch_isolated marks its extent on the thread and the hook leaves a panic inside it alone. The image worker and both mermaid stages use it; rasterization had no isolation at all before. A mermaid raster is also bounded now. It was scaled to the pane width with no limit, so a fifty-node chain at a wide pane was 2000x77000 pixels and over a gigabyte, redone on every width change. It is held to 16 MP, drawn narrower when it would exceed that, and a small diagram is enlarged at most 2x instead of being stretched to fill the pane. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An image path comes from the document. `` blocked in open() until something wrote to the pipe, on the UI thread with raw mode on, so the session froze and Ctrl-C could not end it. A terminal or a device file did the same. Every image is now opened through open_image_file, which requires a regular file under 128 MB and checks that before opening. A GIF declares its canvas size in its header, and the canvas was allocated from it before any frame was read: a 62-byte file declaring 12000x12000 took over a gigabyte the moment the document opened, and the format allows 17 GB. A canvas over 16 MP is refused first. The four GIF decoder setups and three still-image decodes collapse into read_gif and decode_still, and a decode error now says what failed rather than "Unsupported format". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The viewer decoded every frame of a GIF at full size before it opened, which for a 1200x800, 450-frame README recording is around 1.7 GB. It keeps the delays and a StreamingGif now: playback decodes the next frame as it reaches it, and stepping back reopens the stream and walks to the frame once. Every frame was kept for the native Kitty transmitter, which could not work. Each a=f frame carried r=1, which in the graphics protocol edits frame one rather than adding a frame, and a=T was given the cursor position as c/r, which is the display size in cells. So it showed one stretched frame, and it was chosen from the environment alone, which KITTY_WINDOW_ID inherited into tmux satisfies. ratatui-image already draws Kitty frames correctly, so playback goes through it like every other terminal. iTerm2 keeps its native path, which sends the file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
render_mermaid_if_needed ran the layout and rasterization inside terminal.draw. Layout cannot be interrupted and grows much faster than its input: the security audit measured a 4 KB diagram of 50 densely connected nodes at 92 seconds, all of it with the UI taking no input, from one fenced block in a document. MermaidWorker does the work on its own thread, started by the first diagram. A request is tagged with its source hash and the pane width, so a result that arrives after the width changed is dropped and the current width's request, already queued, replaces it. The frame shows "rendering mermaid diagram" until the image is ready, and the event loop polls while any are pending, through the same hooks as image encoding. A source over 32 KB is refused without layout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every save went through NamedTempFile::persist, which leaves the file owner-only, so one checkbox toggle turned a 0644 note into 0600. It also wrote the in-memory copy whatever had happened on disk since load: with a table edit pending the watcher deliberately skips reloading, so an edit made in another editor was silently undone by the next save. fsutil::write_atomic is the one write: through a symlink rather than over it, the original's permissions kept, synced before and after the rename. App::write_document is the one TUI save. It refuses unless the document is a real file (a stdin document wrote `<stdin>` into the working directory) and unless the disk still matches the hash taken when treemd last read or wrote it. Config::save uses write_atomic too, and a config that failed to parse keeps no path, so changing the theme no longer saves defaults over the user's file. Search matched in a lowercased copy and sliced the original at those offsets. Lowercasing changes the length of `İ` and friends, so a Turkish query panicked, and others highlighted the wrong letters. find_ignore_case uses a case-insensitive regex of the escaped query, which reports positions in the text it searched, and the outline filter, content highlighter and match list all use it. Ctrl+C was an ordinary key in raw mode and bound to nothing; it quits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A markdown link refused absolute paths and `..` in load_file_internal, but resolve_relative_file_link's non-markdown branch joined the link onto the document's directory and handed any existing result to the editor, so `[x](/etc/hosts)` opened /etc/hosts. And the boundary check canonicalized the full path inside an `if let (Ok, Ok)`, which passes silently when the target does not exist yet: with `docs` a symlink out of the repository, `[x](docs/new.md)` created the file wherever `docs` pointed once the prompt was confirmed. Wikilinks built their create path with a bare join as well. confine_link_path is the one check, used by markdown links, the non-markdown branch, and wikilinks, before any prompt: no absolute, root, prefix or `..` component, no symlink as the last component, and the deepest part of the path that exists resolved through its symlinks and required to sit under the document's directory. The walk uses symlink_metadata so a dangling symlink fails to resolve rather than being stepped over and later written through. Link-follow mode now opens only http and https, the rule interactive mode already had, through one is_openable_url. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app picked a table by its index among the section's top-level tables as turbovault-parser reports them. edits.rs then found tables in the file with its own rule, a line that starts and ends with a pipe, which misses pipe-less tables and counts ones nested in list items. One of either before the target shifted the index, and the edit, and then the save, went to a different table. Tables are now located by pulldown-cmark, the parser turbovault runs, with its options, already in the build at the same version. Its offset iterator gives each top-level table's byte range; the row and cell are found inside it by unescaped pipes, with or without outer pipes. As a last guard, the cell found in the file has to have the same letters as the one on screen, or the edit is refused. The edit buffer starts from the cell's source, so bold and italic, which the parser strips from its text, survive an edit. Undo writes the original source back verbatim; writing the parser's text turned `x \| y` into `x | y`, a second cell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The no-default-features build was only checked, never tested, so the path without mermaid and dynamic completions ran nowhere. It is tested now, and so is MSRV, which was check-only as well. `cargo doc` with warnings denied failed on three intra-doc links in the keybindings module docs and nothing ran it. They are fixed and the lint job builds the docs. A weekly run catches a new advisory or a toolchain change on a quiet repository, rather than on the next push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every link with `..` was refused and the boundary was the current document's directory, which moved down with each link followed, so `docs/guide.md` could never link back to `../README.md`. The boundary is now a link root, set when a document is opened deliberately (startup or the file picker): its git work tree, or its directory when there is none. confine_link_path resolves a link one component at a time against the real filesystem, canonicalizing what exists so symlinks are judged by their target and `..` steps up from the real location, keeping what does not exist yet as written, and requires the result under the root. A leading `/` is root-relative, as GitHub reads it. Symlinks pointing inside the root are followed now; ones pointing out are still refused, existing or not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each frame filtered, parsed, highlighted and wrapped the whole section, which on a large document cost about a second per keypress and per scroll step. And every position on screen came from a separate count of lines that knew nothing of wrapping or of the filtering the pane does, so the selection, images, mermaid diagrams and search matches were all placed at counted lines rather than drawn rows. content_view wraps the rendered lines to the pane width itself, with grapheme widths, and keeps a row map: where each line and each top-level block was drawn. Three cached stages (displayed text, rendered lines, wrapped rows with search highlighting) each rebuild only when an input changes, and a frame draws only the visible rows. On a 21,000-line document a scroll frame takes about a millisecond. The interactive index parses the same displayed text as the renderer, so frontmatter no longer shifts its block numbering against the pane's, and each element is placed relative to where the renderer really drew its block, then mapped through the wrapping. Scrolling to the selection happens in the frame, against the current layout. Search runs over the drawn lines, so matches, highlighting and scrolling agree. The scroll offset is a usize, so nothing past row 65,535 is out of reach. Images that scroll off the top are no longer pinned over the text below, and are drawn no taller than their reserved rows. The checkbox toggle maps the selected item to the source by its place among task items, since hiding frontmatter or display math can remove blocks but never list items. ratatui's unstable line-count feature is no longer needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tag like `@v7` or `@stable` is a mutable pointer: whoever controls the action's repository can move it to different code, and the next run picks that up silently. The release workflow runs with contents: write and the Apple signing secrets, so that is a supply-chain path straight into a published, immutable release. Every `uses:` now names a commit SHA with the release it belongs to in a trailing comment, which is the form Dependabot's github-actions updates recognise and keep current. dtolnay/rust-toolchain publishes no release tags, so it is pinned to a commit on master and given the toolchain explicitly, the pattern its README documents. `cargo install cross --git ... --rev` resolved cross's dependencies fresh on every run; --locked makes it use the lockfile at that rev. Checkouts no longer persist the token into .git/config, since no job pushes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cargo-deny's advisories check reads the same RustSec database cargo-audit does, so running both would be the same check twice. deny adds what audit cannot: a license allow-list, a ban on `*` version requirements, and crates.io as the only permitted source, which keeps a git or alternate-registry dependency from slipping in through a PR. deny.toml allows exactly the licenses the current tree needs, checked across every target and feature, and ignores no advisory. Unmaintained notices fail only for direct dependencies: the two current ones, bincode under syntect and ttf-parser under mermaid-rs-renderer, are transitive and have no fix on our side, and cargo-audit only ever warned on them. The action runs a prebuilt cargo-deny (0.20.2 at the pinned SHA), so the job no longer compiles a tool from source on every run, and it needs no checks: write. `just audit` and `just deny` run the same checks locally. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
x86_64-unknown-linux-musl is what Alpine users install (#42 was a musl/Alpine breakage), but it was only ever built by the release workflow, after the tag was pushed and could no longer be changed. A musl-only failure was therefore always found in a release. The new job builds it the way the release does, with cross at the same pinned rev, and runs scripts/smoke-test.sh on it inside an alpine:3 container with the checkout mounted read-only. The script is plain POSIX sh so the same file works there, on the release runners, and under Git Bash on Windows. It checks that `--version` answers and that `-l` lists a small document's headings exactly. A debug build keeps the job quick; the release build gets the same test in the release workflow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tag push went straight to building and publishing. Nothing checked that CI had passed, or even run, on the tagged commit, and since a pushed tag is immutable here, a release cut from a red commit could not be withdrawn, only superseded. rust.yml is now callable (workflow_call) and release.yml runs it as a job that the build matrix needs, alongside the preflight. That was chosen over a preflight that queries the commit's check runs through the API: it does not depend on a push-to-main run having happened, or having finished, before the tag, and it is the same suite PRs get, not a list of check names to keep in sync. The preflight also runs `cargo publish --dry-run --locked`, as does `just release-preflight`, so a crates.io packaging problem is found before the tag rather than after it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The certificate import had continue-on-error: true and the signing step fell back to ad-hoc signing whenever no Developer ID identity was found, so an expired certificate or a missing secret produced a published release whose macOS binaries Gatekeeper blocks, with only a log line to show for it. Notarization failed open too: `notarytool submit --wait` exits 0 when Apple's verdict is Invalid. On a tag, the import now fails the job, a missing identity is an error, and the notarization status is read from notarytool's JSON and must be Accepted, with Apple's log printed when it is not. Ad-hoc signing remains for workflow_dispatch runs, where there may be no secrets and nothing is published. The secrets are passed through env: instead of being expanded into the script text, and codesign drops --deep, which is meant for bundles and does nothing for a single binary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… page No release binary was ever executed before it was published, so a binary that built but could not start, as musl on Alpine once could not (#42), went straight out. Each build job now runs scripts/smoke-test.sh on its output after signing: natively on Linux x86_64, arm64 macOS and Windows, under Rosetta for x86_64 macOS when the runner has it, and in an alpine:3 container for x86_64 musl. aarch64 Linux is cross-compiled on an x86_64 runner and cannot be run there. Archives were made per runner with whatever tar was installed (bsdtar on macOS, Compress-Archive on Windows), stamped with build-time mtimes and the runner's owners, so no two runs produced the same bytes and no one could check a published archive against a rebuild. Build jobs now upload only the binary, and one package job assembles every archive with GNU tar (--sort=name, owner 0, --mtime from the release commit's time as SOURCE_DATE_EPOCH), gzip -n, and zip -X with pinned timestamps. Archive names and the binary's name inside are unchanged. The archives now carry the man page, generated once from the Linux x86_64 build since it comes from the CLI definition, and LICENSE, which MIT asks to travel with the binary. Completions are not shipped: treemd only has clap's dynamic completions, which call back into the installed binary, so there is no static file to package. SHA256SUMS was generated through `| grep -v ... || true`, so a failed sha256sum produced an empty or partial file and a green run. The script now fails on any error. The per-binary .sha256 files keep their meaning, the hash of the binary inside the archive, and the README now says which file covers what. The release build no longer restores a rust-cache: a tag run can only read caches from its own ref or the default branch, so it would rarely hit, and anything it did restore would be compiled into a signed, published binary. Two dead steps are gone: one wrote a .cargo/config.toml linker setting for aarch64-gnu that cross never reads, since it builds inside its own image with its own toolchain, and one computed an artifact name nothing used. Matrix values reach the scripts through env:. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing tied a downloaded archive to the workflow and commit that built it: the checksums sit beside the archives in the same release, so anyone able to replace one could replace the other. A release with Developer ID signing covers only the macOS binaries. The package job now runs actions/attest-build-provenance over every archive listed in SHA256SUMS on tag runs, producing a Sigstore-signed SLSA provenance statement per archive that `gh attestation verify` checks against this repository. id-token: write and attestations: write are granted to that job alone; the release job keeps only contents: write, and the build jobs, which hold the Apple secrets, get neither. The README shows how to check a download against SHA256SUMS and its attestation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scripts/build-all.sh had drifted from release.yml: it never built the musl targets, tried to build macOS and Windows targets from whatever single host ran it, which cannot work, and piped each build through `head -20`, so under `set -e` a failed build still went on to copy whatever binary was left in target/. The release workflow is the one place each target's build is defined; the README's build-from-source section now shows the plain cargo build and points there. The `install` and `ci` recipes printed emoji; they print plain text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SIGTERM, or SIGHUP from a closed terminal, killed treemd at the default disposition with raw mode, the alternate screen and mouse reporting still on, so the shell it returned to read mouse escapes as input. Both are now flags, through signal-hook (already in the tree via crossterm), and the event loop exits through its normal cleanup. Raw mode also makes Ctrl+Z an ordinary key, so it never suspended. Normal mode binds it to a new Suspend action, which releases the terminal the way launching the editor does, raises SIGTSTP, and takes the terminal back when the shell resumes it. The editor launch and suspend share release_terminal and reclaim_terminal. Interactive modes keep Ctrl+Z as undo. Checked under a pty: SIGTERM and SIGHUP each end the session with the alternate screen left, and Ctrl+Z leaves it before stopping. Resuming needs a job-control shell, which the harness is not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e in place One misspelled action under [keybindings] failed the parse of the entire config, so the theme and every other setting fell back to their defaults; one key that did not parse made Keybindings::from_config fail and dropped every custom binding. The warning went to stderr just before the alternate screen opened, where nobody saw it. The config is now parsed as a table with [keybindings] taken out and read entry by entry by KeybindingsConfig::from_toml_lenient, which keeps every valid binding and records a warning for each bad mode, key or action. Warnings are kept on Config and shown in the status bar at startup. Saving serialized the whole struct over the file, dropping comments, unknown keys, any binding that had not parsed, and writing out every default. It now edits the user's document with toml_edit, writing only values that differ from the config as loaded and keeping each changed value's trailing comment. A file that does not exist yet is still written whole. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the capability probe got no answer, treemd picked a protocol from environment variables, Kitty from KITTY_WINDOW_ID among them. That variable stays set in a tmux session started inside Kitty, and the guess was taken without a font size, which a protocol needs to place an image in its cells; ratatui-image deliberately keeps halfblocks in that case, and since 11.0.7 does its own environment fallback where it is safe to. The fallback and its two helpers are gone. The iTerm2 override stays, since iTerm2 answers the Kitty probe with a Kitty implementation ratatui-image cannot drive, but not inside tmux, zellij or screen, where LC_TERMINAL can outlive the terminal that set it. Native iTerm2 GIF playback uses the same check, which used to look at TMUX alone; it now also asks the picker, which knows about tmux even when the variable was not passed through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
builtins/collection.rs and builtins/string.rs held only a comment saying they were reserved for later. Every builtin lives in builtins/mod.rs, so the stubs were two more places to look for code that is not there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parser turned any `.name` that is also an element alias (`p`,
`a`, `image`, `code`, `headings`, ...) into a selector over the whole
document, wherever it appeared. So `{p: 1} | .p` printed every
paragraph, `.h | {a: .text} | .a` every link, and `.fm | .image` the
document's images, while `.fm.image` worked because the postfix form
always meant a property.
The selector now carries the name as written, and when the input is
not the document and has a key or property by that name, the
evaluator reads it instead. On the document the selector wins, so
`.h2` and `. | .code` are unchanged. That made the document's
`headings` property alias unreachable, so it is gone; `heading_count`
is the property.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An element selector applied to a heading value inside a pipe ignored the heading and selected from the whole document, so `.h2[0] | .code` returned every code block. It now selects within the heading's section, the same as `>>`. `>` and `>>` handled headings, code and links and silently returned nothing for images, tables, lists, paragraphs and blockquotes, which carry no source position. For those the section is parsed on its own; none of them holds a line number that a separate parse could get wrong, which is what went wrong for code before. The scoping logic moves out of eval_hierarchy into scoped_elements so the pipe and the operators share it, and the direct-child walk for headings is one pass instead of rescanning for intermediates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bare `.` was the identity only before the end of the query, `|`, `,`, `>`, `>>`, `)` or `]`; anything else had to be a name. So `select(. == "x")`, `. + 1` and `.[]` failed with "Expected identifier", although the docs use the identity in exactly these positions. A dot not followed by a name is now always the identity, and the postfix loop handles any brackets after it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 12, 2026
…t needs it A table cell edit replaced the document text but kept the parse made before it, and sections are cut from the text at those heading offsets. Every section after the edit was sliced at stale positions: a shorter cell panicked on the next frame and lost the edit, a longer one cut the next section short or split a character. Edits and undo now re-parse, keeping the outline unless the headings changed. hide_latex is on by default and ran on every document. It collapsed every run of spaces, flattening nested lists and indented code (and a checkbox toggle, mapping the displayed list back to the file, then toggled the wrong item), read `$5 and $10` as math, and stripped backslashed words from paths. It now runs only on documents that contain LaTeX, collapses spaces only between words, and reads `$` the way pandoc does. Detection also runs for the first document, so its hint shows. Frontmatter was stripped from every section, so a section opening with a `---` rule lost its text up to the next rule. Only the whole document is checked now, the closing line must be exactly `---` or `...`, and a byte order mark no longer hides it. The HashMap import is gated with the mermaid code that uses it, so the minimal build passes clippy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`--all-targets` skips doc tests, so the library examples were never run, and clippy only saw the full feature set, where an import used only by mermaid code looked used. `just gate` runs what has to pass before a commit, and `just ci` builds on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ls from startup Terminal::clear in ratatui-core 0.1.2 asks the terminal for the cursor position first, so in a terminal that does not answer, Ctrl+L, resuming after Ctrl+Z and returning from the editor ended the session with an error. Resizing to the current size clears and resets the buffers the same way without asking. While the editor runs the terminal is in its normal mode, and Ctrl+C sent SIGINT to treemd too, killing it with unsaved edits and the cursor hidden. SIGINT and SIGQUIT now set a flag that is dropped when the editor returns, and otherwise end the session cleanly. The cursor is shown whenever the terminal is handed back. Signal handling moves to its own module and is installed before raw mode, so a signal during startup (the image probe can take seconds) no longer leaves the terminal changed. The event loop cannot check the flag while the UI thread is busy, so after SIGTERM or SIGHUP a watcher restores the terminal and exits if the loop has not within two seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and reload `e` reloaded the file after the editor and discarded buffered edits, even when nothing changed; it now asks for a save or undo first, and refuses a document that is not a file. The file picker opened a file without the unsaved-changes prompt links get, leaving edits pending against the new file; it goes through the same prompt, with its own navigation variant since its path is absolute. Discarding cleared the edit list but left the edits in the text for the next save to write; it now undoes them. A change on disk skipped because of pending edits was never loaded and every later save was refused; it now runs once the edits are gone. A reload restores the selected heading by position when it still reads the same, so namesake sections stay apart. A file is no longer wrapped in an `Input` heading when it has none (or the heading sat behind a byte order mark). The wrapped text differed from the file, so every save was refused as a change made on disk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nline tables A config that could not be read (a Latin-1 comment, UTF-16, no read permission) was treated as no file yet, and the first setting change wrote defaults over it. Only a missing file starts from defaults now; any other read error is handled like a parse error, with a warning and no saves to that path. The comparison point for a save was fixed at load, so changing a setting and changing it back saved nothing the second time. It moves to what was written after each save. A section written as an inline table was taken for a missing table and replaced, losing its other keys; tables of either kind are edited in place. With no file yet, the first save wrote every default, pinning them; it now writes only what differs from the defaults. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ns, and scoping cost - -l -o json numbered blocks from the line after the heading while handing the parser a body with its leading blank lines trimmed, so start_line and end_line were short by those lines. The trimmed body's own offset and line are used now. - body_start looked only for `\n`, so a setext underline ended by a lone `\r` sent it past the next heading and slicing panicked. A lone `\r` is a line ending, as in CommonMark, and the body never starts past the next heading. - Sections ended at the next heading's `#`, so before `> # Q` or `- # Q` they kept a stray `>` or `-`. They end at its line's start, and -s uses the same end as everything else. - `.fm` came from parsing the whole document, which took a key/value block between two rules further down for frontmatter, and a byte order mark hid the real one and the block after it. Frontmatter is now only the block opening the document, found by one function the TUI filter shares, and a block with more than 64 YAML aliases is not parsed: a small one expanded to 5 GB on every query. - Comparisons across types returned "equal", so `<` was false and `<=` true for the same pair. They use jq's order, as sort does. - Section-scoped .code and .link counted lines from the top and scanned every block per heading; they binary-search instead, from 38 s to 0.13 s for `.h1 >> .code` on 20,000 headings. The property tests now generate lone carriage returns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mpression Stdin read a line whole before checking it, so a stream with no newlines grew without bound, and files were checked by their reported size, 0 for /dev/zero, FIFOs and process substitution. All reads now go through one reader that stops a byte past 100 MB, and the TUI's later reads (link follow, picker, reload) use it too. ureq's body limit counts bytes received before decompression, so a small gzip response expanding to gigabytes passed it. The decoded text is capped as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he UI thread - The image cache evicted its oldest entry on every insert, which in a section with more than 32 images was one of that section's own, so its first images were never drawn. Entries the section shows are not evicted. Cached images remember their file's mtime and length and are decoded again when it changes; failed decodes are remembered the same way instead of retried on every refresh. - Stills are refused over 64 megapixels from their header. The image crate's default was a 512 MB memory budget, which an 11000x11000 PNG of 470 KB stays under, decoded on the UI thread. - A GIF frame other than the next one is walked to on a thread of its own, cancelled by a newer seek, and handed back with its decoder so playback continues from it. Frame delays are read without decoding. - The mermaid worker skips queued jobs for a width other than the latest, and rendered diagrams are capped at 32. - Native iTerm2 playback is limited to 16 MB files. - In tmux, ratatui-image turns the pane's allow-passthrough on with any picker, halfblocks included, and leaves it on. The setting is noted before the first picker and restored on every exit path, which the normal exit now shares with panics and signals. A session with no attached terminal skips the probe, which nothing would answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ings stay apart - First, Last, PageUp and PageDown are shared by Normal, Help and Interactive mode and acted on the outline in all of them. They act on help, on the section's elements, and in an accepted search on the content now; with the outline focused, paging moves the selection. - Ctrl+C is bound in every mode: quit where q quits, cancel where text is typed or a question asked. - An accepted search had no reading keys and the wheel stepped through matches; j/k/d/u/g/G/q are bound there and the wheel scrolls. - Collapse state and bookmarks are keyed by heading text and the heading's ordinal among namesakes, and the todo filter by index, so repeated headings are no longer one heading. Anchor jumps search all headings, expand collapsed ancestors and resolve GitHub's `-N` suffixes; outline search ignores collapse. - Entering interactive mode, and paging in it, pick the element by the rows elements are drawn on, not by unwrapped line counts. - Per-element state is cleared on a section change. - y in interactive mode copies the selected element; the table copy shares its markdown with the table-only copy. - p in link mode refreshes the link list. - Back/Forward and an external reload lay the section out before restoring the scroll, which the reload path used to dodge by skipping the re-index. - The editor line for an element counts from the displayed text's first line. - A count is cleared after every action. - strip_latex restored code placeholders with a full replace per span, quadratic in a section full of inline code; one pass now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ller CLI fixes - The mode flags form one clap group, so two of them are an error instead of all but one being ignored by hidden precedence. --filter, -L, -n and -o require -l or --tree; without one they were ignored and the TUI opened. -l -o tree prints the tree, and -o's help describes what json prints in each mode. - Remote detection is one function, case-insensitive on the scheme, and a remote spec is never canonicalized, which could resolve it against a local `http:` directory and treat the page as a local file. - Extra files get the "only the first" warning in the TUI too, and the help no longer offers a file picker for several files, which never existed. A file that fails to read is named in the error. - Bidi overrides and isolates are escaped in CLI output to a terminal. - --setup-completions matches its own line exactly, honors ZDOTDIR and XDG_CONFIG_HOME, and creates fish's directory. treemd's own config honors XDG_CONFIG_HOME on macOS. - README: the --tree with --section tip never worked and is replaced; link mode's back/forward keys are Normal mode's; the interactive keys are listed as they are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…packaging in CI The gnu binaries were built natively on ubuntu-latest (aarch64 through cross's image) and linked against its glibc, 2.39, so they failed on Debian 12, Ubuntu 22.04 and RHEL 9. Both are built with cargo-zigbuild against glibc 2.28 now; a step fails the build if any symbol is newer, and the x86_64 binary is smoke-tested in debian:10. Verified locally: zigbuild builds both, and each one's newest symbol is GLIBC_2.28. Packaging asserts all seven targets are present, build artifacts are kept 7 days instead of 1, every job has a timeout, and Alpine is pinned to 3.22. A CI job packages one target twice and diffs the results, so the script is exercised before a tag rather than by one. The README's reproducibility claim is scoped to the same tool versions and names the glibc floor. release-preflight checks HEAD against origin/main; release-tag says when to publish to crates.io. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Query errors exit 3 to parse and 5 to run, as in jq, apart from -e's 1. Mismatched operands are an error rather than null or joined text; + merges objects, - removes array items, / splits strings, % works on whole numbers. A slice of an array is an array, an index past its end is null, and a missing key on front matter or an object is null. Keys with dashes read with .fm["k"], .fm."k" and .["k"]. A bare bracket filter runs to the ], so .code[c++] and .h[Getting Started] work. Prefix not takes a whole comparison, and a stage led by count reduces the stream. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g directory A piped document could load any image on disk by absolute path, and its links reached anywhere in the working directory's git repository. Both now stay in the working directory itself. security.trust_piped_documents in the config restores the old behavior. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…w either slug_style = "github" under [content] makes copied anchors, -l -o json ids and slugs, and the query language's .slug and slugify the way github.com does: _ is kept and repeated headings are numbered. treemd's own anchors stay the default. Following a link finds a heading by either kind of anchor, so #snake_case copied from GitHub works whatever the setting. Uses the github-slugger crate, a port of GitHub's slugger tested against its fixtures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A lone \r ends a line in CommonMark, but the parser read it as nothing, so in a file with classic Mac line endings every heading after the first ran into one line. parse_markdown turns each lone \r into \n, which keeps offsets indexing the file. Saving edits to such a file is refused with the reason, since writing it back would change every line ending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A read-only file was renamed over whenever its directory was writable, so it is refused. A hard-linked file is written in place, keeping its other names in step, and a dangling symlink is written through to the file it names rather than replaced. Where the directory takes no new files, a read-only directory or a file bind-mounted on its own, the file is written in place rather than not at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every image in a section was decoded on the UI thread before the section could be drawn, so a few large photos froze input for seconds. The image worker gets a decode thread: a section queues its images and draws "Loading image..." in their place until each arrives. Leaving the section skips decodes that have not started, and each image is asked for once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
doc_search_status, get_selected_interactive_element, mark_metrics_dirty, selected_command and show_save_width_confirmation had no callers. Done before splitting app.rs, so the move carries only live code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
app.rs had grown to 7,800 lines. Its methods move, unchanged, into src/tui/app/ by what they do: actions (key dispatch), outline, search, palette, navigation (links, anchors, history), file_picker, editing, elements (interactive mode) and images. Each module's tests move with it. mod.rs keeps the App struct, its constructor and what is shared. Nothing is rewritten: private methods become pub(super) so sibling modules can still call them, and moved types are re-exported from mod.rs at their old paths. git diff --color-moved shows the moves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its keys were matched in the event loop ahead of the keymap, so they could not be rebound and Ctrl+C did nothing there. ImageViewer is a keybinding mode with PreviousFrame, NextFrame and TogglePlayback actions, and current_keybinding_mode answers it first. Ctrl+C closes the viewer, as Esc and q do. Text typed while it is open no longer reaches a search behind it, and the count prefix reads the keybinding mode too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
show_help and show_theme_picker were two flags for the same thing, a popup in front of the current mode, and nothing kept both from being set. overlay: Option<Overlay> holds whichever is open, so only one can be, and the keybinding mode and the renderer each read it in one match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
show_search and outline_search_active described three states with four combinations, one meaningless. OutlineSearch is Off, Typing or Applied, and every reader matches on it. The four copies of "cycle through the applied search's matches" (Tab, Shift+Tab, n, N) are one method. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Typing, pasting and Backspace each re-derived which field was active from the mode flags, in three slightly different ways. TextInput names the field, text_input() reads it from the keybinding mode, and all three use it. Each field has one length limit, for typing and paste alike; a paste used to have none. The per-field push and pop helpers are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
|
Closing this in favor of a new PR with the same changes. The commits are rewritten without the co-author trailers; the code is identical. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings every dependency to latest, including turbovault-parser 3.2.0 and ratatui-image 11.1, and fixes what an audit of the TUI, image handling, CLI, query language and release pipeline turned up.
CHANGELOG.mdunder Unreleased has an entry per change with the mechanism behind it.Supersedes #91, #92 and #93. #92 doesn't render nested lists correctly on its own; the renderer fix it needs is in here.
TUI
The content pane is laid out once per section and width, and a frame draws only the visible rows. A scroll frame on a 21,000-line document takes about 1 ms where it used to re-render the section. Selection, images, diagrams and search highlights are placed from the same row map, so they no longer drift below wrapped text.
Saves keep the file's permissions, refuse to overwrite a change made on disk, and write atomically. Config saves edit the file in place and keep comments; a bad keybinding is skipped with a warning instead of discarding the file.
Table edits find the cell with pulldown-cmark, the parser that selected it, and undo restores it byte for byte.
Mermaid layout runs off the UI thread. GIFs stream in the viewer. Image files and GIF canvases are size-capped.
File links resolve within the document's git work tree. Fetched documents can't reach local files.
Ctrl+Z suspends, SIGTERM and SIGHUP restore the terminal, and
cat doc.md | treemdworks on macOS (crossterm'suse-dev-tty; kqueue rejects/dev/tty).A terminal that doesn't answer ratatui-image's capability probe no longer loses every key to the thread the probe leaves reading stdin. ratatui-image 12 fixes this upstream; the check can go once 12.0 is stable.
CLI
Piped stdin is read again when no file is named. Misuse exits 2. With stdout not a terminal, the document is printed. Control characters are escaped when printing to a terminal. Line numbering is linear, so a query that took 20 s on a large file takes 0.2 s.
-e/--exit-statusexits 1 when a query matches nothing.github:finds the README whatever it's named.Query language
25 fixes toward jq semantics,
.childrenand.parenton headings, and the guide and--query-helprewritten against what ships.Behavior changes to call out in the release notes:
X | countreturns one number for the stream.listentry==is type-strictconfig,input,keybindingsandtuimodules are hidden from the library docs (still public)CI and release
Actions pinned to commit SHAs, cargo-deny in place of cargo-audit, the musl binary built and run on Alpine for every PR, CI run on the tagged commit before a release builds, macOS signing and notarization failing closed, reproducible archives with the man page and license, and build provenance attestations.
Before merging: if branch protection names required checks, "Security audit" is now "cargo-deny", and "musl on Alpine" is new. Tag releases now need all six Apple signing secrets set.
Testing
fmt, clippy and rustdoc with
-D warnings, tests with all features and with none, MSRV 1.90, andcargo deny checkall pass locally. New coverage: TestBackend screen tests for the content pane and popups, property tests over generated documents, queries, wrapping and table edits (run at 20,000 cases each locally; they found the pipe-less header undo bug fixed here), 40 CLI and 41 query integration tests. The TUI was driven in a pty on macOS with a file argument and with piped input, images on and off.Not exercised: Ctrl+Z then
fgin an interactive shell, and image rendering in Kitty now that native Kitty GIF playback is gone.Follow-ups, not in this PR: splitting
src/tui/app.rs(6,800 lines) and folding its mode flags into one mode model, and picking up turbovault-parser's next release for the heading alt text fix (turbovault#78).🤖 Generated with Claude Code