Browse folders, edit the config in-app, and release 0.2.0 - #10
Merged
Conversation
The picker has only ever shown one thing: every markdown file in the project, flat, however deep the folders go. On a large vault that is a wall of `notes/2026/07/…` prefixes with no way to see the shape of it. `d` now swaps that list for the folders. Enter steps into the selected one and shows *its* folders, Backspace (or ←/h) comes back out — landing on the folder just left, not at the top — and every row carries the number of markdown files beneath it. The hierarchy is `FolderTree`, derived from the relative paths the scan already walked rather than a second trip to the disk: browsing costs nothing extra and inherits every skip rule, and a folder exists only if it leads to something to read, so Enter can never open an empty level. Browsing doubles as the way to narrow the list: `d` from inside a folder leaves the browser showing just that folder's files, named relative to it, and Esc widens it back. A leaf folder hands over its files instead of an empty list, and `/` still searches every folder — clearing the query puts the browser back. `MenuList` holds that state as a plain value, so the navigation is testable without a TTY, and rows now carry the entry index they stand for instead of `items.firstIndex(of: label)` — a lookup a narrowed, relative-labelled list would have got wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `?` overlay is where the file list's keys are documented, and `d` is the only way to find the browser — a four-word hint in the find box is the whole discovery path otherwise. It gets its own pane, and a test now holds every key in it, plus the aligned key column and a line length that fits the box (the pane is sized to its widest line, so one long line elides all of them first). `examples/` was flat, so `d` in the guided tour said "No folders here" for a feature the README documents. It now has a `folders/` page with a `nested/` subfolder, which is enough to walk: step in, step into a leaf, come back out. Also: CONTRIBUTING now states the convention this change leaned on — TUI state lives in a plain value (`MenuList`, `Pager.TabState`) apart from the loop that reads keys, because anything decided inside a `run()` loop needs a TTY to test. Tests: the folder keys are documented, folder rows stay distinguishable under `--no-color` (the trailing slash carries it when the teal is gone, with no escape left in plain output), and a short detail array leaves the column blank rather than running off the end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two follow-ups to the folder browser. **A breadcrumb row.** The header used to have the browsed folder appended to the path, which rewrote the one thing that says *which* termdown window this is. The path now stays exactly the folder termdown was opened on, and the row under it answers the other question — where you are inside it — as `docs › api`, relative to that folder, ancestors dimmed and the folder you are in picked out. Too long for the terminal loses its start, not its end: the deepest component is the answer the row exists to give. The row is present either way, so stepping into a folder never shifts the list below it — `headerLines` and the click-to-row arithmetic both assume a fixed header. **`file-list-view`.** Picks which list the picker opens on: `files` (the default, unchanged) or `folders`. Only an explicit `folders` switches, so a misspelling keeps the long-standing behaviour rather than opening a mode nobody asked for. Existing configs are offered the key once at `config-version: 4`, valued `files`. Also fixed, found while checking the header still shows the path: a path shorter than 12 columns was never shown at all. The floor is about having room to elide *into*, but it was applied to the path's own width, so `termdown ~/notes` named no folder at any terminal size. `TerminalMenu+Draw.swift` crossed the 400-line ceiling, so the row drawing — files, folders and the breadcrumb — moved to `TerminalMenu+Row.swift`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was a header row, which put it two columns left of the names it describes and a search box away from them. It now opens the list itself: just inside the separator, directly above `../` — the row that is the way back out of the folder the breadcrumb names — with a `❯` in the marker column the rows below use for their selection bar, so the crumbs line up with the names underneath. The row is carved out of the list rather than added to the chrome, so the header never moves. That costs one list row, and both the drawing and the loop's scroll/paging/click arithmetic now go through `MenuList.listRows(in:)` for it: a row counted in one and not the other would have put every click in a folder one row off, which is the sort of thing nobody notices until they click the wrong file. Verified in a pty — clicking the row under the breadcrumb selects `api/`, not `../`, and clicking it again steps in. The two drawing test files each crossed the 400-line ceiling on the way, so the browser's row tests moved to `TerminalMenuBrowseDrawTests`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things the screenshots showed. **The transition.** Stepping into a folder put the cursor on the first row, which is `../` — the way back out — so a second Enter undid the first. It lands on the first real entry now; coming *up* still lands on the folder just left. And `../` was being counted in the header, so a level holding one subfolder read "2 folders". `Row.isUp` marks it as navigation rather than content, and the count skips it. **A surface.** The breadcrumb draws on its own background band across the full row, one shade below the selection surface — the two sit a single column apart, and the brightest band in the list has to stay the row under the cursor. **A way out of a narrowed list.** The files of a folder are inside it just as its subfolders are, but only the browser had `../`, so `examples/folders/nested`'s files were a room with no door for anyone using the mouse. The narrowed list carries the row too, and `Backspace`/`←`/`h` work there, both landing in the browser at the level the folder was chosen from. Activating a folder row now switches lists as well as folders — without that, its `../` moved `cwd` and left the same files on screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It only appeared once you had descended a level, so pressing `d` gave you a list of folders with nothing saying you had switched lists — the banner arrived one Enter late, and arriving is itself a small jolt. The browser now always has the row. At the root it names the folder termdown was opened on (`❯ termdown`), which is also what anchors the crumbs below it: a level in reads `❯ termdown › docs`, so the row is the short form of the path in the header rather than a fragment of it. The name comes from the header's own path, so nothing new had to be threaded down to the picker. The unnarrowed file list keeps its first row: it is the whole project, which the header already names, and there is nothing to be relative to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first crumb repeated what the header two rows above it already says. `..` says the useful thing instead: everything above here is outside what termdown was pointed at — the same thing the `../` row directly below it means. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The config file has been editable only in an editor, which means leaving the app to change the thing you noticed while using it. `,` — from the file list and from the viewer — puts every scalar key on one screen: theme, width, no-color, mouse, mouse-select, mermaid and its charset, wide-emoji, file-list-view, bare-render. Space cycles a value, Enter toggles one or opens a list to pick from (27 themes are a list, not something to cycle through), and width takes a typed number where an empty entry means auto. Each change is written as it is made, so there is no save step and Esc cannot lose anything. The writer is the theme picker's, generalised: it rewrites the key's *last* active line — the one the parser honours — under any spelling the parser accepts, keeps the comment beside it, and appends only when the file has never set the key. The header names the file, because a settings screen must not leave that to be guessed. The keys a running session can honour (theme, colors, mermaid, emoji width) apply as you change them. The rest are read once at startup and marked `↻`; a key a project's `.termdown.yaml` also sets is marked `local`, since writing the global file for it would look like doing nothing. The footer note wraps rather than being cut, and drops its caveat rather than slicing a word when even two lines won't do. `ConfigSettings.editable` is the one table the view, the writer and the tests all read, and `ConfigSettingTests` writes and re-reads every row — a key wired into the view but not into `parseYAML` fails there instead of silently doing nothing. Two files had to give on the way: `PagerInput`'s key switch was already on the cyclomatic-complexity ceiling, so cases with bodies moved to `PagerKeys.swift`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The settings view routes theme, mermaid and mermaid-charset to `RenderContext.apply` by key. A key that fell through that switch would be written to the file and then do nothing until the next launch, while the view reported it as applied — so each one is now asserted through an actual render, and a key the context does not own is asserted to change nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README's short workflow block named `just test` and `just check` but not the end-to-end script or its Linux container, which is where a contributor looks first. The settings view's help pane also named a fixed config path, which `XDG_CONFIG_HOME` can move — it now points at the path the view itself prints. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eadlocking "Tests are slow on Linux" turned out to be three things, none of them slowness. **`termdown notes/ | cat` hung.** A directory argument opens the keyboard-driven file list, which needs a terminal to draw on *and* one to read keys from — but only the viewer checked. Through a pipe, termdown painted a frame into it and then blocked on a key that could never arrive. It now prints the markdown files it found and exits, the way a redirected `view` renders instead of paging. That is also what wedged the suite: it runs the binary with pipes, so one test blocked forever, the parallel runner stopped draining its other workers, and the whole run sat at 0% CPU looking busy rather than stuck. **The CLI test helper deadlocked on its own pipes.** It read the child's stdout to EOF while holding that pipe's write end open itself — `Process` closes the parent's copy on Darwin, swift-corelibs-foundation does not — so the read could never see EOF after the child exited. It also drained stderr only afterwards, which deadlocks on its own once a child outgrows a pipe buffer. Both ends are closed after spawn now, and the two streams are drained at once. **The rest is the environment.** On an aarch64 host under Docker Desktop the XCTest process blocks in poll partway through even a suite of pure string tests, however it is launched — serial runner, parallel runner, or the bundle directly. The same commit is green on the x86_64 Linux CI job, so the justfile and CONTRIBUTING now say to trust CI for Linux unit tests and use `just linux-integration` locally, which drives the binary rather than XCTest and does complete. Regression tests at both levels: a CLI case that runs a directory through pipes, and an integration check that does the same *under a deadline* — without one, this class of bug hangs the run instead of failing it, which is how it stayed hidden. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nding The `linux-build` recipe ran `swift test` in the container and could never finish. Two environmental hangs, now pinned down rather than worked around: * A suite runs every case and then hangs on **exit**. Line-buffer the `.xctest` bundle and it prints `Executed 13 tests, with 0 failures` and then goes silent forever — which is why the serial runner looked like it hung before the first test (its output was block-buffered and never flushed) and why pure string suites hung as readily as anything else. * Every test that spawns the binary through Foundation's `Process` hangs outright, including the one that only asks for `--version`. Both reproduce on aarch64 *and* on emulated x86_64, so it is Docker Desktop's Linux VM rather than the architecture. The code is fine: the same commits are green on the x86_64 Linux CI job, which runs this very image's `swift test` on GitHub's runners. So `linux-build` now builds — worth keeping, it catches `#if canImport` and corelibs gaps — and no longer runs a test command that is guaranteed to wedge. Linux units come from CI; the local Linux check is `just linux-integration`, which drives the binary from bash and touches neither XCTest nor `Process`, and passes 28/28. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The release workflow verifies the pushed tag against `appVersion`, so the bump and the changelog date land together, before the tag exists. 0.2.0 rather than 0.1.11: this carries the folder browser, a settings view for the config file, a new config key and `XDG_CONFIG_HOME` support — more than a point release's worth after eleven of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Summary
Three features and two hangs, plus the 0.2.0 release bump.
d). The picker only ever showed every Markdown file, flat — a wall ofnotes/2026/07/…prefixes on a large vault.dswaps that for the folders, one level at a time:Entersteps in,Backspace/←/hcomes back out landing on the folder you just left, and each row counts the Markdown files beneath it. Only folders that lead to one are listed, so you can never step into a dead end.dagain leaves the browser with the list narrowed to that folder, named relative to it;Escwidens it back. A breadcrumb row (❯ .. › docs › api) opens the list on its own surface, directly above../— which the narrowed file list carries too, so a folder's files are never a room with no door. The hierarchy is derived from the paths the scan already walked, so browsing costs no extra work and inherits every skip rule.,), from the file list and the viewer. Every scalar config key on one screen:Spacecycles a value,Entertoggles one or opens a list to pick from,widthtakes a typed number. Each change is written as it is made — the theme picker's writer, generalised: it rewrites the key's last active line (the one the parser honours), under any spelling the parser accepts, and keeps the comment beside it. Rows read only at startup are marked↻; keys a project's.termdown.yamloverrides are markedlocal.file-list-viewpicks which list the picker opens on, andXDG_CONFIG_HOMEis now honoured for the config's location.Fixed
termdown notes/ | cathung. The file list needs a terminal to draw on and one to read keys from, but only the viewer checked; through a pipe termdown painted a frame into it and blocked on a key that could never arrive. It lists what it found and exits now, the way a redirectedviewrenders instead of paging.../;../is no longer counted as a folder; a picker-header path shorter than 12 columns is shown again (the elision floor was applied to the path's own width, sotermdown ~/notesnamed no folder at any size).Processcloses that copy on Darwin, corelibs-foundation does not) and drained stderr only afterwards.Tests
712 unit tests (+~100) and a new 28-check integration suite (
Tests/Integration/cli.sh,just integration) that drives the built binary: rendering, stdin, exit codes, and the config file being created, migrated, overridden by a project-local file, and honoured forwidth/no-color/mermaid. It runs under a deadline, so a hang fails instead of masquerading as slowness, and CI runs it on both platforms. ADockerfileruns the same checks on Linux (just linux-integration) — 28/28 there.just linux-buildnow builds only: under Docker Desktop's Linux VM the XCTest process cannot finish (a suite passes its cases then hangs on exit; everyProcess-spawning test hangs outright), on aarch64 and emulated x86_64, while the same commits are green on the x86_64 Linux CI job. The justfile and CONTRIBUTING record that so nobody re-derives it.Docs
README features + keybindings + config keys + Configuration section, a
Foldersand a settings pane in the?help,examples/folders/in the guided tour, CONTRIBUTING conventions, and the CHANGELOG.🤖 Generated with Claude Code