Skip to content

Browse folders, edit the config in-app, and release 0.2.0 - #10

Merged
dsaad68 merged 13 commits into
mainfrom
feat/folder-browse
Aug 12, 2026
Merged

Browse folders, edit the config in-app, and release 0.2.0#10
dsaad68 merged 13 commits into
mainfrom
feat/folder-browse

Conversation

@dsaad68

@dsaad68 dsaad68 commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Summary

Three features and two hangs, plus the 0.2.0 release bump.

  • Folder browsing in the file list (d). The picker only ever showed every Markdown file, flat — a wall of notes/2026/07/… prefixes on a large vault. d swaps that for the folders, one level at a time: Enter steps in, Backspace//h comes 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. d again leaves the browser with the list narrowed to that folder, named relative to it; Esc widens 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.
  • A settings view (,), from the file list and the viewer. Every scalar config key on one screen: Space cycles a value, Enter toggles one or opens a list to pick from, width takes 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.yaml overrides are marked local.
  • file-list-view picks which list the picker opens on, and XDG_CONFIG_HOME is now honoured for the config's location.

Fixed

  • termdown notes/ | cat hung. 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 redirected view renders instead of paging.
  • Stepping into a folder no longer lands on ../; ../ 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, so termdown ~/notes named no folder at any size).
  • The CLI tests no longer deadlock on their own pipes — they held a pipe's write end open while reading it for EOF (Process closes 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 for width/no-color/mermaid. It runs under a deadline, so a hang fails instead of masquerading as slowness, and CI runs it on both platforms. A Dockerfile runs the same checks on Linux (just linux-integration) — 28/28 there.

just linux-build now builds only: under Docker Desktop's Linux VM the XCTest process cannot finish (a suite passes its cases then hangs on exit; every Process-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 Folders and a settings pane in the ? help, examples/folders/ in the guided tour, CONTRIBUTING conventions, and the CHANGELOG.

🤖 Generated with Claude Code

dsaad68 and others added 13 commits August 10, 2026 21:41
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>
@dsaad68
dsaad68 merged commit 4bcf01c into main Aug 12, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant