diff --git a/.dagger/main.go b/.dagger/main.go index 12cbad0..d0ab4d4 100644 --- a/.dagger/main.go +++ b/.dagger/main.go @@ -7,7 +7,6 @@ import ( "fmt" "strconv" "strings" - "sync" "dagger/tdx-ci/internal/dagger" "dagger/tdx-ci/pipeline" @@ -24,8 +23,8 @@ type TdxCi struct{} // Ci runs every portable validation used by GitHub CI. func (m *TdxCi) Ci(ctx context.Context, source *dagger.Directory) (string, error) { - // golangci-lint has the highest peak memory use, so keep it out of the - // concurrent group to make local runs reliable on smaller Docker VMs. + // Keep memory-intensive checks sequential so a developer's small Docker + // VM does not run multiple Go/Rust compilers alongside terminal contracts. if _, err := m.Lint(ctx, source); err != nil { return "", fmt.Errorf("lint: %w", err) } @@ -55,26 +54,10 @@ func (m *TdxCi) Ci(ctx context.Context, source *dagger.Directory) (string, error }}, } - type result struct { - name string - err error - } - results := make(chan result, len(checks)) - var wg sync.WaitGroup - for _, check := range checks { - wg.Add(1) - go func() { - defer wg.Done() - results <- result{name: check.name, err: check.run()} - }() - } - wg.Wait() - close(results) - var failures []error - for result := range results { - if result.err != nil { - failures = append(failures, fmt.Errorf("%s: %w", result.name, result.err)) + for _, check := range checks { + if err := check.run(); err != nil { + failures = append(failures, fmt.Errorf("%s: %w", check.name, err)) } } if err := errors.Join(failures...); err != nil { @@ -282,8 +265,10 @@ func (m *TdxCi) usageCheck(ctx context.Context, source *dagger.Directory) (strin return m.goBase(source). WithExec([]string{"go", "build", "-o", "/tmp/tdx-usage-binary", "./cmd/tdx"}). WithExec([]string{"go", "run", "./cmd/tdx-usage", "-driver", "cli", "-binary", "/tmp/tdx-usage-binary", "-sessions", "1", "-steps", "40"}). + WithExec([]string{"go", "run", "./cmd/tdx-usage", "-driver", "structural", "-sessions", "1", "-steps", "60"}). WithExec([]string{"sh", "-ec", "apt-get update -qq && apt-get install -y --no-install-recommends python3"}). WithExec([]string{"python3", "scripts/usage-pty.py", "--binary", "/tmp/tdx-usage-binary"}). WithExec([]string{"bash", "scripts/test_release_notes.sh"}). + WithExec([]string{"python3", "scripts/test_install.py"}). Stdout(ctx) } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbbf529..0ae3fe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,60 +11,13 @@ permissions: contents: read jobs: - portable: - name: Portable - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v7.0.1 - - - uses: jdx/mise-action@v4.3.0 - - name: Run portable CI - run: mise run ci - - native: - name: Native (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [macos-latest, windows-latest] - - steps: - - name: Checkout code - uses: actions/checkout@v7.0.1 - - - name: Set up Go - uses: actions/setup-go@v7.0.0 - with: - go-version-file: go.mod - cache-dependency-path: go.sum - - - name: Build native binary - shell: bash - run: | - if [ "$RUNNER_OS" = "Windows" ]; then - go build -v -o tdx.exe ./cmd/tdx - else - go build -v -o tdx ./cmd/tdx - fi - - - name: Run native filesystem tests - run: go test -v ./internal/markdown ./internal/versioning - - - name: Test native binary execution - shell: bash - run: | - if [ "$RUNNER_OS" = "Windows" ]; then - ./tdx.exe --version - else - ./tdx --version - fi + validate: + uses: ./.github/workflows/validate.yml coverage-badge: name: Coverage badge if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: portable + needs: validate runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39732b7..2a08f7a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,15 +9,22 @@ permissions: contents: read jobs: + validate: + uses: ./.github/workflows/validate.yml + release: + needs: validate runs-on: ubuntu-latest permissions: contents: write + id-token: write + attestations: write steps: - name: Checkout code uses: actions/checkout@v7.0.1 with: + ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false @@ -36,6 +43,21 @@ jobs: version: "0.21.9" call: release-artifacts --source=. export --path=release + - name: Calculate release checksums + working-directory: release + run: sha256sum tdx-* > SHA256SUMS + + - name: Attest release artifacts and checksum manifest + id: provenance + uses: actions/attest@v4 + with: + subject-path: release/* + + - name: Include provenance bundle in release + env: + BUNDLE_PATH: ${{ steps.provenance.outputs.bundle-path }} + run: cp "$BUNDLE_PATH" release/provenance.json + - name: Set up Python uses: actions/setup-python@v7.0.0 with: diff --git a/.github/workflows/usage-campaign.yml b/.github/workflows/usage-campaign.yml new file mode 100644 index 0000000..963c9ee --- /dev/null +++ b/.github/workflows/usage-campaign.yml @@ -0,0 +1,50 @@ +name: Scheduled usage campaign + +on: + schedule: + - cron: "23 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + replay: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@v7.0.0 + with: + go-version-file: go.mod + - name: Record replay seed and commit + run: | + mkdir -p dist/usage/campaign + seed=$(date -u +%Y%m%d) + echo "TDX_CAMPAIGN_SEED=$seed" >> "$GITHUB_ENV" + printf 'seed=%s\ncommit=%s\n' "$seed" "$GITHUB_SHA" > dist/usage/campaign/environment.txt + go version >> dist/usage/campaign/environment.txt + - name: Replay rotating TUI sessions + run: go run ./cmd/tdx-usage -seed "$TDX_CAMPAIGN_SEED" -sessions 10 -steps 1800 -output dist/usage/campaign/tui + - name: Replay rotating executable sessions + if: always() + run: | + go build -o /tmp/tdx-campaign ./cmd/tdx + go run ./cmd/tdx-usage -driver cli -binary /tmp/tdx-campaign -seed "$TDX_CAMPAIGN_SEED" -sessions 2 -steps 200 -output dist/usage/campaign/cli + - name: Replay rotating structural edits + if: always() + run: go run ./cmd/tdx-usage -driver structural -seed "$TDX_CAMPAIGN_SEED" -sessions 8 -steps 500 -output dist/usage/campaign/structural + - name: Capture bounded diagnostic profiles + if: always() + run: | + mkdir -p dist/usage/campaign/profile + go test ./internal/usage -run TestSeededSessions -count=1 -cpuprofile=dist/usage/campaign/profile/cpu.pprof -memprofile=dist/usage/campaign/profile/heap.pprof -o dist/usage/campaign/profile/usage.test + - name: Retain replay traces, reduced failures and profiles + if: always() + uses: actions/upload-artifact@v7.0.1 + with: + name: usage-campaign-${{ github.run_id }}-${{ github.run_attempt }} + path: dist/usage/campaign + retention-days: 30 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..e871e65 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,75 @@ +name: Validate commit + +on: + workflow_call: + +permissions: + contents: read + +jobs: + portable: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: jdx/mise-action@v4.3.0 + - name: Validate the checked-out commit + run: mise run ci + + native: + name: Native (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-go@v7.0.0 + with: + go-version-file: go.mod + - uses: actions/setup-python@v7.0.0 + with: + python-version: "3.13" + - name: Run native CLI and filesystem tests + run: go test -v ./cmd/tdx ./internal/cmd ./internal/markdown ./internal/versioning + - name: Build and replay native executable + shell: bash + run: | + binary=tdx + if [ "$RUNNER_OS" = "Windows" ]; then binary=tdx.exe; fi + go build -o "$binary" ./cmd/tdx + go run ./cmd/tdx-usage -driver cli -binary "$binary" -sessions 1 -steps 40 -output dist/usage/cli + - name: Run POSIX terminal contracts + if: runner.os != 'Windows' + run: python scripts/usage-pty.py --binary ./tdx + - name: Install POSIX visual terminal test dependencies + if: runner.os != 'Windows' + run: python -m pip install pyte==0.8.2 wcwidth==0.8.3 + - name: Run inline terminal screen and scrollback regressions + if: runner.os != 'Windows' + run: python scripts/visual-pty.py --binary ./tdx + - name: Install Windows terminal test dependency + if: runner.os == 'Windows' + run: python -m pip install pywinpty==3.0.2 + - name: Run Windows ConPTY smoke contracts + if: runner.os == 'Windows' + run: python scripts/usage-conpty.py --binary ./tdx.exe + - name: Verify installer failures offline + if: runner.os != 'Windows' + run: python scripts/test_install.py + - name: Retain native replay and terminal diagnostics + if: always() + uses: actions/upload-artifact@v7.0.1 + with: + name: native-${{ matrix.os }}-${{ github.sha }} + path: dist/usage + if-no-files-found: ignore + retention-days: 14 diff --git a/README.md b/README.md index 436a8c9..6e0a4a4 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ A fast, single-binary CLI todo manager focused on developer experience. Features - ⌨️ **Vim-style navigation** - `j/k`, relative jumps (`5j`), number keys - 🖥️ **Interactive TUI** - Toggle, create, edit, delete, undo, move, copy - 🎯 **Command Palette** - Helix-style `:` commands with fuzzy search -- 📋 **Read-Only Mode** - Prevent auto-save, check/uncheck all, filter done +- 📋 **Manual Save** - Reuse checklists without automatic writes; legacy read-only options remain supported +- 🔖 **Saved Views** - Save per-file filters and section focus; optionally restore the last view - 🔧 **Scriptable** - Filtered JSON output and `list`, `add`, `toggle`, `edit`, `delete` commands - 🔄 **Smart Conflict Handling** - Atomic saves, external-change detection, visual conflict diffs - 🕘 **Version History** - Automatic snapshots with visual diffs and safe restore @@ -43,7 +44,7 @@ Press **s** to open the section overview. It lists every Markdown heading, inclu | a | Show all tasks and clear folds | | Esc | Return to the task list | -While focused, **n** adds a task after the selection; in an empty section it creates that section's first task. **N** adds to the focused section's own task list. Press **S** to return to all sections. Tag, priority, due-date, completed-task filters, and search respect the current section. Focus and folds last for the current session and reset when a file is reloaded, headings change, or an edit is undone. **u** undoes up to 100 edits during the session. +While focused, **n** adds a task after the selection; in an empty section it creates that section's first task. **N** adds to the focused section's own task list. Press **S** to return to all sections. Tag, priority, due-date, completed-task filters, and search respect the current section. Focus and folds last for the current session and reset when a file is reloaded, headings change, or an edit is undone. Save a named view to recall them later. **u** undoes up to 100 edits during the session. Section editing uses the same guarded saves and version history as task editing. Read-only files support section browsing and focus without permitting heading edits. @@ -61,6 +62,21 @@ Section editing uses the same guarded saves and version history as task editing. - Use mise for reproducible development tools and tasks. `mise tasks` lists available commands; `mise run check` runs local validation. - Installation defaults to `~/.local/bin`; set `TDX_INSTALL_DIR` to choose another directory. Existing todo files and global configuration remain compatible. +## Saved views + +Set your tag (`t`), priority (`p`), due-date (`D`), completed-task and section filters, then run `:save-view`. Enter a name such as `Today` or `Backend`. Use `:views` to select a saved view, `:delete-view` to remove one, and `:clear-view` to show all tasks and stop restoring that view. Replacing or deleting a view requires confirmation; `Esc` cancels. + +The status bar shows the active view and marks it modified if you change its filters. Views include heading visibility, section focus and folds. Section references follow heading ancestry and repeated-title occurrence; removed or renamed headings are skipped when loading a view. Task text and Markdown files are never changed by saving a view. + +Views are stored per canonical file path in the configuration directory, so opening a file through a symlink shares its views. To reopen the last saved/selected view automatically, opt in through `~/.config/tdx/config.toml`: + +```toml +[views] +restore = true +``` + +Without this setting, views are loaded only when requested. Changes to an active view are not saved until `:save-view` is used again. + ## Scripting and editor integrations Query a project without opening the TUI or writing history: @@ -71,11 +87,16 @@ tdx tasks.md list --status done tdx tasks.md list --json | jq -r '.[] | select(.priority == 1) | .text' tdx add -- --read-only # Add literal flag-like text tdx --read-only tasks.md list # Reads work; CLI writes are rejected +# Query a heading and its descendants without renumbering tasks +tdx tasks.md list --json --section Backend --priority 1 --due week +# Explicit completion is safe to retry +tdx tasks.md done 2 +tdx tasks.md undone 2 ``` `--file` (or `-f`) accepts any filename, including paths with spaces or without a `.md` extension. The positional `tdx tasks.md …` syntax still works. Options may appear before or after the command; use `--` before task text that starts with a dash. Shell quoting is preserved as received, including intentional quote characters in task text. -`list` supports `--status all|open|done` (default `all`) and exact, case-sensitive `--tag` filters, with or without the leading `#`. Repeat `--tag` to require every tag. Filters apply to both text and JSON output. An empty JSON result is `[]`, including when the todo file does not exist; listing does not create that file or require a writable history directory. +`list` supports `--status all|open|done` (default `all`) and exact, case-sensitive `--tag` filters, with or without the leading `#`. Repeat `--tag` to require every tag. Filters apply to both text and JSON output. `--priority N` matches an exact priority (`0` means unset). `--due` accepts `all` (has a due date), `none`, `overdue`, `today`, `week` (today through seven days ahead), or an exact `YYYY-MM-DD` date. Relative dates use the local calendar. `--section "Heading title"` matches the exact Markdown heading title and includes its subsections; repeated titles select all matching sections. These filters compose with status and tags. An empty JSON result is `[]`, including when the todo file does not exist; listing does not create that file or require a writable history directory. Each JSON task contains: @@ -87,7 +108,41 @@ Each JSON task contains: | `tags`, `priority` | Tag array (empty is `[]`); priority `0` means unset | | `due_date` | `YYYY-MM-DD` or `null` | -Indexes are positions, **not persistent IDs**: re-query after inserting, deleting, reordering, or externally editing tasks before using an index in a write command. JSON goes only to stdout; errors go to stderr with exit code 1. Successful commands exit 0. The schema remains subject to change during pre-1.0 development. +Indexes are positions, **not persistent IDs**: re-query after inserting, deleting, reordering, or externally editing tasks before using an index in a write command. JSON goes only to stdout; errors go to stderr with exit code 1. Successful commands exit 0. The existing array shape and field meanings are retained. Consumers should ignore additional fields. Removing or changing a field's type or meaning requires an explicitly versioned contract and migration guidance, including during pre-1.0 development. + +### Guard scripts against intervening edits + +Use `list --with-revision` for one consistent snapshot of both the query result and the document revision: + +```json +{"schema_version": 1, "revision": "sha256:<64 lowercase hex digits>", "tasks": []} +``` + +`--with-revision` implies JSON and changes the top-level shape to this envelope; ordinary `--json` continues to return an array. The revision covers the entire file, including frontmatter and line endings. A nonexistent file returns `missing`, which differs from an existing empty file. `tdx tasks.md revision` prints only the revision token and does not create history or configuration files. + +```bash +snapshot=$(tdx tasks.md list --with-revision --status open --tag backend) +revision=$(printf '%s' "$snapshot" | jq -r .revision) +index=$(printf '%s' "$snapshot" | jq -r '.tasks[0].index // empty') +if [ -n "$index" ]; then + tdx tasks.md done "$index" --if-revision "$revision" +fi +``` + +All task mutation commands support `--if-revision`. A stale revision fails before changing the document; query again and reconsider the selected task. Changes that arrive after loading are still caught by the normal locked save. `done` and `undone` leave an already-correct file unchanged, but a supplied stale revision still fails. Revision tokens detect intervening content changes; they are not persistent task identities or authentication tokens. + +### Shell completion + +```bash +# Bash: add to ~/.bashrc +source <(tdx completion bash) +# Zsh: after autoload -Uz compinit && compinit +source <(tdx completion zsh) +# Fish: create its completions directory first if needed +tdx completion fish > ~/.config/fish/completions/tdx.fish +``` + +Completion generation is static and never reads your tasks or history. ## Installation @@ -105,6 +160,10 @@ To upgrade an existing Homebrew installation, run `brew update` and `brew upgrad curl -fsSL https://niklas-heer.github.io/tdx/install.sh | bash ``` +The installer verifies the selected executable against the release's `SHA256SUMS` before replacing an existing installation. It requires `sha256sum` or `shasum`, uses a temporary download directory, and installs to `~/.local/bin` by default. Set `TDX_INSTALL_DIR` or `TDX_VERSION` to choose the destination or release. Releases predating checksum manifests cannot be installed with the verified script; use the Homebrew formula or download that historical binary directly from its release page. The script never silently bypasses verification. + +New releases also publish a signed provenance bundle. With GitHub CLI installed, verify a downloaded executable using `gh attestation verify --repo niklas-heer/tdx`. Checksum verification detects mismatched bytes; attestation verification separately checks the build's provenance. + The installer writes to `~/.local/bin` by default. Add that directory to `PATH`, or set `TDX_INSTALL_DIR` when running the script. To pin a release, use `curl -fsSL https://niklas-heer.github.io/tdx/install.sh | TDX_VERSION=0.14.0 bash`. Check `command -v tdx` if an older installation exists elsewhere. ### Download Binary @@ -195,7 +254,9 @@ Press `:` to open the command palette with fuzzy search. Available commands: | `filter-today` | Toggle showing only todos due today | | `filter-week` | Toggle showing only todos due this week | | `clear-done` | Delete all completed todos | -| `read-only` | Toggle read-only mode (changes not saved) | +| `manual-save` | Toggle automatic saving (`read-only` remains an alias) | +| `save-view` / `views` | Save the current filters / open a saved view | +| `delete-view` / `clear-view` | Delete a saved view / clear filters and active view | | `save` | Save current state to file | | `force-save` | Force save even if file was modified externally | | `reload` | Reload file from disk (discards unsaved changes) | @@ -207,15 +268,15 @@ Press `:` to open the command palette with fuzzy search. Available commands: | `set-max-visible` | Set max visible items for this session | | `show-headings` | Toggle displaying markdown headings between tasks | -**Read-Only Mode:** +**Manual Save (compatible with read-only mode):** -Start tdx with `-r` or `--read-only` flag for workflows where you don't want changes saved automatically: +Start tdx with `--manual-save`, `-r`, or `--read-only` for workflows where you want temporary checklist edits without automatic saving: ```bash tdx -r checklist.md ``` -Use `:save` to manually save when ready, or `:read-only` to turn auto-save back on. +The status bar displays `MANUAL SAVE`. Use `:save` to save when ready, or `:manual-save` (legacy alias `:read-only`) to enable automatic saving. This mode permits temporary task edits; CLI mutation commands are rejected when either flag is present. The preferred global setting is `[defaults] manual_save = true`; an explicitly set `manual_save` overrides the legacy `read_only` setting. **Vim-style navigation:** - `5j` - Move down 5 lines @@ -348,6 +409,10 @@ tdx ~/notes/work.md list tdx project.md add "Task" ``` +### Clipboard + +Copy uses `pbcopy` on macOS, `wl-copy`/`wl-paste` in Wayland sessions, `xclip` or `xsel` in X11 sessions, and PowerShell on Windows. Clipboard commands run outside the input loop with a bounded timeout. Successful copies show confirmation; missing helpers, desktop connection failures and timeouts show an error. Terminal paste remains available in input fields, including over SSH; a headless remote process may have no desktop clipboard to copy into. + ### Recent Files tdx automatically tracks recently opened files and restores your cursor position when you reopen them. @@ -474,10 +539,12 @@ You only need to include the settings you want to change from the defaults. | `[defaults]` | `max_visible` | number | 0 | Limit visible tasks (0 = unlimited) | | `[defaults]` | `word_wrap` | boolean | true | Enable word wrapping for long lines | | `[defaults]` | `show_headings` | boolean | false | Show markdown headings between tasks | -| `[defaults]` | `read_only` | boolean | false | Disable automatic TUI saves; reject CLI mutations | +| `[defaults]` | `manual_save` | boolean | unset | Preferred setting; explicitly overrides `read_only` | +| `[defaults]` | `read_only` | boolean | false | Legacy setting: disable automatic TUI saves; reject CLI mutations | | `[defaults]` | `filter_done` | boolean | false | Hide completed tasks by default | | `[recent]` | `max_files` | number | 20 | Maximum recent files to track | | `[versioning]` | `max_versions` | number | 100 | Versions retained per file (0 = unlimited) | +| `[views]` | `restore` | boolean | false | Restore the last saved/selected view when opening its file | #### Per-File Configuration @@ -545,7 +612,7 @@ tdx --show-headings todo.md [Goldmark](https://github.com/yuin/goldmark) identifies tasks and headings using task-list, table and strikethrough extensions. The CLI and TUI apply shared document actions through `internal/editor`; each application instance owns its configuration, styles and persistence callbacks. -A checkbox edit uses the AST's exact source location and updates cached checked state without extracting all task metadata again. While a document has only checkbox changes, serialization retains its source bytes. Structural changes invalidate that source path and use the custom Markdown serializer. This distinction matters: tdx preserves checkbox edits exactly, but does not promise byte-for-byte preservation of arbitrary Markdown after structural editing. +A checkbox edit uses the AST's exact source location and updates cached checked state without extracting all task metadata again. Text and structural edits patch affected source ranges, validate the resulting task structure, and reparse before committing. Unrelated source bytes—including reference definitions, HTML, tables and line endings—stay intact. Moving or sorting a parent carries its subtree and attached body. Unsupported boundaries, including structural rearrangement inside blockquotes or tab-indented continuations, return an error instead of rewriting the document. Checkbox toggles and supported localized edits remain available where their source ranges can be identified. Undo retains up to 100 committed snapshots, with provisional input stored separately until confirmed. File saves compare the loaded disk revision, acquire a file lock and atomically replace the target. Watcher reloads defer during pending input so a concurrent editor's changes cannot silently become the revision used by a later save. Explicit conflict recovery and version history remain available in the TUI. @@ -553,7 +620,7 @@ Undo retains up to 100 committed snapshots, with provisional input stored separa Checkbox nodes and headings are cached. Search is debounced for 50 ms, while immediate Enter and navigation use the current query. The [measured 100-hour simulated campaign](experiments/usage/README.md) covers 36,000 actions, real disk saves, conflicts, cancellation and reload, with separate executable and PTY contracts. -The report includes loaded-document checkbox benchmarks, complete action latency, allocation profiles, exact source revisions and reproduction commands. These measurements are workload-specific; they do not establish an application-wide comparison with other tools. Simulated hours are accelerated actions rather than wall-clock endurance. See the [Rust evaluation](experiments/rust-eval/README.md) for the narrower parser experiment and its limitations. +The report includes loaded-document checkbox benchmarks, complete action latency, allocation profiles, exact source revisions and reproduction commands. `mise run test:usage-structural` exercises rich documents and nested structural actions; failed campaigns retain a replayable prefix, reduced trace and reduction diagnostic. Weekly CI rotates recorded seeds and retains replay/profile artifacts. These measurements are workload-specific; they do not establish an application-wide comparison with other tools. Simulated hours are accelerated actions rather than wall-clock endurance. See the [Rust evaluation](experiments/rust-eval/README.md) for the narrower parser experiment and its limitations. ### Project Structure diff --git a/RELEASE.md b/RELEASE.md index caa58ff..28d240d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -12,7 +12,7 @@ mise run release The release task verifies a clean checkout of `main`, checks that local and remote commits match, runs local validation, and asks before pushing the single version tag. It uses the version already configured in `tdx.toml` and does not increment it automatically. -The tag workflow checks the version, builds five platform binaries with Dagger, uses the reviewed release notes (or generates notes when none exist), creates the GitHub release, and attempts to update the Homebrew tap. The application and Dagger module use the compatible toolchains documented in README.md. +The tag workflow first validates the exact tagged commit through the shared portable and native matrix, independently of the local helper. Only after all checks succeed does it verify the version, build five platform binaries with Dagger, publish SHA256SUMS and signed build provenance, use the reviewed release notes (or generate notes when none exist), create the GitHub release, and attempt to update the Homebrew tap. The application and Dagger module use the compatible toolchains documented in README.md. To inspect release artifacts without publishing: @@ -21,11 +21,11 @@ mise run release-artifacts ls dist/ ``` -For a specific existing release, users can set `TDX_VERSION=0.14.0` when running the install script. `TDX_INSTALL_DIR` overrides its default `~/.local/bin` destination. +For a release containing SHA256SUMS, users can set `TDX_VERSION` when running the install script. The script fails closed if its checksum manifest is missing or invalid; historical releases such as v0.14.0 require Homebrew or a direct release-asset download. `TDX_INSTALL_DIR` overrides the default `~/.local/bin` destination. `gh attestation verify --repo niklas-heer/tdx` verifies release provenance separately. The website is GitHub Pages, published from `main:/docs`; merging documentation updates triggers deployment. Verify the Pages build, the live release link and installer after publishing. Keep `docs/install.sh` byte-identical to `scripts/install.sh`. -Before calling a release complete, verify that GitHub marks it as the latest release, all five assets are present, downloaded binaries report the intended version, and the Homebrew formula references the new version and checksums. Read the generated notes before announcing the release. The reviewed-note path requires no OpenRouter credentials; the legacy generated-note path does. +Before calling a release complete, verify that GitHub marks it as the latest release, all five binaries plus SHA256SUMS and provenance.json are present, downloaded binaries report the intended version, and the Homebrew formula references the new version and checksums. Read the generated notes before announcing the release. The reviewed-note path requires no OpenRouter credentials; the legacy generated-note path does. Verify release-note selection offline with `mise run test:release-notes` (also included in `mise run check` and portable CI). diff --git a/cmd/tdx-usage/main.go b/cmd/tdx-usage/main.go index 32e8bef..e75fb53 100644 --- a/cmd/tdx-usage/main.go +++ b/cmd/tdx-usage/main.go @@ -22,7 +22,7 @@ func run(args []string) error { sessions := flags.Int("sessions", 20, "number of independent sessions") steps := flags.Int("steps", 1800, "actions per session; each represents 10 simulated seconds") seed := flags.Uint64("seed", 100, "first deterministic seed") - driver := flags.String("driver", "tui", "tui or cli") + driver := flags.String("driver", "tui", "tui, cli or structural") binary := flags.String("binary", "", "CLI executable under test (Go or alternative implementation)") replay := flags.String("replay", "", "replay a saved JSON trace instead of generating") output := flags.String("output", "dist/usage", "trace and report directory") @@ -59,7 +59,7 @@ func run(args []string) error { } else { trace = usage.Generate(*seed+uint64(n), *steps, *driver) } - if trace.Driver != "tui" && trace.Driver != "cli" { + if trace.Driver != "tui" && trace.Driver != "cli" && trace.Driver != "structural" { return fmt.Errorf("unsupported driver %q", trace.Driver) } name := fmt.Sprintf("%s-%d", trace.Driver, trace.Seed) @@ -77,6 +77,14 @@ func run(args []string) error { if err := writeJSON(filepath.Join(*output, name+"-failure.json"), trace); err != nil { return err } + minimal, diagnostic := usage.MinimizeFailure(trace, *binary, runErr, 64) + if err := writeJSON(filepath.Join(*output, name+"-minimized.json"), minimal); err != nil { + return err + } + if err := writeJSON(filepath.Join(*output, name+"-reduction.json"), map[string]string{"diagnostic": diagnostic}); err != nil { + return err + } + fmt.Fprintln(os.Stderr, diagnostic) return runErr } if *replay != "" { diff --git a/cmd/tdx/args.go b/cmd/tdx/args.go index 7e20cec..bca99c7 100644 --- a/cmd/tdx/args.go +++ b/cmd/tdx/args.go @@ -6,16 +6,18 @@ import ( "strings" "github.com/niklas-heer/tdx/internal/cmd" + "github.com/niklas-heer/tdx/internal/markdown" ) type cliOptions struct { - File string - Command string - Args []string - ReadOnly bool - ShowHeadings bool - MaxVisible int - List cmd.ListOptions + File string + Command string + Args []string + ReadOnly bool + ShowHeadings bool + MaxVisible int + List cmd.ListOptions + ExpectedRevision string } // parseArgs keeps option parsing separate from configuration, storage, and UI setup. @@ -42,7 +44,7 @@ func parseArgs(args []string, defaults DefaultsConfig) (cliOptions, error) { return args[i], nil } switch name { - case "--file", "-f", "--max-visible", "-m", "--status", "--tag": + case "--file", "-f", "--max-visible", "-m", "--status", "--tag", "--due", "--priority", "--section", "--if-revision": value, err := takeValue() if err != nil { return opts, err @@ -64,6 +66,27 @@ func parseArgs(args []string, defaults DefaultsConfig) (cliOptions, error) { return opts, fmt.Errorf("--status must be all, open, or done") } opts.List.Status, listFlags = value, true + case "--due": + opts.List.Due, listFlags = value, true + if value == "" { + return opts, fmt.Errorf("--due requires a value") + } + case "--priority": + n, err := strconv.Atoi(value) + if err != nil || n < 0 { + return opts, fmt.Errorf("--priority requires a non-negative integer") + } + opts.List.Priority, listFlags = &n, true + case "--section": + if strings.TrimSpace(value) == "" { + return opts, fmt.Errorf("--section requires a heading title") + } + opts.List.Section, listFlags = value, true + case "--if-revision": + if err := markdown.ValidateRevisionToken(value); err != nil { + return opts, err + } + opts.ExpectedRevision = value case "--tag": value = strings.TrimPrefix(value, "#") if value == "" || strings.ContainsAny(value, " \t\r\n") { @@ -72,15 +95,17 @@ func parseArgs(args []string, defaults DefaultsConfig) (cliOptions, error) { opts.List.Tags = append(opts.List.Tags, value) listFlags = true } - case "--read-only", "-r", "--show-headings", "--json": + case "--read-only", "-r", "--manual-save", "--show-headings", "--json", "--with-revision": if hasValue { return opts, fmt.Errorf("%s does not take a value", name) } switch name { - case "--read-only", "-r": + case "--read-only", "-r", "--manual-save": opts.ReadOnly = true case "--show-headings": opts.ShowHeadings = true + case "--with-revision": + opts.List.WithRevision, listFlags = true, true case "--json": opts.List.JSON, listFlags = true, true } @@ -111,7 +136,17 @@ func parseArgs(args []string, defaults DefaultsConfig) (cliOptions, error) { } } if listFlags && opts.Command != "list" { - return opts, fmt.Errorf("--json, --status, and --tag are only supported by list") + return opts, fmt.Errorf("query options are only supported by list") + } + if err := opts.List.Validate(); err != nil { + return opts, err + } + if opts.ExpectedRevision != "" { + switch opts.Command { + case "add", "edit", "toggle", "delete", "done", "undone": + default: + return opts, fmt.Errorf("--if-revision is only supported by task mutations") + } } return opts, nil } diff --git a/cmd/tdx/automation_test.go b/cmd/tdx/automation_test.go new file mode 100644 index 0000000..449af9e --- /dev/null +++ b/cmd/tdx/automation_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + commandpkg "github.com/niklas-heer/tdx/internal/cmd" +) + +func TestCLIRevisionWorkflow(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "tasks.md") + configRoot := filepath.Join(root, "config") + original := "# Project\n\n- [ ] First !p2\n## API\n- [ ] Second !p1 @due(2026-10-01)\n" + if err := os.WriteFile(file, []byte(original), 0600); err != nil { + t.Fatal(err) + } + out, stderr, err := scriptCLI(t, configRoot, file, "list", "--with-revision", "--section=Project", "--priority=1", "--due=2026-10-01") + if err != nil || stderr != "" { + t.Fatalf("query: %v %s", err, stderr) + } + var snapshot commandpkg.ListSnapshot + if err := json.Unmarshal([]byte(out), &snapshot); err != nil { + t.Fatal(err) + } + if len(snapshot.Tasks) != 1 || snapshot.Tasks[0].Index != 2 { + t.Fatal(out) + } + token, stderr, err := scriptCLI(t, configRoot, file, "revision") + if err != nil || stderr != "" || strings.TrimSpace(token) != snapshot.Revision { + t.Fatalf("revision: %q %q %v", token, stderr, err) + } + if _, err := os.Stat(configRoot); !os.IsNotExist(err) { + t.Fatal("read-only query created configuration") + } + _, stderr, err = scriptCLI(t, configRoot, file, "done", "2", "--if-revision", snapshot.Revision) + if err != nil { + t.Fatalf("done: %v %s", err, stderr) + } + before, _ := os.ReadFile(file) + _, stderr, err = scriptCLI(t, configRoot, file, "delete", "1", "--if-revision", snapshot.Revision) + if err == nil || !strings.Contains(stderr, "revision does not match") { + t.Fatalf("accepted stale write: %v %s", err, stderr) + } + after, _ := os.ReadFile(file) + if string(before) != string(after) { + t.Fatal("stale write changed file") + } + for _, command := range []string{"done", "done", "undone", "undone"} { + _, stderr, err = scriptCLI(t, configRoot, file, command, "2") + if err != nil { + t.Fatalf("%s: %v %s", command, err, stderr) + } + } + after, _ = os.ReadFile(file) + if string(after) != original { + t.Fatalf("idempotent round trip altered file: %q", after) + } +} + +func TestCLIAutomationValidationAndCompletions(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "missing.md") + configRoot := filepath.Join(root, "config") + for _, args := range [][]string{ + {"list", "--due=tomorow"}, {"list", "--priority=-1"}, {"list", "--section="}, + {"list", "--if-revision=missing"}, {"done", "1", "--if-revision=bad"}, + {"done", "1", "--read-only"}, {"undone", "1", "--manual-save"}, + {"revision", "extra"}, {"add", "hi", "--with-revision"}, + {"completion", "bad"}, {"completion"}, + } { + out, stderr, err := scriptCLI(t, configRoot, append([]string{file}, args...)...) + if err == nil || out != "" || stderr == "" { + t.Fatalf("%q: %q %q %v", args, out, stderr, err) + } + } + for _, shell := range []string{"bash", "zsh", "fish"} { + out, stderr, err := scriptCLI(t, configRoot, "completion", shell) + if err != nil || stderr != "" || !strings.Contains(out, "with-revision") { + t.Fatalf("%s completion: %s %s %v", shell, out, stderr, err) + } + } + if _, err := os.Stat(configRoot); !os.IsNotExist(err) { + t.Fatal("validation/completion created configuration") + } + if _, err := os.Stat(file); !os.IsNotExist(err) { + t.Fatal("validation/completion created task file") + } +} diff --git a/cmd/tdx/cli_tui_test.go b/cmd/tdx/cli_tui_test.go index 87c8632..6ad44e1 100644 --- a/cmd/tdx/cli_tui_test.go +++ b/cmd/tdx/cli_tui_test.go @@ -1,9 +1,11 @@ package main import ( + "errors" "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" @@ -107,6 +109,9 @@ func testMain(m *testing.M) int { defer func() { _ = os.RemoveAll(tmpDir) }() testBinary = filepath.Join(tmpDir, "tdx") + if runtime.GOOS == "windows" { + testBinary += ".exe" + } buildCmd := exec.Command("go", "build", "-o", testBinary, ".") if output, err := buildCmd.CombinedOutput(); err != nil { panic(string(output) + err.Error()) @@ -117,9 +122,16 @@ func testMain(m *testing.M) int { // Helper to run CLI command func runCLI(t *testing.T, file string, args ...string) string { + t.Helper() cmdArgs := append([]string{file}, args...) cmd := exec.Command(testBinary, cmdArgs...) - out, _ := cmd.CombinedOutput() // Some commands may fail, that's okay + out, err := cmd.CombinedOutput() + // Nonzero command exits are intentional in invalid-input tests, but failure + // to start the test executable must not masquerade as empty output. + var exitErr *exec.ExitError + if err != nil && !errors.As(err, &exitErr) { + t.Fatalf("start test CLI: %v\n%s", err, out) + } return strings.TrimSpace(string(out)) } @@ -497,9 +509,9 @@ func TestTUI_ReadOnlyMode(t *testing.T) { // The toggle should not persist because read-only mode is on output := testRuntime.RunPiped(file, []byte(":read-only\r "), false) - // Check that output shows READ ONLY indicator - if !strings.Contains(output, "READ ONLY") { - t.Errorf("Expected READ ONLY indicator in output") + // Check that output shows MANUAL SAVE indicator + if !strings.Contains(output, "MANUAL SAVE") { + t.Errorf("Expected MANUAL SAVE indicator in output") } // File should still have unchecked todo (change not persisted) diff --git a/cmd/tdx/main.go b/cmd/tdx/main.go index cbb6481..8d1c43f 100644 --- a/cmd/tdx/main.go +++ b/cmd/tdx/main.go @@ -71,7 +71,7 @@ func canonicalHistoryPath(path string) (string, error) { func commandUsesVersioning(command string, args []string) bool { switch command { - case "", "add", "toggle", "edit", "delete", "last": + case "", "add", "toggle", "edit", "delete", "done", "undone", "last": return true case "recent": return len(args) > 0 && args[0] != "clear" @@ -96,6 +96,8 @@ func run() (exitCode int) { tuiCfg := &tui.ConfigType{} recentDir, _ := config.GetConfigDir() tuiCfg.Recent = config.RecentStore{Dir: recentDir, Limit: appConfig.Recent.MaxFiles} + tuiCfg.Views = config.NewFileViewStore("") + tuiCfg.ViewsRestore = appConfig.Views.Restore tuiCfg.Display.CheckSymbol = appConfig.Display.CheckSymbol tuiCfg.Display.SelectMarker = appConfig.Display.SelectMarker tuiCfg.Display.MaxVisible = appConfig.Defaults.MaxVisible @@ -158,7 +160,7 @@ func run() (exitCode int) { filePath, command, cmdArgs := opts.File, opts.Command, opts.Args readOnly, showHeadings, maxVisible := opts.ReadOnly, opts.ShowHeadings, opts.MaxVisible switch command { - case "list", "add", "toggle", "edit", "delete": + case "list", "add", "toggle", "edit", "delete", "done", "undone": if err := cmd.ValidateCommand(command, cmdArgs); err != nil { fmt.Fprintf(os.Stderr, "tdx: %v\n", err) return 1 @@ -169,6 +171,12 @@ func run() (exitCode int) { } } + if command == "revision" && len(cmdArgs) != 0 { + fmt.Fprintln(os.Stderr, "tdx: revision takes no arguments") + return 1 + } + cli.ExpectedRevision = opts.ExpectedRevision + // Resolve file path (expand ~ and make absolute) filePath = resolveFilePath(filePath) @@ -213,7 +221,28 @@ func run() (exitCode int) { fmt.Fprintf(os.Stderr, "tdx: %v\n", err) return 1 } - case "add", "toggle", "edit", "delete": + case "revision": + fm, err := (markdown.Store{}).ReadFile(filePath) + if err != nil { + fmt.Fprintf(os.Stderr, "tdx: %v\n", err) + return 1 + } + token, err := fm.RevisionToken() + if err != nil { + fmt.Fprintf(os.Stderr, "tdx: %v\n", err) + return 1 + } + fmt.Println(token) + case "completion": + if len(cmdArgs) != 1 { + fmt.Fprintln(os.Stderr, "tdx: completion requires bash, zsh, or fish") + return 1 + } + if err := cmd.WriteCompletion(os.Stdout, cmdArgs[0]); err != nil { + fmt.Fprintf(os.Stderr, "tdx: %v\n", err) + return 1 + } + case "add", "toggle", "edit", "delete", "done", "undone": if err := cli.HandleCommand(command, cmdArgs, filePath); err != nil { fmt.Fprintf(os.Stderr, "tdx: %v\n", err) return 1 @@ -241,7 +270,9 @@ Usage: Options: -f, --file Select any file path (including paths without .md) - -r, --read-only Don't save changes to disk (read-only mode) + -r, --read-only Manual save in TUI; reject CLI mutations + --manual-save Alias for --read-only + --if-revision Reject a mutation unless the loaded revision matches --show-headings Display markdown headings between tasks -m, --max-visible Set max visible items (0 = unlimited) -- Treat remaining arguments as literal text @@ -250,12 +281,20 @@ List options: --json Emit a JSON array for scripts and editor integrations --status Filter by all (default), open, or done --tag Filter by exact tag; repeat to require every tag + --priority Filter by priority (0 means unset) + --due all, none, overdue, today, week, or YYYY-MM-DD + --section Exact heading title, including its subsections + --with-revision JSON envelope with schema_version, revision, tasks Commands: (none) Launch interactive TUI - list List todos (supports --json, --status, --tag) + list List todos with composable filters add "text" Add a new todo toggle <index> Toggle todo completion + done <index> Mark complete (safe to retry) + undone <index> Mark open (safe to retry) + revision Print current document revision + completion <shell> Generate bash, zsh, or fish completion edit <index> "text" Edit todo text delete <index> Delete a todo last Open the most recently used file diff --git a/cmd/tdx/userconfig.go b/cmd/tdx/userconfig.go index 336a669..13c18cc 100644 --- a/cmd/tdx/userconfig.go +++ b/cmd/tdx/userconfig.go @@ -14,6 +14,7 @@ var themesFS embed.FS // UserConfig holds user configuration type UserConfig struct { + Views ViewsConfig `toml:"views"` Theme ThemeConfig `toml:"theme"` Colors ColorsConfig // Populated from builtin theme, not from config file Display DisplayConfig `toml:"display"` @@ -60,12 +61,17 @@ type DisplayConfig struct { // DefaultsConfig holds default behavior settings type DefaultsConfig struct { - File string `toml:"file"` // default file path (default: "todo.md", use absolute/~ for central file) - MaxVisible int `toml:"max_visible"` // max todos to show (0 = unlimited) - WordWrap bool `toml:"word_wrap"` // enable word wrapping (default: true) - ShowHeadings bool `toml:"show_headings"` // show headings between tasks (default: false) - ReadOnly bool `toml:"read_only"` // open in read-only mode (default: false) - FilterDone bool `toml:"filter_done"` // filter out completed tasks (default: false) + ManualSave *bool `toml:"manual_save,omitempty"` // preferred alias, overrides read_only when set + File string `toml:"file"` // default file path (default: "todo.md", use absolute/~ for central file) + MaxVisible int `toml:"max_visible"` // max todos to show (0 = unlimited) + WordWrap bool `toml:"word_wrap"` // enable word wrapping (default: true) + ShowHeadings bool `toml:"show_headings"` // show headings between tasks (default: false) + ReadOnly bool `toml:"read_only"` // manual-save mode: temporary edits until explicit save + FilterDone bool `toml:"filter_done"` // filter out completed tasks (default: false) +} + +type ViewsConfig struct { + Restore bool `toml:"restore"` } // RecentConfig holds recent files settings @@ -232,6 +238,7 @@ func DefaultConfig() *UserConfig { Recent: RecentConfig{ MaxFiles: 20, // default max recent files }, + Views: ViewsConfig{Restore: false}, Versioning: VersioningConfig{ MaxVersions: 100, // default max versions per file }, @@ -375,6 +382,9 @@ func LoadConfig() *UserConfig { } } + if config.Defaults.ManualSave != nil { + config.Defaults.ReadOnly = *config.Defaults.ManualSave + } return config } @@ -509,6 +519,7 @@ func getConfigPath() (string, error) { // minimalSaveConfig is used for saving config without colors (colors come from theme) type minimalSaveConfig struct { + Views *ViewsConfig `toml:"views,omitempty"` Theme struct { Name string `toml:"name"` } `toml:"theme"` @@ -535,6 +546,9 @@ func SaveTheme(themeName string) error { // Create config with theme name and preserve other settings minConfig := &minimalSaveConfig{} minConfig.Theme.Name = themeName + if existingConfig.Views.Restore { + minConfig.Views = &existingConfig.Views + } // Preserve display settings if they were customized defaults := DefaultConfig() @@ -544,7 +558,7 @@ func SaveTheme(themeName string) error { } // Preserve defaults settings if any were customized - if existingConfig.Defaults.File != defaults.Defaults.File || + if existingConfig.Defaults.ManualSave != nil || existingConfig.Defaults.File != defaults.Defaults.File || existingConfig.Defaults.MaxVisible != defaults.Defaults.MaxVisible || existingConfig.Defaults.WordWrap != defaults.Defaults.WordWrap || existingConfig.Defaults.ShowHeadings != defaults.Defaults.ShowHeadings || diff --git a/cmd/tdx/userconfig_test.go b/cmd/tdx/userconfig_test.go index cd38149..b917b23 100644 --- a/cmd/tdx/userconfig_test.go +++ b/cmd/tdx/userconfig_test.go @@ -574,7 +574,7 @@ func TestResolveFilePath_RelativePath(t *testing.T) { } func TestResolveFilePath_AbsolutePathUnchanged(t *testing.T) { - absPath := "/tmp/my-todos.md" + absPath := filepath.Join(t.TempDir(), "my-todos.md") result := resolveFilePath(absPath) if result != absPath { @@ -602,3 +602,27 @@ func TestResolveFilePath_CustomFilename(t *testing.T) { }) } } + +func TestSavedViewsConfigAndManualSaveAlias(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + configDir := filepath.Join(dir, "tdx") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(configDir, "config.toml") + if err := os.WriteFile(path, []byte("[defaults]\nread_only = true\nmanual_save = false\n[views]\nrestore = true\n"), 0600); err != nil { + t.Fatal(err) + } + cfg := LoadConfig() + if cfg.Defaults.ReadOnly || !cfg.Views.Restore { + t.Fatalf("aliases/settings not applied: %+v", cfg) + } + if err := SaveTheme("tokyo-night"); err != nil { + t.Fatal(err) + } + cfg = LoadConfig() + if cfg.Defaults.ManualSave == nil || *cfg.Defaults.ManualSave || !cfg.Views.Restore { + t.Fatal("theme change dropped workflow settings") + } +} diff --git a/docs/install.sh b/docs/install.sh index aadc753..bdf198d 100755 --- a/docs/install.sh +++ b/docs/install.sh @@ -29,12 +29,31 @@ if [[ -n "${TDX_VERSION:-}" ]]; then release_path="download/v${TDX_VERSION#v}" fi tmp_dir=$(mktemp -d) -trap 'rm -rf "$tmp_dir"' EXIT +staged="" +trap 'rm -rf "$tmp_dir"; [[ -z "$staged" ]] || rm -f "$staged"' EXIT printf 'Downloading %s...\n' "$ARTIFACT" -curl --fail --show-error --silent --location --retry 3 \ +curl --fail --show-error --silent --location --retry 3 --connect-timeout 15 --max-time 120 \ "https://github.com/${REPO}/releases/${release_path}/${ARTIFACT}" -o "$tmp_dir/$BINARY" +curl --fail --show-error --silent --location --retry 3 --connect-timeout 15 --max-time 120 \ + "https://github.com/${REPO}/releases/${release_path}/SHA256SUMS" -o "$tmp_dir/SHA256SUMS" +expected=$(awk -v artifact="$ARTIFACT" '$2 == artifact { print $1; count++ } END { if (count != 1) exit 1 }' "$tmp_dir/SHA256SUMS") || { + echo "Checksum manifest must contain exactly one entry for $ARTIFACT." >&2; exit 1; +} +[[ "$expected" =~ ^[[:xdigit:]]{64}$ ]] || { echo 'Invalid SHA-256 checksum.' >&2; exit 1; } +if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$tmp_dir/$BINARY") +elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$tmp_dir/$BINARY") +else + echo 'SHA-256 verification requires sha256sum or shasum.' >&2; exit 1 +fi +[[ "${actual%% *}" == "$expected" ]] || { echo 'SHA-256 verification failed; installation unchanged.' >&2; exit 1; } +# Stage on the destination filesystem so replacement is a single rename. mkdir -p "$INSTALL_DIR" -install -m 755 "$tmp_dir/$BINARY" "$INSTALL_DIR/$BINARY" +staged=$(mktemp "$INSTALL_DIR/.tdx-install.XXXXXX") +install -m 755 "$tmp_dir/$BINARY" "$staged" +mv -f "$staged" "$INSTALL_DIR/$BINARY" +staged="" printf 'Installed %s/%s\n' "$INSTALL_DIR" "$BINARY" case ":$PATH:" in *":$INSTALL_DIR:"*) ;; diff --git a/experiments/usability/synthetic-five-person-study.json b/experiments/usability/synthetic-five-person-study.json new file mode 100644 index 0000000..b4ba48b --- /dev/null +++ b/experiments/usability/synthetic-five-person-study.json @@ -0,0 +1,468 @@ +{ + "schema_version": 1, + "data_kind": "synthetic_usability_rehearsal", + "synthetic": true, + "seed": 20260912, + "real_participants": 0, + "generated_for": "Rehearsing a five-person study, its recording format and analysis only", + "warning": "All profiles, task outcomes, timings and observations are invented. No humans were recruited or observed. These values are not measurements, forecasts, testimonials or evidence that the implementation improves usability. Do not merge with real study results or automated benchmark measurements.", + "conditions": { + "baseline": "Hypothetical manual-filter workflow; not an observed baseline", + "proposed": "Hypothetical saved-view workflow; not observed implementation behavior" + }, + "method": "Seeded Gaussian illustrative timings with task-specific centers and explicit hypothetical assistance/failure cases. Centers encode assumptions, not findings.", + "participants": [ + { + "id": "S01", + "hypothetical_profile": "CLI-focused developer", + "hypothetical_experience": "Daily terminal use; new to tdx", + "synthetic": true, + "tasks": [ + { + "task": "capture", + "scenario": "Capture a new project task", + "baseline_seconds": 29, + "proposed_seconds": 27, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + }, + { + "task": "save_view", + "scenario": "Prepare a reusable Today/project task subset", + "baseline_seconds": 59, + "proposed_seconds": 76, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false, + "baseline_scenario": "Configure filters and record their recipe outside the app", + "proposed_scenario": "Configure filters and save a named view" + }, + { + "task": "reopen_view", + "scenario": "Return to that task subset in a later session", + "baseline_seconds": 37, + "proposed_seconds": 5, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false, + "baseline_scenario": "Reapply recorded filters manually in a later session", + "proposed_scenario": "Load the named view in a later session" + }, + { + "task": "nested_edit", + "scenario": "Edit a nested task without affecting its notes", + "baseline_seconds": 28, + "proposed_seconds": 29, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + }, + { + "task": "clear_filters", + "scenario": "Return to all tasks", + "baseline_seconds": 23, + "proposed_seconds": 16, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + }, + { + "task": "recover", + "scenario": "Undo an accidental edit", + "baseline_seconds": 16, + "proposed_seconds": 13, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + } + ], + "illustrative_observation": "Hypothesis: a CLI-first user may prefer query flags and completion over the view picker." + }, + { + "id": "S02", + "hypothetical_profile": "Occasional terminal user", + "hypothetical_experience": "Comfortable with Markdown; rarely uses modal editors", + "synthetic": true, + "tasks": [ + { + "task": "capture", + "scenario": "Capture a new project task", + "baseline_seconds": 18, + "proposed_seconds": 21, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": true + }, + { + "task": "save_view", + "scenario": "Prepare a reusable Today/project task subset", + "baseline_seconds": 46, + "proposed_seconds": 54, + "proposed_success": false, + "proposed_assistance": true, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": true, + "baseline_scenario": "Configure filters and record their recipe outside the app", + "proposed_scenario": "Configure filters and save a named view" + }, + { + "task": "reopen_view", + "scenario": "Return to that task subset in a later session", + "baseline_seconds": 58, + "proposed_seconds": 5, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": false, + "baseline_assistance": false, + "baseline_scenario": "Reapply recorded filters manually in a later session", + "proposed_scenario": "Load the named view in a later session" + }, + { + "task": "nested_edit", + "scenario": "Edit a nested task without affecting its notes", + "baseline_seconds": 39, + "proposed_seconds": 43, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + }, + { + "task": "clear_filters", + "scenario": "Return to all tasks", + "baseline_seconds": 33, + "proposed_seconds": 17, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + }, + { + "task": "recover", + "scenario": "Undo an accidental edit", + "baseline_seconds": 14, + "proposed_seconds": 19, + "proposed_success": true, + "proposed_assistance": true, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": true + } + ], + "illustrative_observation": "Hypothesis: first-time view creation may need clearer help; explicit save-mode wording may still need explanation." + }, + { + "id": "S03", + "hypothetical_profile": "Vim user", + "hypothetical_experience": "Uses keyboard navigation; manages nested project notes", + "synthetic": true, + "tasks": [ + { + "task": "capture", + "scenario": "Capture a new project task", + "baseline_seconds": 21, + "proposed_seconds": 25, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + }, + { + "task": "save_view", + "scenario": "Prepare a reusable Today/project task subset", + "baseline_seconds": 51, + "proposed_seconds": 65, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false, + "baseline_scenario": "Configure filters and record their recipe outside the app", + "proposed_scenario": "Configure filters and save a named view" + }, + { + "task": "reopen_view", + "scenario": "Return to that task subset in a later session", + "baseline_seconds": 47, + "proposed_seconds": 26, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false, + "baseline_scenario": "Reapply recorded filters manually in a later session", + "proposed_scenario": "Load the named view in a later session" + }, + { + "task": "nested_edit", + "scenario": "Edit a nested task without affecting its notes", + "baseline_seconds": 40, + "proposed_seconds": 49, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + }, + { + "task": "clear_filters", + "scenario": "Return to all tasks", + "baseline_seconds": 24, + "proposed_seconds": 20, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + }, + { + "task": "recover", + "scenario": "Undo an accidental edit", + "baseline_seconds": 19, + "proposed_seconds": 32, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + } + ], + "illustrative_observation": "Hypothesis: keyboard familiarity helps navigation, while confidence depends on preserving nested notes." + }, + { + "id": "S04", + "hypothetical_profile": "Windows developer", + "hypothetical_experience": "PowerShell user; switches between GUI and terminal", + "synthetic": true, + "tasks": [ + { + "task": "capture", + "scenario": "Capture a new project task", + "baseline_seconds": 20, + "proposed_seconds": 26, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + }, + { + "task": "save_view", + "scenario": "Prepare a reusable Today/project task subset", + "baseline_seconds": 57, + "proposed_seconds": 64, + "proposed_success": true, + "proposed_assistance": true, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false, + "baseline_scenario": "Configure filters and record their recipe outside the app", + "proposed_scenario": "Configure filters and save a named view" + }, + { + "task": "reopen_view", + "scenario": "Return to that task subset in a later session", + "baseline_seconds": 44, + "proposed_seconds": 18, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false, + "baseline_scenario": "Reapply recorded filters manually in a later session", + "proposed_scenario": "Load the named view in a later session" + }, + { + "task": "nested_edit", + "scenario": "Edit a nested task without affecting its notes", + "baseline_seconds": 37, + "proposed_seconds": 45, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + }, + { + "task": "clear_filters", + "scenario": "Return to all tasks", + "baseline_seconds": 30, + "proposed_seconds": 14, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": true + }, + { + "task": "recover", + "scenario": "Undo an accidental edit", + "baseline_seconds": 23, + "proposed_seconds": 23, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + } + ], + "illustrative_observation": "Hypothesis: clipboard availability and path handling may influence trust independently of saved views." + }, + { + "id": "S05", + "hypothetical_profile": "Returning checklist user", + "hypothetical_experience": "Uses tdx weekly; values temporary checklist changes", + "synthetic": true, + "tasks": [ + { + "task": "capture", + "scenario": "Capture a new project task", + "baseline_seconds": 22, + "proposed_seconds": 26, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + }, + { + "task": "save_view", + "scenario": "Prepare a reusable Today/project task subset", + "baseline_seconds": 52, + "proposed_seconds": 75, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false, + "baseline_scenario": "Configure filters and record their recipe outside the app", + "proposed_scenario": "Configure filters and save a named view" + }, + { + "task": "reopen_view", + "scenario": "Return to that task subset in a later session", + "baseline_seconds": 53, + "proposed_seconds": 24, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false, + "baseline_scenario": "Reapply recorded filters manually in a later session", + "proposed_scenario": "Load the named view in a later session" + }, + { + "task": "nested_edit", + "scenario": "Edit a nested task without affecting its notes", + "baseline_seconds": 40, + "proposed_seconds": 45, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + }, + { + "task": "clear_filters", + "scenario": "Return to all tasks", + "baseline_seconds": 22, + "proposed_seconds": 5, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + }, + { + "task": "recover", + "scenario": "Undo an accidental edit", + "baseline_seconds": 12, + "proposed_seconds": 15, + "proposed_success": true, + "proposed_assistance": false, + "synthetic": true, + "baseline_success": true, + "baseline_assistance": false + } + ], + "illustrative_observation": "Hypothesis: reopening a saved checklist view may help repeated use, but initial setup has a learning cost." + } + ], + "illustrative_summary": [ + { + "task": "capture", + "baseline_median_seconds": 21, + "proposed_median_seconds": 26, + "proposed_successes": 5, + "assisted_attempts": 0, + "baseline_successes": 5, + "baseline_assisted_attempts": 1 + }, + { + "task": "save_view", + "baseline_median_seconds": 52, + "proposed_median_seconds": 65, + "proposed_successes": 4, + "assisted_attempts": 2, + "baseline_successes": 5, + "baseline_assisted_attempts": 1 + }, + { + "task": "reopen_view", + "baseline_median_seconds": 47, + "proposed_median_seconds": 18, + "proposed_successes": 5, + "assisted_attempts": 0, + "baseline_successes": 4, + "baseline_assisted_attempts": 0 + }, + { + "task": "nested_edit", + "baseline_median_seconds": 39, + "proposed_median_seconds": 45, + "proposed_successes": 5, + "assisted_attempts": 0, + "baseline_successes": 5, + "baseline_assisted_attempts": 0 + }, + { + "task": "clear_filters", + "baseline_median_seconds": 24, + "proposed_median_seconds": 16, + "proposed_successes": 5, + "assisted_attempts": 0, + "baseline_successes": 5, + "baseline_assisted_attempts": 1 + }, + { + "task": "recover", + "baseline_median_seconds": 16, + "proposed_median_seconds": 19, + "proposed_successes": 5, + "assisted_attempts": 1, + "baseline_successes": 5, + "baseline_assisted_attempts": 1 + } + ], + "next_step": "Replace synthetic records with consented observations before drawing usability conclusions." +} diff --git a/experiments/usage/README.md b/experiments/usage/README.md index dee0fe6..ab46e42 100644 --- a/experiments/usage/README.md +++ b/experiments/usage/README.md @@ -10,13 +10,14 @@ mise run test:usage -- -seed 500 # explore another set of sessions mise run test:usage -- -sessions 1 -steps 120 # short investigation mise run test:usage -- -replay dist/usage/tui-100-failure.json -output dist/usage/replay mise run test:usage-cli # 200 real CLI actions plus JSON queries +mise exec -- go run ./cmd/tdx-usage -driver structural -sessions 8 -steps 500 mise run test:terminal # actual PTYs, macOS/Linux mise run profile:usage # CPU/heap profiles and checkbox benchmarks ``` The default campaign runs twenty independent five-hour sessions, seeds 100–119, with 1,800 actions each: **36,000 deliberate actions at six actions per minute**. Ten seconds of think time per action is accounted for without sleeping. An action can include navigation, multiple keys, a save, and validation. This is a stated workload assumption, not telemetry about how people use tdx. -Generated traces, reports, failure prefixes and terminal transcripts stay in ignored `dist/usage/`. A failure exits nonzero and writes the trace through the failing action. Prefixes preserve all prerequisite state; they are not automatically minimized. Reports include the canonical trace SHA-256, counts by action, simulated duration, actual wall time, per-action latency percentiles, and total allocated bytes. Reuse a failure trace with `-replay`; changing the generator later cannot change that trace's actions or expectations. +Generated traces, reports, failure prefixes and terminal transcripts stay in ignored `dist/usage/`. A failure exits nonzero and writes the trace through the failing action. The runner also writes a `-minimized.json` trace and `-reduction.json` diagnostic. Chunk reduction tries up to 64 candidate traces while retaining the same failure category; flat candidates regenerate expectations through the independent oracle. A budget-limited trace is smaller when possible, not guaranteed globally minimal. Failures outside reproducible step invariants retain the original prefix with an explicit reason. Reports include the canonical trace SHA-256, counts by action, simulated duration, actual wall time, per-action latency percentiles, and total allocated bytes. Reuse a failure trace with `-replay`; changing the generator later cannot change that trace's actions or expectations. ## Contracts and rewrite candidates @@ -24,6 +25,8 @@ The Go driver sends real Bubble Tea `Update` messages, renders after each action The campaign mixes add/edit/delete/toggle, move/undo, cancelled edit/add/move, empty input, navigation, immediate search selection, reopen, external edits and conflicting saves followed by reload. External changes use real filesystem writes; conflict cases verify that disk content remains authoritative before resolving the conflict. Fixtures start with 24 flat tasks containing Unicode, tags, priorities and due dates, and growth is bounded around 65 tasks. Dedicated regressions cover full undo capacity, nested/ordered/rich Markdown, exact bytes, dirty snapshots and external changes while input is unfinished. +The `structural` driver starts with nested tasks, ordered lists, multiline bodies, fenced examples, HTML, tables and reference definitions, alternating LF and CRLF by seed. It mixes text, section, sort, move, indentation, bulk and undo operations with real conditional saves and reopen. It checks task identity conservation, protected source bytes, rejection atomicity, exact undo and parser/cache agreement. It supplements the independent flat oracle; it does not claim an independent full Markdown parser or drive terminal keys. The checked-in [heading-cache regression](heading-cache-regression.json) is a one-step replay reduced from the first structural campaign. + The model driver executes only the explicit reload command. It does not run the full asynchronous event loop. The separate PTY suite launches the actual application and exercises terminal initialization, resize, Unicode paste/edit/undo, read-only saves, canonical history lookup through a symlink, and a real watcher tick during an unfinished edit. All config, SQLite history and files are isolated. The terminal suite checks observable text and files; it is not a complete terminal screen emulator or a screenshot comparison. A future executable can use the same CLI and PTY contracts: @@ -37,13 +40,13 @@ python3 scripts/usage-pty.py --binary /absolute/path/to/candidate \ The CLI adapter checks each mutation, then the full `list --json` task representation: one-based indexes, text, checked state, depth, parent, tags, priority and due date. Each subprocess has a ten-second timeout. Its supported actions are add, edit, delete, toggle and reopen/query. TUI-only actions fail explicitly when given to the CLI adapter; they are never silently counted as passing. The existing Rust parser probe is **not** a compatible candidate executable. -Trace schema 1 is JSON: `schema`, `seed`, `driver`, `initial` tasks and ordered `steps`. A task contains `text` and `checked`; a step contains `op`, zero-based `index`, optional `text`, positive `seconds`, and the complete expected task array. The driver also recalculates each expectation using the independent oracle, rejecting inconsistent traces. The current oracle's grammar is flat literal task text with whitespace-delimited metadata tokens; arbitrary Markdown and nested editing require additional contracts before a full rewrite comparison. See [trace.go](../../internal/usage/trace.go) and [replay.go](../../internal/usage/replay.go). +Trace schema 1 is JSON: `schema`, `seed`, `driver`, `initial` tasks and ordered `steps`. A task contains `text` and `checked`; a step contains `op`, zero-based `index`, optional `text`, positive `seconds`, and the complete expected task array. The driver also recalculates each expectation using the independent oracle, rejecting inconsistent traces. The flat oracle's grammar remains literal task text with whitespace-delimited metadata tokens. Structural traces additionally contain `source`, `protected` source blocks and `action` objects on steps, with `undo` and `reopen` operations. Arbitrary Markdown still needs a language-neutral structural oracle before a full rewrite comparison. See [trace.go](../../internal/usage/trace.go) and [replay.go](../../internal/usage/replay.go). ## Improvements found and implemented - Checkbox saves and undo could drop HTML, flatten tables/paragraphs, lose unknown frontmatter, change CRLF or add a header/newline. Freshly parsed checkbox edits now patch only the AST-identified checkbox byte, retain raw frontmatter while settings are unchanged, and preserve bytes through snapshots and undo. Fenced examples cannot be mistaken for editable tasks. -- Checkbox updates re-extracted every task's text, tags, priority and due date. They now use indexed checkbox nodes and update cached checked state directly. Structural changes invalidate the index. Metadata extraction also has cheap guards for text without markers. -- Structural saves dropped HTML and destroyed table syntax. They now retain HTML blocks, render valid tables, and preserve ordinary paragraph line breaks. Structural editing still normalizes formatting; it is not a general lossless Markdown editor. Reference definitions, complex multiline task bodies and other unsupported syntax need broader contracts before making that guarantee. +- Checkbox updates re-extracted every task's text, tags, priority and due date. They now use indexed checkbox nodes and update cached checked state directly. Source edits reparse and rebuild the index. Metadata extraction also has cheap guards for text without markers. +- Task and section edits now patch the original source rather than render the entire AST, preserving reference definitions, HTML, tables, fences, CRLF and unrelated document bytes. Structural edits move complete task bodies and descendants; sorting preserves sibling groups and sections. Source candidates are reparsed and checked before installation. Unsupported structural edits inside block quotes or tab-indented continuations and moves into a task’s own descendants are rejected without changing the document. Task titles must be single-line; existing multiline bodies remain supported. - Entering and cancelling edit/add/move at the 100-entry undo limit evicted the oldest committed edit. Provisional snapshots now live outside the bounded stack. Empty input is cancelled too. - Priority options could remain stale after edits; undo and explicit reload could retain obsolete picker metadata. Task metadata and active filters now refresh at those boundaries. - The watcher could refresh the document and disk revision while edit/add/move input was pending. Enter could then overwrite another editor's change without a conflict. Watch reloads now defer during unfinished input, preserving the original revision so the save produces a conflict. Both a deterministic regression and the PTY suite exercise this case. diff --git a/experiments/usage/heading-cache-regression.json b/experiments/usage/heading-cache-regression.json new file mode 100644 index 0000000..ec10d15 --- /dev/null +++ b/experiments/usage/heading-cache-regression.json @@ -0,0 +1,20 @@ +{ + "schema": 1, + "seed": 100, + "driver": "structural", + "source": "# Work\n\n- [ ] parent\n - [ ] child\n\n[guide]: /kept\n", + "protected": [ + "[guide]: /kept\n" + ], + "steps": [ + { + "op": "action", + "seconds": 10, + "action": { + "Kind": "rename-heading", + "Index": 0, + "Text": "Longer heading" + } + } + ] +} diff --git a/internal/cmd/automation_test.go b/internal/cmd/automation_test.go new file mode 100644 index 0000000..e7811e0 --- /dev/null +++ b/internal/cmd/automation_test.go @@ -0,0 +1,148 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/niklas-heer/tdx/internal/markdown" +) + +func TestQuerySnapshotAndCombinedFilters(t *testing.T) { + path := writeTodoFile(t, "# Work\n\n- [ ] One !p2 @due(2026-10-01) #work\n## API\n- [ ] Two !p1 @due(2026-10-01) #work\n# Personal\n- [ ] Three !p1 @due(2026-10-01) #work\n# Work\n- [ ] Four !p1 @due(2026-10-01) #work\n") + priority := 1 + var out bytes.Buffer + if err := WriteList(&out, path, ListOptions{WithRevision: true, Status: "open", Tags: []string{"work"}, Priority: &priority, Due: "2026-10-01", Section: "Work"}); err != nil { + t.Fatal(err) + } + var snapshot ListSnapshot + if err := json.Unmarshal(out.Bytes(), &snapshot); err != nil { + t.Fatal(err) + } + if snapshot.SchemaVersion != 1 || len(snapshot.Tasks) != 2 || snapshot.Tasks[0].Index != 2 || snapshot.Tasks[1].Index != 4 { + t.Fatalf("unexpected snapshot: %s", out.String()) + } + fm, err := markdown.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := fm.RequireRevision(snapshot.Revision); err != nil { + t.Fatal(err) + } +} + +func TestSectionQueryUsesExposedTaskIndexes(t *testing.T) { + path := writeTodoFile(t, "# Work\n\n- [ ] outer\n > - [ ] quoted\n\n# Later\n\n- [ ] after\n\n# Tail\n") + var out bytes.Buffer + if err := WriteList(&out, path, ListOptions{JSON: true, Section: "Later"}); err != nil { + t.Fatal(err) + } + var tasks []Task + if err := json.Unmarshal(out.Bytes(), &tasks); err != nil { + t.Fatal(err) + } + if len(tasks) != 1 || tasks[0].Text != "after" || tasks[0].Index != 2 { + t.Fatalf("query selected a hidden or unrelated task: %s", out.String()) + } +} + +func TestGuardedCompletionAndRetry(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + path := writeTodoFile(t, "---\ntitle: Project\n---\n- [ ] First\n- [ ] Second\n") + fm, err := markdown.ReadFile(path) + if err != nil { + t.Fatal(err) + } + token, _ := fm.RevisionToken() + writes := 0 + s := Service{Out: io.Discard, ExpectedRevision: token, Store: markdown.Store{OnWrite: func(_, _ string) error { writes++; return nil }}} + if err := s.SetTodoChecked(path, 2, true); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(path) + if err := s.SetTodoChecked(path, 1, true); !errors.Is(err, markdown.ErrFileChanged) { + t.Fatalf("stale index guard failed: %v", err) + } + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) || writes != 1 { + t.Fatal("stale revision changed file") + } + s.ExpectedRevision = "" + if err := s.SetTodoChecked(path, 2, true); err != nil || writes != 1 { + t.Fatalf("retry rewrote an already complete task: %v, writes=%d", err, writes) + } + if err := s.SetTodoChecked(path, 2, false); err != nil || writes != 2 { + t.Fatalf("undone failed: %v", err) + } +} + +func TestMissingAndEmptyRevisionsDiffer(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing.md") + var out bytes.Buffer + if err := WriteList(&out, path, ListOptions{WithRevision: true}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), `"revision": "missing"`) || !strings.Contains(out.String(), `"tasks": []`) { + t.Fatal(out.String()) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("query created file") + } + if err := os.WriteFile(path, nil, 0600); err != nil { + t.Fatal(err) + } + fm, err := markdown.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := fm.RequireRevision("missing"); !errors.Is(err, markdown.ErrFileChanged) { + t.Fatalf("empty file accepted missing revision: %v", err) + } +} + +func TestMutationRejectsChangeAfterRevisionCheck(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + path := writeTodoFile(t, "- [ ] Original\n") + fm, _ := markdown.ReadFile(path) + token, _ := fm.RevisionToken() + external := "- [ ] External\n" + s := Service{ExpectedRevision: token, Out: io.Discard, Store: markdown.Store{OnRead: func(_, _ string) error { + return os.WriteFile(path, []byte(external), 0600) + }}} + if err := s.SetTodoChecked(path, 1, true); !errors.Is(err, markdown.ErrFileChanged) { + t.Fatalf("accepted concurrent change: %v", err) + } + after, _ := os.ReadFile(path) + if string(after) != external { + t.Fatal("overwrote concurrent edit") + } +} + +func TestDueQueryVocabulary(t *testing.T) { + today := time.Now() + yesterday := today.AddDate(0, 0, -1) + week := today.AddDate(0, 0, 7) + for _, tt := range []struct { + date *time.Time + filter string + want bool + }{ + {nil, "none", true}, {nil, "all", false}, {&today, "today", true}, + {&yesterday, "overdue", true}, {&week, "week", true}, {&yesterday, "week", false}, + } { + if got := matchesDue(markdown.Todo{DueDate: tt.date}, tt.filter); got != tt.want { + t.Errorf("filter %s = %v, want %v", tt.filter, got, tt.want) + } + } + for _, due := range []string{"2026-02-30", "tomorow", "2026-1-01"} { + if err := (ListOptions{Due: due}).Validate(); err == nil { + t.Errorf("accepted invalid date %q", due) + } + } +} diff --git a/internal/cmd/cli.go b/internal/cmd/cli.go index 0e8b704..e5233d8 100644 --- a/internal/cmd/cli.go +++ b/internal/cmd/cli.go @@ -15,10 +15,11 @@ import ( // Service owns the output and persistence dependencies for a CLI instance. // Its zero value uses stdout, plain styling, and a hook-free Markdown store. type Service struct { - Store markdown.Store - Out io.Writer - GreenStyle func(string) string - CheckSymbol string + Store markdown.Store + Out io.Writer + GreenStyle func(string) string + CheckSymbol string + ExpectedRevision string } func (s Service) output() io.Writer { @@ -47,7 +48,7 @@ func (s Service) ListTodos(filePath string) error { // AddTodo adds a new todo to a file func (s Service) AddTodo(filePath string, text string) error { - fm, err := s.Store.ReadFile(filePath) + fm, err := s.readForMutation(filePath) if err != nil { return err } @@ -66,7 +67,7 @@ func (s Service) AddTodo(filePath string, text string) error { // ToggleTodo toggles the completion status of a todo func (s Service) ToggleTodo(filePath string, index int) error { - fm, err := s.Store.ReadFile(filePath) + fm, err := s.readForMutation(filePath) if err != nil { return err } @@ -94,7 +95,7 @@ func (s Service) ToggleTodo(filePath string, index int) error { // EditTodo edits the text of a todo func (s Service) EditTodo(filePath string, index int, text string) error { - fm, err := s.Store.ReadFile(filePath) + fm, err := s.readForMutation(filePath) if err != nil { return err } @@ -117,7 +118,7 @@ func (s Service) EditTodo(filePath string, index int, text string) error { // DeleteTodo deletes a todo by index func (s Service) DeleteTodo(filePath string, index int) error { - fm, err := s.Store.ReadFile(filePath) + fm, err := s.readForMutation(filePath) if err != nil { return err } @@ -151,7 +152,7 @@ func ValidateCommand(command string, args []string) error { if len(args) < 1 { return fmt.Errorf("add requires text argument") } - case "toggle", "delete": + case "toggle", "delete", "done", "undone": if len(args) != 1 { return fmt.Errorf("%s requires exactly one index argument", command) } @@ -162,7 +163,7 @@ func ValidateCommand(command string, args []string) error { default: return fmt.Errorf("unknown command: %s", command) } - if command == "toggle" || command == "delete" || command == "edit" { + if command == "toggle" || command == "delete" || command == "edit" || command == "done" || command == "undone" { index, err := strconv.Atoi(args[0]) if err != nil || index < 1 { return fmt.Errorf("invalid index: use a positive integer") @@ -187,9 +188,53 @@ func (s Service) HandleCommand(command string, cmdArgs []string, filePath string case "edit": idx, _ := strconv.Atoi(cmdArgs[0]) return s.EditTodo(filePath, idx, strings.Join(cmdArgs[1:], " ")) + case "done", "undone": + idx, _ := strconv.Atoi(cmdArgs[0]) + return s.SetTodoChecked(filePath, idx, command == "done") case "delete": idx, _ := strconv.Atoi(cmdArgs[0]) return s.DeleteTodo(filePath, idx) } return fmt.Errorf("unknown command: %s", command) } + +// readForMutation checks the supplied revision against the same snapshot that +// will be edited, avoiding a separate-read race before the guarded save. +func (s Service) readForMutation(path string) (*markdown.FileModel, error) { + fm, err := s.Store.ReadFile(path) + if err != nil { + return nil, err + } + if s.ExpectedRevision != "" { + if err := fm.RequireRevision(s.ExpectedRevision); err != nil { + return nil, err + } + } + return fm, nil +} + +// SetTodoChecked is idempotent: a retry does not toggle the task back or rewrite +// an already-correct file. Supplied revision preconditions still apply. +func (s Service) SetTodoChecked(path string, index int, checked bool) error { + fm, err := s.readForMutation(path) + if err != nil { + return err + } + if index < 1 || index > len(fm.Todos) { + return fmt.Errorf("invalid index %d", index) + } + if fm.Todos[index-1].Checked != checked { + if _, err := editor.Apply(fm, editor.Action{Kind: editor.SetChecked, Index: index - 1, Checked: checked}); err != nil { + return err + } + if err := s.Store.WriteFile(path, fm); err != nil { + return err + } + } + state := "open" + if checked { + state = "done" + } + _, err = fmt.Fprintf(s.output(), "%s: %s\n", state, fm.Todos[index-1].Text) + return err +} diff --git a/internal/cmd/completion.go b/internal/cmd/completion.go new file mode 100644 index 0000000..5a482eb --- /dev/null +++ b/internal/cmd/completion.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "fmt" + "io" +) + +// WriteCompletion emits static shell code. It never opens task files, queries +// history, or evaluates document text as shell syntax. +func WriteCompletion(out io.Writer, shell string) error { + var script string + switch shell { + case "bash": + script = bashCompletion + case "zsh": + script = zshCompletion + case "fish": + script = fishCompletion + default: + return fmt.Errorf("unsupported completion shell %q; use bash, zsh, or fish", shell) + } + _, err := io.WriteString(out, script) + return err +} + +const bashCompletion = `# Load with: source <(tdx completion bash) +_tdx_complete() { + local cur prev word choices i + cur=${COMP_WORDS[COMP_CWORD]} + prev=${COMP_WORDS[COMP_CWORD-1]} + COMPREPLY=() + for ((i=1; i<COMP_CWORD; i++)); do + [[ ${COMP_WORDS[i]} != -- ]] || return + done + case "$prev" in + --file|-f) return ;; + --status) choices='all open done' ;; + --due) choices='all none overdue today week' ;; + --priority) choices='0 1 2 3 4' ;; + --tag|--section|--if-revision|--max-visible|-m) return ;; + completion) choices='bash zsh fish' ;; + *) choices='list add toggle done undone edit delete revision completion last recent help --file --read-only --manual-save --show-headings --max-visible --json --with-revision --if-revision --status --tag --priority --due --section --help --version' ;; + esac + while IFS= read -r word; do + COMPREPLY+=("$word") + done < <(compgen -W "$choices" -- "$cur") +} +complete -o default -F _tdx_complete tdx +` + +const zshCompletion = `# Load after compinit: source <(tdx completion zsh) +_tdx_complete() { + local curcontext="$curcontext" state line + _arguments -s \ + '(-f --file)'{-f,--file}'[Task file]:file:_files' \ + '(-r --read-only --manual-save)'{-r,--read-only,--manual-save}'[Manual save in TUI; reject CLI writes]' \ + '--show-headings[Display headings]' \ + '(-m --max-visible)'{-m,--max-visible}'[Maximum visible tasks]:count:' \ + '--json[JSON task array]' '--with-revision[Versioned JSON snapshot]' \ + '--if-revision[Require matching document revision]:revision:' \ + '--status[Completion filter]:status:(all open done)' \ + '*--tag[Require tag]:tag:' \ + '--priority[Priority filter]:priority:(0 1 2 3 4)' \ + '--due[Due date filter]:due:(all none overdue today week)' \ + '--section[Heading and its subsections]:heading:' \ + '(-h --help)'{-h,--help}'[Show help]' \ + '(-v --version)'{-v,--version}'[Show version]' \ + '*:argument:->arguments' + if [[ $state == arguments ]]; then + if [[ ${words[CURRENT-1]} == completion ]]; then + compadd bash zsh fish + else + compadd list add toggle done undone edit delete revision completion last recent help + _files + fi + fi +} +compdef _tdx_complete tdx +` + +const fishCompletion = `# Save to ~/.config/fish/completions/tdx.fish +complete -c tdx -n __fish_use_subcommand -a 'list add toggle done undone edit delete revision completion last recent help' +complete -c tdx -n '__fish_seen_subcommand_from completion' -f -a 'bash zsh fish' +complete -c tdx -s f -l file -r -d 'Task file' +complete -c tdx -s r -l read-only -d 'Manual save in TUI; reject CLI writes' +complete -c tdx -l manual-save -d 'Alias for read-only' +complete -c tdx -l show-headings -d 'Display headings' +complete -c tdx -s m -l max-visible -x -d 'Maximum visible tasks' +complete -c tdx -l json -d 'JSON task array' +complete -c tdx -l with-revision -d 'Versioned JSON snapshot' +complete -c tdx -l if-revision -x -d 'Require matching revision' +complete -c tdx -l status -x -a 'all open done' +complete -c tdx -l tag -x -d 'Require tag' +complete -c tdx -l priority -x -a '0 1 2 3 4' +complete -c tdx -l due -x -a 'all none overdue today week' +complete -c tdx -l section -x -d 'Heading and its subsections' +complete -c tdx -s h -l help -d 'Show help' +complete -c tdx -s v -l version -d 'Show version' +` diff --git a/internal/cmd/completion_test.go b/internal/cmd/completion_test.go new file mode 100644 index 0000000..e15ab0d --- /dev/null +++ b/internal/cmd/completion_test.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "bytes" + "os/exec" + "strings" + "testing" +) + +func TestCompletionShellSyntax(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish"} { + t.Run(shell, func(t *testing.T) { + var out bytes.Buffer + if err := WriteCompletion(&out, shell); err != nil { + t.Fatal(err) + } + if _, err := exec.LookPath(shell); err != nil { + t.Skipf("%s unavailable for syntax check", shell) + } + command := exec.Command(shell, "-n") + command.Stdin = &out + if result, err := command.CombinedOutput(); err != nil { + t.Fatalf("invalid %s syntax: %v\n%s", shell, err, result) + } + }) + } +} + +func TestBashCompletionValuesAndLiteralText(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash unavailable") + } + for _, tt := range []struct{ words, want string }{ + {"tdx list --status o", "open"}, {"tdx completion z", "zsh"}, {"tdx add -- --st", ""}, + } { + script := bashCompletion + "\nCOMP_WORDS=(" + tt.words + "); COMP_CWORD=$((${#COMP_WORDS[@]}-1)); _tdx_complete; printf '%s' \"${COMPREPLY[*]}\"\n" + command := exec.Command("bash") + command.Stdin = strings.NewReader(script) + result, err := command.CombinedOutput() + if err != nil || string(result) != tt.want { + t.Fatalf("%s: %q %v", tt.words, result, err) + } + } +} diff --git a/internal/cmd/list.go b/internal/cmd/list.go index 792b165..2a5ba80 100644 --- a/internal/cmd/list.go +++ b/internal/cmd/list.go @@ -3,15 +3,21 @@ package cmd import ( "encoding/json" "fmt" + "github.com/niklas-heer/tdx/internal/markdown" "io" "slices" + "time" ) // ListOptions applies equally to human-readable and JSON task queries. type ListOptions struct { - JSON bool - Status string - Tags []string + JSON bool + Status string + Tags []string + Priority *int + Due string + Section string + WithRevision bool } // Task is the scripting representation. Index and ParentIndex are one-based; @@ -29,15 +35,22 @@ type Task struct { // WriteList writes a query result without formatting JSON with terminal styles. func (s Service) WriteList(out io.Writer, filePath string, opts ListOptions) error { - if opts.Status != "" && opts.Status != "all" && opts.Status != "open" && opts.Status != "done" { - return fmt.Errorf("status must be all, open, or done") + if err := opts.Validate(); err != nil { + return err } fm, err := s.Store.ReadFile(filePath) if err != nil { return err } + sectionMatches := matchingSections(fm, opts.Section) tasks := make([]Task, 0, len(fm.Todos)) for _, todo := range fm.Todos { + if opts.Priority != nil && todo.Priority != *opts.Priority { + continue + } + if !matchesDue(todo, opts.Due) || (sectionMatches != nil && !sectionMatches[todo.Index-1]) { + continue + } if opts.Status == "open" && todo.Checked || opts.Status == "done" && !todo.Checked { continue } @@ -62,10 +75,17 @@ func (s Service) WriteList(out io.Writer, filePath string, opts ListOptions) err } tasks = append(tasks, task) } - if opts.JSON { + if opts.JSON || opts.WithRevision { encoder := json.NewEncoder(out) encoder.SetIndent("", " ") encoder.SetEscapeHTML(false) + if opts.WithRevision { + revision, err := fm.RevisionToken() + if err != nil { + return err + } + return encoder.Encode(ListSnapshot{SchemaVersion: 1, Revision: revision, Tasks: tasks}) + } return encoder.Encode(tasks) } if len(tasks) == 0 { @@ -88,3 +108,64 @@ func (s Service) WriteList(out io.Writer, filePath string, opts ListOptions) err func WriteList(out io.Writer, filePath string, opts ListOptions) error { return (Service{}).WriteList(out, filePath, opts) } + +// ListSnapshot is the opt-in versioned envelope; ordinary --json stays an array. +type ListSnapshot struct { + SchemaVersion int `json:"schema_version"` + Revision string `json:"revision"` + Tasks []Task `json:"tasks"` +} + +func (opts ListOptions) Validate() error { + if opts.Status != "" && opts.Status != "all" && opts.Status != "open" && opts.Status != "done" { + return fmt.Errorf("status must be all, open, or done") + } + if opts.Priority != nil && *opts.Priority < 0 { + return fmt.Errorf("priority must be a non-negative integer (0 means unset)") + } + switch opts.Due { + case "", "all", "none", "overdue", "today", "week": + default: + if _, err := time.Parse("2006-01-02", opts.Due); err != nil { + return fmt.Errorf("due must be all, none, overdue, today, week, or YYYY-MM-DD") + } + } + return nil +} + +func matchesDue(todo markdown.Todo, due string) bool { + switch due { + case "none": + return todo.DueDate == nil + case "", "all", "overdue", "today", "week": + return todo.HasDueDateFilter(due) + default: + return todo.DueDate != nil && todo.DueDate.Format("2006-01-02") == due + } +} + +// A section includes its descendants. Repeated titles select all matching +// sections, preserving document indexes even for overlapping parent sections. +func matchingSections(fm *markdown.FileModel, title string) []bool { + if title == "" { + return nil + } + matches := make([]bool, len(fm.Todos)) + headings := fm.GetHeadings() + for i, h := range headings { + if h.Text != title { + continue + } + end := len(fm.Todos) + for _, next := range headings[i+1:] { + if next.Level <= h.Level { + end = next.BeforeTodoIndex + break + } + } + for task := h.BeforeTodoIndex; task >= 0 && task < end; task++ { + matches[task] = true + } + } + return matches +} diff --git a/internal/config/views.go b/internal/config/views.go new file mode 100644 index 0000000..9614c40 --- /dev/null +++ b/internal/config/views.go @@ -0,0 +1,171 @@ +package config + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "unicode" +) + +// SectionRef identifies a section by its heading ancestry, surviving insertions. +// Occurrence disambiguates repeated headings with the same ancestry. +type SectionRef struct { + Path []string `json:"path"` + Occurrence int `json:"occurrence"` +} +type SavedView struct { + Tags []string `json:"tags,omitempty"` + Priorities []int `json:"priorities,omitempty"` + Due string `json:"due,omitempty"` + FilterDone bool `json:"filter_done"` + Focus *SectionRef `json:"focus,omitempty"` + Folded []SectionRef `json:"folded,omitempty"` + ShowHeadings bool `json:"show_headings"` +} +type SavedViews struct { + Active string `json:"active,omitempty"` + Views map[string]SavedView `json:"views"` +} +type ViewStore interface { + Load(string) (*SavedViews, error) + Save(string, *SavedViews) error +} +type FileViewStore struct{ Dir string } + +func NewFileViewStore(dir string) ViewStore { return FileViewStore{Dir: dir} } +func (s FileViewStore) path(file string) (string, error) { + canonical, err := filepath.Abs(file) + if err != nil { + return "", err + } + // Resolve the existing ancestor too, so new files beneath a symlinked + // directory share the same key before and after creation. + unresolved := []string{} + for { + resolved, resolveErr := filepath.EvalSymlinks(canonical) + if resolveErr == nil { + canonical = resolved + break + } + if !os.IsNotExist(resolveErr) { + return "", resolveErr + } + parent := filepath.Dir(canonical) + if parent == canonical { + return "", resolveErr + } + unresolved = append(unresolved, filepath.Base(canonical)) + canonical = parent + } + for i := len(unresolved) - 1; i >= 0; i-- { + canonical = filepath.Join(canonical, unresolved[i]) + } + sum := sha256.Sum256([]byte(filepath.Clean(canonical))) + dir := s.Dir + if dir == "" { + dir, err = GetConfigDir() + if err != nil { + return "", err + } + } + return filepath.Join(dir, "views", hex.EncodeToString(sum[:])+".json"), nil +} +func (s FileViewStore) Load(file string) (*SavedViews, error) { + path, err := s.path(file) + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return &SavedViews{Views: map[string]SavedView{}}, nil + } + if err != nil { + return nil, err + } + var state SavedViews + if err = json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("read saved views: %w", err) + } + if state.Views == nil { + state.Views = map[string]SavedView{} + } + if err := validateViews(&state); err != nil { + return nil, err + } + return &state, nil +} +func (s FileViewStore) Save(file string, state *SavedViews) error { + path, err := s.path(file) + if err != nil { + return err + } + if err := validateViews(state); err != nil { + return err + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + temp, err := os.CreateTemp(filepath.Dir(path), ".views-*") + if err != nil { + return err + } + defer func() { _ = os.Remove(temp.Name()) }() + if _, err = temp.Write(data); err != nil { + _ = temp.Close() + return err + } + if err = temp.Sync(); err != nil { + _ = temp.Close() + return err + } + if err = temp.Close(); err != nil { + return err + } + return os.Rename(temp.Name(), path) +} + +func validateViews(state *SavedViews) error { + if state == nil { + return fmt.Errorf("saved views cannot be nil") + } + validRef := func(ref SectionRef) bool { + if len(ref.Path) == 0 || ref.Occurrence < 1 { + return false + } + // Empty heading labels are valid Markdown and remain part of the + // ancestry; path length and occurrence identify the section. + return true + } + for name, view := range state.Views { + if strings.TrimSpace(name) == "" || strings.IndexFunc(name, unicode.IsControl) >= 0 { + return fmt.Errorf("view name must contain text without control characters") + } + switch view.Due { + case "", "all", "today", "week", "overdue": + default: + return fmt.Errorf("saved view %q has invalid due filter", name) + } + for _, p := range view.Priorities { + if p < 1 { + return fmt.Errorf("saved view %q has invalid priority", name) + } + } + if view.Focus != nil && !validRef(*view.Focus) { + return fmt.Errorf("saved view %q has invalid section focus", name) + } + for _, fold := range view.Folded { + if !validRef(fold) { + return fmt.Errorf("saved view %q has invalid section fold", name) + } + } + } + return nil +} diff --git a/internal/config/views_test.go b/internal/config/views_test.go new file mode 100644 index 0000000..8a54666 --- /dev/null +++ b/internal/config/views_test.go @@ -0,0 +1,92 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestViewStoreCanonicalFilesAndIsolation(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "todo.md") + if err := os.WriteFile(file, []byte("- [ ] task\n"), 0600); err != nil { + t.Fatal(err) + } + store := FileViewStore{Dir: t.TempDir()} + state := &SavedViews{Active: "Work", Views: map[string]SavedView{"Work": {FilterDone: true}}} + if err := store.Save(file, state); err != nil { + t.Fatal(err) + } + alias := filepath.Join(dir, "alias.md") + if err := os.Symlink(file, alias); err == nil { + got, err := store.Load(alias) + if err != nil || got.Active != "Work" { + t.Fatalf("canonical alias: %+v %v", got, err) + } + } + other, err := store.Load(filepath.Join(dir, "other.md")) + if err != nil || len(other.Views) != 0 { + t.Fatal("views leaked across files") + } + got, _ := store.Load(file) + delete(got.Views, "Work") + got.Active = "" + if err := store.Save(file, got); err != nil { + t.Fatal(err) + } + got, _ = store.Load(file) + if len(got.Views) != 0 { + t.Fatal("delete did not persist") + } + path, _ := store.path(file) + if err := os.WriteFile(path, []byte("broken"), 0600); err != nil { + t.Fatal(err) + } + if _, err := store.Load(file); err == nil { + t.Fatal("corrupt views silently ignored") + } +} + +func TestViewStoreValidationAndMissingCanonicalParent(t *testing.T) { + store := FileViewStore{Dir: t.TempDir()} + file := filepath.Join(t.TempDir(), "todo.md") + for _, state := range []*SavedViews{nil, {Views: map[string]SavedView{"bad": {Due: "invalid"}}}, {Views: map[string]SavedView{"bad": {Focus: &SectionRef{}}}}} { + if err := store.Save(file, state); err == nil { + t.Fatal("invalid state accepted") + } + } + dir := t.TempDir() + real := filepath.Join(dir, "real") + if err := os.Mkdir(real, 0700); err != nil { + t.Fatal(err) + } + alias := filepath.Join(dir, "alias") + if err := os.Symlink(real, alias); err != nil { + t.Skip(err) + } + a, _ := store.path(filepath.Join(real, "new", "todo.md")) + b, _ := store.path(filepath.Join(alias, "new", "todo.md")) + if a != b { + t.Fatal("nonexistent file under symlink parent not canonical") + } +} + +func TestViewStoreAllowsEmptySectionLabels(t *testing.T) { + store := FileViewStore{Dir: t.TempDir()} + file := filepath.Join(t.TempDir(), "todo.md") + state := &SavedViews{Active: "Blank", Views: map[string]SavedView{"Blank": { + Focus: &SectionRef{Path: []string{"", "Child"}, Occurrence: 1}, + Folded: []SectionRef{{Path: []string{""}, Occurrence: 2}}, + }}} + if err := store.Save(file, state); err != nil { + t.Fatal(err) + } + restored, err := store.Load(file) + if err != nil { + t.Fatal(err) + } + view := restored.Views["Blank"] + if view.Focus.Path[0] != "" || len(view.Focus.Path) != 2 || view.Folded[0].Path[0] != "" || view.Folded[0].Occurrence != 2 { + t.Fatalf("blank labels changed: %+v", view) + } +} diff --git a/internal/editor/actions.go b/internal/editor/actions.go index 0053683..50f3c09 100644 --- a/internal/editor/actions.go +++ b/internal/editor/actions.go @@ -3,7 +3,6 @@ package editor import ( "fmt" - "sort" "github.com/niklas-heer/tdx/internal/markdown" ) @@ -59,10 +58,10 @@ func Apply(doc *markdown.FileModel, action Action) (int, error) { } switch action.Kind { case Add: - doc.AddTodoItem(action.Text, action.Checked) - return len(doc.Todos) - 1, nil + err := doc.AddTodoItemChecked(action.Text, action.Checked) + return len(doc.Todos) - 1, err case Insert: - return doc.InsertTodoItemAfter(index, action.Text, action.Checked), nil + return doc.InsertTodoItemAfterChecked(index, action.Text, action.Checked) case AddInSection: return doc.AddTodoInSection(index, action.Text) case Edit: @@ -74,9 +73,11 @@ func Apply(doc *markdown.FileModel, action Action) (int, error) { case Delete: return index, doc.DeleteTodoItem(index) case Move: - return action.Target, doc.MoveTodoItem(index, action.Target) + moved := moveResultIndex(doc.Todos, index, action.Target, index < action.Target) + return moved, doc.MoveTodoItem(index, action.Target) case MoveToPosition: - return action.Target, doc.MoveTodoItemToPosition(index, action.Target, action.InsertAfter) + moved := moveResultIndex(doc.Todos, index, action.Target, action.InsertAfter) + return moved, doc.MoveTodoItemToPosition(index, action.Target, action.InsertAfter) case Indent: return index, doc.IndentTodoItem(index) case Outdent: @@ -95,37 +96,58 @@ func Apply(doc *markdown.FileModel, action Action) (int, error) { } return index, nil case ClearDone: + snapshot := doc.Clone() for i := len(doc.Todos) - 1; i >= 0; i-- { if doc.Todos[i].Checked { if _, err := Apply(doc, Action{Kind: Delete, Index: i}); err != nil { + doc.RestoreContent(snapshot) return -1, err } } } return index, nil case SortDone, SortDue, SortPriority: - SortTodosInSections(doc.Todos, doc.GetHeadings(), func(todos []markdown.Todo) { - sort.SliceStable(todos, func(i, j int) bool { - a, b := todos[i], todos[j] - switch action.Kind { - case SortDone: - return !a.Checked && b.Checked - case SortDue: - if a.DueDate == nil { - return false - } - return b.DueDate == nil || a.DueDate.Before(*b.DueDate) - default: - if a.Priority == 0 { - return false - } - return b.Priority == 0 || a.Priority < b.Priority + return index, doc.SortTodos(func(a, b markdown.Todo) bool { + switch action.Kind { + case SortDone: + return !a.Checked && b.Checked + case SortDue: + if a.DueDate == nil { + return false } - }) + return b.DueDate == nil || a.DueDate.Before(*b.DueDate) + default: + if a.Priority == 0 { + return false + } + return b.Priority == 0 || a.Priority < b.Priority + } }) - markdown.RebuildFileStructure(doc) - return index, nil default: return -1, fmt.Errorf("unknown editor action: %q", action.Kind) } } + +func moveResultIndex(todos []markdown.Todo, from, target int, after bool) int { + if from == target { + return from + } + if from < 0 || target < 0 || from >= len(todos) || target >= len(todos) { + return -1 + } + end := from + 1 + for end < len(todos) && todos[end].Depth > todos[from].Depth { + end++ + } + insert := target + if after { + insert++ + for insert < len(todos) && todos[insert].Depth > todos[target].Depth { + insert++ + } + } + if insert > from { + insert -= end - from + } + return insert +} diff --git a/internal/editor/source_test.go b/internal/editor/source_test.go new file mode 100644 index 0000000..3ea9638 --- /dev/null +++ b/internal/editor/source_test.go @@ -0,0 +1,57 @@ +package editor + +import ( + "testing" + + "github.com/niklas-heer/tdx/internal/markdown" +) + +func TestMoveResultIndexIncludesSubtrees(t *testing.T) { + for _, tc := range []struct { + action Action + want int + }{ + {Action{Kind: Move, Index: 0, Target: 3}, 1}, + {Action{Kind: MoveToPosition, Index: 3, Target: 0, InsertAfter: true}, 3}, + {Action{Kind: MoveToPosition, Index: 3, Target: 0}, 0}, + {Action{Kind: MoveToPosition, Index: 1, Target: 0, InsertAfter: true}, 2}, + } { + f := markdown.ParseMarkdown("- [ ] duplicate\n - [ ] child\n - [ ] child\n- [ ] duplicate\n") + index, err := Apply(f, tc.action) + if err != nil { + t.Fatal(err) + } + if index != tc.want { + t.Fatalf("%+v: got index %d; want %d", tc.action, index, tc.want) + } + } +} + +func TestRejectedClearDoneRestoresWholeDocument(t *testing.T) { + source := "> - [x] quoted\n\n- [x] removable\n\n[ref]: /keep\n" + f := markdown.ParseMarkdown(source) + if _, err := Apply(f, Action{Kind: ClearDone}); err == nil { + t.Fatal("expected unsupported quoted deletion") + } + if got := markdown.SerializeMarkdown(f); got != source { + t.Fatalf("partial bulk edit: %q", got) + } +} + +func TestSourceUndoAcrossStructuralActions(t *testing.T) { + source := "# Work\r\n\r\n- [ ] parent\r\n body [guide]\r\n - [x] child\r\n- [ ] sibling\r\n\r\n[guide]: /original\r\n" + f := markdown.ParseMarkdown(source) + for _, action := range []Action{{Kind: Edit, Text: "changed"}, {Kind: Delete}, {Kind: Move, Target: 2}, {Kind: SortDone}, {Kind: RenameHeading, Text: "Renamed"}} { + var history History + history.Push(f) + if _, err := Apply(f, action); err != nil { + t.Fatal(err) + } + if !history.Undo(f) { + t.Fatal("undo unavailable") + } + if got := markdown.SerializeMarkdown(f); got != source { + t.Fatalf("%s undo changed source: %q", action.Kind, got) + } + } +} diff --git a/internal/markdown/ast.go b/internal/markdown/ast.go index 7c4096c..28e1a09 100644 --- a/internal/markdown/ast.go +++ b/internal/markdown/ast.go @@ -162,6 +162,15 @@ type Heading struct { // ExtractHeadings walks the AST and extracts all headings with their positions relative to todos func (doc *ASTDocument) ExtractHeadings() []Heading { + // Count the same exposed tasks as ExtractTodos. Checkboxes in opaque nested + // containers (for example a quoted example inside a task body) are not indexes. + if doc.checkboxes == nil { + doc.ExtractTodos() + } + indexed := make(map[ast.Node]bool, len(doc.checkboxes)) + for _, checkbox := range doc.checkboxes { + indexed[checkbox] = true + } var headings []Heading nextTodoIndex := 0 @@ -194,7 +203,7 @@ func (doc *ASTDocument) ExtractHeadings() []Heading { } // Count todos as we encounter them - if node.Kind() == extast.KindTaskCheckBox { + if indexed[node] { nextTodoIndex++ } @@ -292,57 +301,32 @@ func (doc *ASTDocument) extractTodoText(listItem ast.Node, checkbox ast.Node) st // FindTodoNode finds the TodoNode for a given todo index func (doc *ASTDocument) FindTodoNode(todoIndex int) (*TodoNode, error) { - currentIndex := 0 - - var found *TodoNode - _ = ast.Walk(doc.AST, func(node ast.Node, entering bool) (ast.WalkStatus, error) { - if !entering || found != nil { - return ast.WalkContinue, nil - } - - if node.Kind() == extast.KindTaskCheckBox { - if currentIndex == todoIndex { - checkbox := node.(*extast.TaskCheckBox) - textBlock := checkbox.Parent() - if textBlock == nil { - return ast.WalkContinue, nil - } - listItem := textBlock.Parent() - if listItem == nil { - return ast.WalkContinue, nil - } - - // Find the text node containing the checkbox text - var textNode *ast.Text - _ = ast.Walk(textBlock, func(n ast.Node, entering bool) (ast.WalkStatus, error) { - if entering { - if tn, ok := n.(*ast.Text); ok { - textNode = tn - return ast.WalkStop, nil - } - } - return ast.WalkContinue, nil - }) - - found = &TodoNode{ - ListItem: listItem.(*ast.ListItem), - CheckBox: checkbox, - TextNode: textNode, - Checked: checkbox.IsChecked, - } + if doc.checkboxes == nil { + doc.ExtractTodos() + } + if todoIndex < 0 || todoIndex >= len(doc.checkboxes) { + return nil, fmt.Errorf("todo at index %d not found", todoIndex) + } + checkbox := doc.checkboxes[todoIndex] + container := checkbox.Parent() + if container == nil { + return nil, fmt.Errorf("task checkbox has no text container") + } + item, ok := container.Parent().(*ast.ListItem) + if !ok { + return nil, fmt.Errorf("task checkbox has no list item") + } + var textNode *ast.Text + _ = ast.Walk(container, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + if text, ok := node.(*ast.Text); ok { + textNode = text return ast.WalkStop, nil } - currentIndex++ } - return ast.WalkContinue, nil }) - - if found == nil { - return nil, fmt.Errorf("todo at index %d not found", todoIndex) - } - - return found, nil + return &TodoNode{ListItem: item, CheckBox: checkbox, TextNode: textNode, Checked: checkbox.IsChecked}, nil } // ToggleTodo toggles the checked state of a todo @@ -375,569 +359,3 @@ func (doc *ASTDocument) ToggleTodo(todoIndex int) error { checkbox.IsChecked = !checkbox.IsChecked return nil } - -// invalidateSource switches structural edits to the AST serializer and drops -// indexes whose nodes may have moved or been removed. -func (doc *ASTDocument) invalidateSource() { doc.sourceValid = false; doc.checkboxes = nil } - -// UpdateTodoText updates the text of a todo -func (doc *ASTDocument) UpdateTodoText(todoIndex int, newText string) (err error) { - defer func() { - if err == nil { - doc.invalidateSource() - } - }() - node, err := doc.FindTodoNode(todoIndex) - if err != nil { - return err - } - - container := node.CheckBox.Parent() - if container == nil { - return fmt.Errorf("todo checkbox has no text container") - } - - for child := container.FirstChild(); child != nil; { - next := child.NextSibling() - if child != node.CheckBox { - container.RemoveChild(container, child) - } - child = next - } - if newText != "" { - start := len(doc.Source) - doc.Source = append(doc.Source, newText...) - container.AppendChild(container, ast.NewTextSegment(text.NewSegment(start, len(doc.Source)))) - } - return nil -} - -// DeleteTodo removes a todo from the AST, promoting any children to the parent level -func (doc *ASTDocument) DeleteTodo(todoIndex int) (err error) { - defer func() { - if err == nil { - doc.invalidateSource() - } - }() - node, err := doc.FindTodoNode(todoIndex) - if err != nil { - return err - } - - listItem := node.ListItem - parentList := listItem.Parent() - if parentList == nil { - return fmt.Errorf("list item has no parent") - } - - // Find any nested list (children) within this list item - var nestedList *ast.List - for child := listItem.FirstChild(); child != nil; child = child.NextSibling() { - if list, ok := child.(*ast.List); ok { - nestedList = list - break - } - } - - // If there are children, promote them to the parent level - if nestedList != nil { - // Collect all children first (we'll insert them after the deleted item's position) - var children []*ast.ListItem - for child := nestedList.FirstChild(); child != nil; child = child.NextSibling() { - if li, ok := child.(*ast.ListItem); ok { - children = append(children, li) - } - } - - // Remove the nested list from the item being deleted - listItem.RemoveChild(listItem, nestedList) - - // Insert children into parent list at the deleted item's position - // Insert in forward order, updating insertion point each time - var insertAfter ast.Node = listItem - for _, child := range children { - // Detach from nested list - nestedList.RemoveChild(nestedList, child) - - // Insert into parent list after the insertion point - parentList.InsertAfter(parentList, insertAfter, child) - insertAfter = child // Next child goes after this one - } - } - - // Remove the list item itself - parentList.RemoveChild(parentList, listItem) - - return nil -} - -// AddTodo adds a new todo to the AST -func (doc *ASTDocument) AddTodo(todoText string, checked bool) (err error) { - defer func() { - if err == nil { - doc.invalidateSource() - } - }() - // Find the last list in the document, or create one - var lastList *ast.List - - _ = ast.Walk(doc.AST, func(node ast.Node, entering bool) (ast.WalkStatus, error) { - if entering { - if list, ok := node.(*ast.List); ok { - // Check if this list contains task items - hasTaskItems := false - for child := list.FirstChild(); child != nil; child = child.NextSibling() { - if listItem, ok := child.(*ast.ListItem); ok { - // Check if this list item has a checkbox - _ = ast.Walk(listItem, func(n ast.Node, entering bool) (ast.WalkStatus, error) { - if entering && n.Kind() == extast.KindTaskCheckBox { - hasTaskItems = true - return ast.WalkStop, nil - } - return ast.WalkContinue, nil - }) - if hasTaskItems { - break - } - } - } - if hasTaskItems { - lastList = list - } - } - } - return ast.WalkContinue, nil - }) - - // If no list found, create one and append to document - if lastList == nil { - lastList = ast.NewList(0) // 0 = unordered list with '-' - lastList.Marker = '-' - doc.AST.AppendChild(doc.AST, lastList) - } - - // Append new text to source (this is a bit of a hack but necessary for AST) - newTodoText := []byte(todoText) - sourceStart := len(doc.Source) - doc.Source = append(doc.Source, newTodoText...) - - // Create new list item with checkbox and text - listItem := ast.NewListItem(0) - - // Create paragraph (required for task list items) - para := ast.NewParagraph() - listItem.AppendChild(listItem, para) - - // Create checkbox - checkbox := extast.NewTaskCheckBox(checked) - para.AppendChild(para, checkbox) - - // Create text node pointing to the new source bytes - textNode := ast.NewText() - textNode.Segment = text.NewSegment(sourceStart, sourceStart+len(newTodoText)) - para.AppendChild(para, textNode) - - // Append to list - lastList.AppendChild(lastList, listItem) - - return nil -} - -// InsertTodoAfter inserts a new todo after the specified index -// If afterIndex is -1, inserts at the beginning of the first todo list -func (doc *ASTDocument) InsertTodoAfter(afterIndex int, todoText string, checked bool) (err error) { - defer func() { - if err == nil { - doc.invalidateSource() - } - }() - var node *TodoNode - if afterIndex >= 0 { - node, err = doc.FindTodoNode(afterIndex) - if err != nil { - return err - } - if node.ListItem.Parent() == nil { - return fmt.Errorf("list item has no parent") - } - } - // Append new text to source - newTodoText := []byte(todoText) - sourceStart := len(doc.Source) - doc.Source = append(doc.Source, newTodoText...) - - // Create new list item with checkbox and text - newListItem := ast.NewListItem(0) - para := ast.NewParagraph() - newListItem.AppendChild(newListItem, para) - checkbox := extast.NewTaskCheckBox(checked) - para.AppendChild(para, checkbox) - textNode := ast.NewText() - textNode.Segment = text.NewSegment(sourceStart, sourceStart+len(newTodoText)) - para.AppendChild(para, textNode) - - if afterIndex < 0 { - // Insert at the beginning of the first todo list - var firstList *ast.List - _ = ast.Walk(doc.AST, func(node ast.Node, entering bool) (ast.WalkStatus, error) { - if entering { - if list, ok := node.(*ast.List); ok { - hasTaskItems := false - for child := list.FirstChild(); child != nil; child = child.NextSibling() { - if listItem, ok := child.(*ast.ListItem); ok { - _ = ast.Walk(listItem, func(n ast.Node, entering bool) (ast.WalkStatus, error) { - if entering && n.Kind() == extast.KindTaskCheckBox { - hasTaskItems = true - return ast.WalkStop, nil - } - return ast.WalkContinue, nil - }) - if hasTaskItems { - break - } - } - } - if hasTaskItems { - firstList = list - return ast.WalkStop, nil - } - } - } - return ast.WalkContinue, nil - }) - - if firstList == nil { - // No existing list, create one - firstList = ast.NewList(0) - firstList.Marker = '-' - doc.AST.AppendChild(doc.AST, firstList) - firstList.AppendChild(firstList, newListItem) - } else { - // Insert before the first child - firstChild := firstList.FirstChild() - if firstChild != nil { - firstList.InsertBefore(firstList, firstChild, newListItem) - } else { - firstList.AppendChild(firstList, newListItem) - } - } - return nil - } - - // Get the parent list - parentList := node.ListItem.Parent() - if parentList == nil { - return fmt.Errorf("list item has no parent") - } - - // Insert after the found list item - nextSibling := node.ListItem.NextSibling() - if nextSibling != nil { - parentList.InsertBefore(parentList, nextSibling, newListItem) - } else { - parentList.AppendChild(parentList, newListItem) - } - - return nil -} - -// SwapTodos swaps two todos in the AST -// MoveTodoToPosition moves a todo by removing it and inserting before/after another todo -// insertAfter: if true, insert after targetIndex; if false, insert before targetIndex -func (doc *ASTDocument) MoveTodoToPosition(fromIndex, targetIndex int, insertAfter bool) (err error) { - defer func() { - if err == nil { - doc.invalidateSource() - } - }() - if fromIndex == targetIndex { - return nil // No-op - } - - // Get the node to move - nodeFrom, err := doc.FindTodoNode(fromIndex) - if err != nil { - return err - } - - // Get the target node - nodeTarget, err := doc.FindTodoNode(targetIndex) - if err != nil { - return err - } - - // Remove from current parent - parentFrom := nodeFrom.ListItem.Parent() - parentFrom.RemoveChild(parentFrom, nodeFrom.ListItem) - - // Insert at target position - parentTarget := nodeTarget.ListItem.Parent() - if insertAfter { - parentTarget.InsertAfter(parentTarget, nodeTarget.ListItem, nodeFrom.ListItem) - } else { - parentTarget.InsertBefore(parentTarget, nodeTarget.ListItem, nodeFrom.ListItem) - } - - return nil -} - -// MoveTodo moves a todo from fromIndex to toIndex (shifts other todos) -// Deprecated: Use MoveTodoToPosition for more explicit control -func (doc *ASTDocument) MoveTodo(fromIndex, toIndex int) (err error) { - defer func() { - if err == nil { - doc.invalidateSource() - } - }() - if fromIndex == toIndex { - return nil // No-op - } - - // Get both nodes BEFORE any modifications - nodeFrom, err := doc.FindTodoNode(fromIndex) - if err != nil { - return err - } - - nodeTo, err := doc.FindTodoNode(toIndex) - if err != nil { - return err - } - - // Get parents - parentFrom := nodeFrom.ListItem.Parent() - parentTo := nodeTo.ListItem.Parent() - - // Remove the moving node from its current position - parentFrom.RemoveChild(parentFrom, nodeFrom.ListItem) - - // Insert at new position relative to nodeTo - if fromIndex < toIndex { - // Moving down: insert AFTER nodeTo - parentTo.InsertAfter(parentTo, nodeTo.ListItem, nodeFrom.ListItem) - } else { - // Moving up: insert BEFORE nodeTo - parentTo.InsertBefore(parentTo, nodeTo.ListItem, nodeFrom.ListItem) - } - - return nil -} - -// IndentTodo makes a todo a child of its previous sibling (increases depth) -// Returns error if the todo cannot be indented (e.g., first item in list) -func (doc *ASTDocument) IndentTodo(todoIndex int) (err error) { - defer func() { - if err == nil { - doc.invalidateSource() - } - }() - node, err := doc.FindTodoNode(todoIndex) - if err != nil { - return err - } - - // Get the list item and its parent list - listItem := node.ListItem - parentList := listItem.Parent() - if parentList == nil { - return fmt.Errorf("list item has no parent") - } - - // Find the previous sibling (must exist to indent under) - var prevSibling *ast.ListItem - for child := parentList.FirstChild(); child != nil; child = child.NextSibling() { - if child == listItem { - break - } - if li, ok := child.(*ast.ListItem); ok { - prevSibling = li - } - } - - if prevSibling == nil { - return fmt.Errorf("cannot indent: no previous sibling") - } - - // Find or create a nested list in the previous sibling - var nestedList *ast.List - for child := prevSibling.FirstChild(); child != nil; child = child.NextSibling() { - if list, ok := child.(*ast.List); ok { - nestedList = list - break - } - } - - if nestedList == nil { - // Create a new nested list - nestedList = ast.NewList(0) - nestedList.Marker = '-' - prevSibling.AppendChild(prevSibling, nestedList) - } - - // Remove from current parent and add to nested list - parentList.RemoveChild(parentList, listItem) - nestedList.AppendChild(nestedList, listItem) - - return nil -} - -// OutdentTodo moves a todo up one level in the hierarchy (decreases depth) -// Returns error if the todo cannot be outdented (e.g., already at top level) -func (doc *ASTDocument) OutdentTodo(todoIndex int) (err error) { - defer func() { - if err == nil { - doc.invalidateSource() - } - }() - node, err := doc.FindTodoNode(todoIndex) - if err != nil { - return err - } - - // Get the list item and its parent list - listItem := node.ListItem - parentList := listItem.Parent() - if parentList == nil { - return fmt.Errorf("list item has no parent") - } - - // The parent list must be inside a list item for us to outdent - parentListItem := parentList.Parent() - if parentListItem == nil { - return fmt.Errorf("cannot outdent: already at top level") - } - - // Check that the parent is actually a list item - grandparentListItem, ok := parentListItem.(*ast.ListItem) - if !ok { - return fmt.Errorf("cannot outdent: already at top level") - } - - // Get the grandparent list (where we'll insert the item) - grandparentList := grandparentListItem.Parent() - if grandparentList == nil { - return fmt.Errorf("cannot outdent: no grandparent list") - } - - // Remove from current parent - parentList.RemoveChild(parentList, listItem) - - // Insert after the grandparent list item - grandparentList.InsertAfter(grandparentList, grandparentListItem, listItem) - - // If the nested list is now empty, remove it - if parentList.FirstChild() == nil { - grandparentListItem.RemoveChild(grandparentListItem, parentList) - } - - return nil -} - -// SwapTodos swaps two todos in the AST (kept for backward compatibility but prefer MoveTodo) -func (doc *ASTDocument) SwapTodos(index1, index2 int) (err error) { - defer func() { - if err == nil { - doc.invalidateSource() - } - }() - node1, err := doc.FindTodoNode(index1) - if err != nil { - return err - } - - node2, err := doc.FindTodoNode(index2) - if err != nil { - return err - } - - // Get parent (SwapTodos assumes same parent for simplicity) - parent1 := node1.ListItem.Parent() - - // NOTE: SwapTodos is deprecated in favor of MoveTodo for better UX - // MoveTodo provides better insertion behavior and handles cross-parent moves - - // Find what comes before each node (these will be our anchors) - var prev1, prev2 ast.Node - for child := parent1.FirstChild(); child != nil; child = child.NextSibling() { - if child.NextSibling() == node1.ListItem { - prev1 = child - } - if child.NextSibling() == node2.ListItem { - prev2 = child - } - } - - // Check if they are adjacent - adjacent := (node1.ListItem.NextSibling() == node2.ListItem) || (node2.ListItem.NextSibling() == node1.ListItem) - - if adjacent { - // For adjacent nodes, we need special handling - // Determine which comes first - var firstNode, secondNode *TodoNode - var prevFirst ast.Node - - if node1.ListItem.NextSibling() == node2.ListItem { - firstNode, secondNode = node1, node2 - prevFirst = prev1 - } else { - firstNode, secondNode = node2, node1 - prevFirst = prev2 - } - - // Remove both nodes - parent1.RemoveChild(parent1, firstNode.ListItem) - parent1.RemoveChild(parent1, secondNode.ListItem) - - // Insert in swapped order - if prevFirst != nil { - // Insert second after prevFirst - parent1.InsertAfter(parent1, prevFirst, secondNode.ListItem) - // Insert first after second - parent1.InsertAfter(parent1, secondNode.ListItem, firstNode.ListItem) - } else { - // First node was at the beginning - if parent1.FirstChild() != nil { - // Insert at beginning - parent1.InsertBefore(parent1, parent1.FirstChild(), secondNode.ListItem) - } else { - parent1.AppendChild(parent1, secondNode.ListItem) - } - parent1.InsertAfter(parent1, secondNode.ListItem, firstNode.ListItem) - } - } else { - // Non-adjacent nodes: swap them independently - // Remove both from parent - parent1.RemoveChild(parent1, node1.ListItem) - parent1.RemoveChild(parent1, node2.ListItem) - - // After removing node1, if prev2 was node1, we need to adjust - if prev2 == node1.ListItem { - prev2 = prev1 - } - - // Re-insert in swapped order - if prev1 != nil { - parent1.InsertAfter(parent1, prev1, node2.ListItem) - } else { - // node1 was first child - if parent1.FirstChild() != nil { - parent1.InsertBefore(parent1, parent1.FirstChild(), node2.ListItem) - } else { - parent1.AppendChild(parent1, node2.ListItem) - } - } - - if prev2 != nil { - parent1.InsertAfter(parent1, prev2, node1.ListItem) - } else { - // node2 was first child - if parent1.FirstChild() != nil { - parent1.InsertBefore(parent1, parent1.FirstChild(), node1.ListItem) - } else { - parent1.AppendChild(parent1, node1.ListItem) - } - } - } - - return nil -} diff --git a/internal/markdown/parser.go b/internal/markdown/parser.go index c5edbde..03cd0f7 100644 --- a/internal/markdown/parser.go +++ b/internal/markdown/parser.go @@ -224,9 +224,16 @@ func (fm *FileModel) syncTodosToAST() { // AddTodoItem adds a new todo at the end of the file func (fm *FileModel) AddTodoItem(text string, checked bool) { + _ = fm.AddTodoItemChecked(text, checked) +} + +// AddTodoItemChecked reports unsupported source edits to interactive/CLI callers. +func (fm *FileModel) AddTodoItemChecked(text string, checked bool) error { if fm.ast != nil { // Use AST for adding - _ = fm.ast.AddTodo(text, checked) + if err := fm.ast.AddTodo(text, checked); err != nil { + return err + } // Re-extract todos to keep cache in sync fm.Todos = fm.ast.ExtractTodos() } else { @@ -240,26 +247,38 @@ func (fm *FileModel) AddTodoItem(text string, checked bool) { fm.Todos = append(fm.Todos, newTodo) fm.Lines = append(fm.Lines, fmt.Sprintf("- [%s] %s", map[bool]string{true: "x", false: " "}[checked], text)) } + return nil } // InsertTodoItemAfter inserts a new todo after the specified index // If afterIndex is -1, inserts at the beginning // Returns the index of the newly inserted todo func (fm *FileModel) InsertTodoItemAfter(afterIndex int, text string, checked bool) int { + index, _ := fm.InsertTodoItemAfterChecked(afterIndex, text, checked) + return index +} + +// InsertTodoItemAfterChecked returns the actual index after the selected subtree. +func (fm *FileModel) InsertTodoItemAfterChecked(afterIndex int, text string, checked bool) (int, error) { if fm.ast != nil { + insert := afterIndex + 1 + if afterIndex >= 0 && afterIndex < len(fm.Todos) { + for insert < len(fm.Todos) && fm.Todos[insert].Depth > fm.Todos[afterIndex].Depth { + insert++ + } + } // Use AST for inserting - _ = fm.ast.InsertTodoAfter(afterIndex, text, checked) + if err := fm.ast.InsertTodoAfter(afterIndex, text, checked); err != nil { + return -1, err + } // Re-extract todos to keep cache in sync fm.Todos = fm.ast.ExtractTodos() // Return the new index (afterIndex + 1, or 0 if inserting at beginning) - if afterIndex < 0 { - return 0 - } - return afterIndex + 1 + return insert, nil } // Legacy fallback - just append fm.AddTodoItem(text, checked) - return len(fm.Todos) - 1 + return len(fm.Todos) - 1, nil } // UpdateTodoItem updates an existing todo diff --git a/internal/markdown/revision.go b/internal/markdown/revision.go new file mode 100644 index 0000000..be621f3 --- /dev/null +++ b/internal/markdown/revision.go @@ -0,0 +1,49 @@ +package markdown + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +// RevisionToken identifies the exact bytes loaded from disk, including frontmatter. +// A missing file is distinct from an existing empty file. In-memory edits do not +// change the token until a successful save establishes a new disk revision. +func (fm *FileModel) RevisionToken() (string, error) { + if !fm.revisionKnown { + return "", ErrRevisionUnknown + } + if !fm.revision.exists { + return "missing", nil + } + return "sha256:" + hex.EncodeToString(fm.revision.hash[:]), nil +} + +func ValidateRevisionToken(token string) error { + if token == "missing" { + return nil + } + hash, ok := strings.CutPrefix(token, "sha256:") + decoded, err := hex.DecodeString(hash) + if !ok || err != nil || len(decoded) != sha256.Size || hash != strings.ToLower(hash) { + return fmt.Errorf("revision must be 'missing' or sha256:<64 lowercase hex digits>") + } + return nil +} + +// RequireRevision checks a caller's snapshot before applying an edit. WriteFile +// still checks the loaded revision under its lock to reject subsequent changes. +func (fm *FileModel) RequireRevision(expected string) error { + if err := ValidateRevisionToken(expected); err != nil { + return err + } + actual, err := fm.RevisionToken() + if err != nil { + return err + } + if actual != expected { + return fmt.Errorf("%w: revision does not match; query again before editing", ErrFileChanged) + } + return nil +} diff --git a/internal/markdown/sections.go b/internal/markdown/sections.go index d43c5d0..4c2c064 100644 --- a/internal/markdown/sections.go +++ b/internal/markdown/sections.go @@ -5,8 +5,6 @@ import ( "strings" "github.com/yuin/goldmark/ast" - extast "github.com/yuin/goldmark/extension/ast" - "github.com/yuin/goldmark/text" ) func (fm *FileModel) headingNodes() []*ast.Heading { @@ -33,29 +31,32 @@ func validHeading(title string, level int) error { return nil } -func (fm *FileModel) setHeadingText(h *ast.Heading, title string) { - fm.ast.invalidateSource() - h.RemoveChildren(h) - start := len(fm.ast.Source) - fm.ast.Source = append(fm.ast.Source, title...) - h.AppendChild(h, ast.NewTextSegment(text.NewSegment(start, len(fm.ast.Source)))) -} - -// RenameHeading changes exactly the selected heading, even when titles repeat. +// RenameHeading changes exactly the selected heading and preserves its source style. func (fm *FileModel) RenameHeading(index int, title string) error { headings := fm.headingNodes() if index < 0 || index >= len(headings) { return fmt.Errorf("section no longer exists") } - if err := validHeading(title, headings[index].Level); err != nil { + h := headings[index] + if err := validHeading(title, h.Level); err != nil { + return err + } + if h.Lines().Len() == 0 { + return fmt.Errorf("heading source location unavailable") + } + start := h.Lines().At(0).Start + end := h.Lines().At(h.Lines().Len() - 1).Stop + for end > start && (fm.ast.Source[end-1] == '\n' || fm.ast.Source[end-1] == '\r') { + end-- + } + if err := fm.ast.patch([]sourceEdit{{start, end, strings.TrimSpace(title)}}, fm.ast.ExtractTodos(), -1); err != nil { return err } - fm.setHeadingText(headings[index], strings.TrimSpace(title)) + fm.Todos = fm.ast.ExtractTodos() return nil } -// CreateHeading inserts a heading after a selected section and its descendants. -// An index of -1 appends a new top-level section to the document. +// CreateHeading inserts after a selected section and its descendants, or appends. func (fm *FileModel) CreateHeading(afterIndex, level int, title string) (int, error) { if err := validHeading(title, level); err != nil { return -1, err @@ -67,67 +68,94 @@ func (fm *FileModel) CreateHeading(afterIndex, level int, title string) (int, er if afterIndex < -1 || afterIndex >= len(headings) { return -1, fmt.Errorf("section no longer exists") } - h := ast.NewHeading(level) - fm.setHeadingText(h, strings.TrimSpace(title)) - if afterIndex == -1 { - fm.ast.AST.AppendChild(fm.ast.AST, h) - } else { - selected := headings[afterIndex] - parent := selected.Parent() - var before ast.Node - for n := selected.NextSibling(); n != nil; n = n.NextSibling() { - if next, ok := n.(*ast.Heading); ok && next.Level <= selected.Level { - before = n + at := len(fm.ast.Source) + index := len(headings) + if afterIndex >= 0 { + h := headings[afterIndex] + if h.Lines().Len() == 0 { + return -1, fmt.Errorf("empty heading source location unavailable") + } + if h.Parent() != fm.ast.AST { + return -1, fmt.Errorf("creating sections inside nested Markdown blocks is not supported") + } + for i := afterIndex + 1; i < len(headings); i++ { + if headings[i].Level <= h.Level { + if headings[i].Lines().Len() == 0 { + return -1, fmt.Errorf("empty heading source location unavailable") + } + at = fm.ast.lineStart(headings[i].Lines().At(0).Start) + index = i break } } - if before != nil { - parent.InsertBefore(parent, before, h) - } else { - parent.AppendChild(parent, h) - } } - for i, node := range fm.headingNodes() { - if node == h { - return i, nil + newline := fm.ast.newline() + text := strings.Repeat("#", level) + " " + strings.TrimSpace(title) + newline + newline + if at > 0 { + text = newline + text + if fm.ast.Source[at-1] != '\n' { + text = newline + text } } - return -1, fmt.Errorf("could not create section") + if err := fm.ast.patch([]sourceEdit{{at, at, text}}, fm.ast.ExtractTodos(), -1); err != nil { + return -1, err + } + fm.Todos = fm.ast.ExtractTodos() + return index, nil } -// AddTodoInSection inserts into the section's own task list, including empty sections. +// AddTodoInSection appends to the section's immediate list, including empty sections. func (fm *FileModel) AddTodoInSection(index int, title string) (int, error) { headings := fm.headingNodes() if index < 0 || index >= len(headings) { return -1, fmt.Errorf("section no longer exists") } - fm.ast.invalidateSource() + if strings.ContainsAny(title, "\r\n") { + return -1, fmt.Errorf("task title must be a single line") + } h := headings[index] - list, ok := h.NextSibling().(*ast.List) - if !ok || list.IsOrdered() { - list = ast.NewList('-') - h.Parent().InsertAfter(h.Parent(), h, list) - } - start := len(fm.ast.Source) - fm.ast.Source = append(fm.ast.Source, title...) - item := ast.NewListItem(0) - para := ast.NewParagraph() - para.AppendChild(para, extast.NewTaskCheckBox(false)) - para.AppendChild(para, ast.NewTextSegment(text.NewSegment(start, len(fm.ast.Source)))) - item.AppendChild(item, para) - list.AppendChild(list, item) - fm.Todos = fm.ast.ExtractTodos() - // The inserted task is the last task in this immediate list, before any later section. - count := 0 - found := -1 - _ = ast.Walk(fm.ast.AST, func(n ast.Node, entering bool) (ast.WalkStatus, error) { - if entering && n.Kind() == extast.KindTaskCheckBox { - if n.Parent() == para { - found = count - } - count++ + if h.Lines().Len() == 0 { + return -1, fmt.Errorf("empty heading source location unavailable") + } + if h.Parent() != fm.ast.AST { + return -1, fmt.Errorf("adding tasks to nested Markdown sections is not supported") + } + at := fm.ast.lineEnd(h.Lines().At(h.Lines().Len()-1).Stop - 1) + // Setext headings have an extra underline line after the text lines. + if at < len(fm.ast.Source) { + next := strings.TrimSpace(string(fm.ast.Source[at:fm.ast.lineEnd(at)])) + if next != "" && strings.Trim(next, "=-") == "" { + at = fm.ast.lineEnd(at) } - return ast.WalkContinue, nil - }) - return found, nil + } + prefix := "- " + if list, ok := h.NextSibling().(*ast.List); ok && list.LastChild() != nil { + span, err := fm.ast.itemSource(list.LastChild().(*ast.ListItem)) + if err != nil { + return -1, err + } + at = span.end + prefix = span.indent + span.marker + span.space + } + todos := fm.ast.ExtractTodos() + insert := 0 + for insert < len(todos) && todos[insert].LineNo < at { + insert++ + } + expected := append([]Todo{}, todos[:insert]...) + expected = append(expected, Todo{Text: title}) + expected = append(expected, todos[insert:]...) + newline := fm.ast.newline() + text := prefix + "[ ] " + title + newline + if _, ok := h.NextSibling().(*ast.List); !ok { + text = newline + text + newline + } + if at > 0 && fm.ast.Source[at-1] != '\n' { + text = newline + text + } + if err := fm.ast.patch([]sourceEdit{{at, at, text}}, expected, insert); err != nil { + return -1, err + } + fm.Todos = fm.ast.ExtractTodos() + return insert, nil } diff --git a/internal/markdown/sort.go b/internal/markdown/sort.go new file mode 100644 index 0000000..658342f --- /dev/null +++ b/internal/markdown/sort.go @@ -0,0 +1,120 @@ +package markdown + +import ( + "slices" + "sort" + + "github.com/yuin/goldmark/ast" +) + +// SortTodos sorts sibling tasks while carrying each task's complete body and +// descendants. Lists, sections and non-task siblings remain separate groups. +// All groups are prepared in a private document, so a rejected group rolls back +// the whole action instead of leaving a partially sorted document. +func (fm *FileModel) SortTodos(less func(Todo, Todo) bool) error { + if fm.ast == nil { + sort.SliceStable(fm.Todos, func(i, j int) bool { return less(fm.Todos[i], fm.Todos[j]) }) + fm.dirty = true + return nil + } + doc, _ := ParseAST(SerializeAST(fm.ast)) + type location struct{ start, depth int } + var lists []location + _ = ast.Walk(doc.AST, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if list, ok := node.(*ast.List); entering && ok && list.FirstChild() != nil { + start, _ := doc.blockExtent(list) + depth := 0 + for n := list.Parent(); n != nil; n = n.Parent() { + depth++ + } + lists = append(lists, location{doc.lineStart(start), depth}) + } + return ast.WalkContinue, nil + }) + sort.Slice(lists, func(i, j int) bool { + if lists[i].depth != lists[j].depth { + return lists[i].depth > lists[j].depth + } + return lists[i].start > lists[j].start + }) + for _, loc := range lists { + todos := doc.ExtractTodos() + indexes := map[*ast.ListItem]int{} + for i, checkbox := range doc.checkboxes { + indexes[checkbox.Parent().Parent().(*ast.ListItem)] = i + } + var list *ast.List + _ = ast.Walk(doc.AST, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if n, ok := node.(*ast.List); entering && ok && n.FirstChild() != nil { + start, _ := doc.blockExtent(n) + if doc.lineStart(start) == loc.start { + list = n + return ast.WalkStop, nil + } + } + return ast.WalkContinue, nil + }) + if list == nil { + continue + } + expected := slices.Clone(todos) + var edits []sourceEdit + var run []*ast.ListItem + flush := func() error { + if len(run) < 2 { + run = nil + return nil + } + ordered := slices.Clone(run) + sort.SliceStable(ordered, func(i, j int) bool { return less(todos[indexes[ordered[i]]], todos[indexes[ordered[j]]]) }) + if slices.Equal(run, ordered) { + run = nil + return nil + } + out := indexes[run[0]] + for i, item := range ordered { + from, err := doc.itemSource(item) + if err != nil { + return err + } + to, err := doc.itemSource(run[i]) + if err != nil { + return err + } + text := doc.relocated(from, to.indent, to.marker, to.space) + if to.end < len(doc.Source) && len(text) > 0 && text[len(text)-1] != '\n' { + text += doc.newline() + } + edits = append(edits, sourceEdit{to.start, to.end, text}) + start := indexes[item] + end := start + 1 + for end < len(todos) && todos[end].Depth > todos[start].Depth { + end++ + } + copy(expected[out:], todos[start:end]) + out += end - start + } + run = nil + return nil + } + for child := list.FirstChild(); child != nil; child = child.NextSibling() { + item, ok := child.(*ast.ListItem) + if _, task := indexes[item]; ok && task { + run = append(run, item) + } else if err := flush(); err != nil { + return err + } + } + if err := flush(); err != nil { + return err + } + if len(edits) > 0 { + if err := doc.patch(edits, expected, -1); err != nil { + return err + } + } + } + fm.ast = doc + fm.Todos = doc.ExtractTodos() + return nil +} diff --git a/internal/markdown/source.go b/internal/markdown/source.go new file mode 100644 index 0000000..1d6231e --- /dev/null +++ b/internal/markdown/source.go @@ -0,0 +1,532 @@ +package markdown + +import ( + "bytes" + "fmt" + "regexp" + "slices" + "sort" + "strconv" + "strings" + + "github.com/yuin/goldmark/ast" +) + +// Source edits are prepared against the original bytes and parsed before being +// installed. Goldmark discards reference definitions from its tree, so rendering +// the tree cannot safely implement document edits. +type sourceEdit struct { + start, end int + text string +} + +type itemSource struct { + start, end int + indent, marker, space string +} + +var listPrefix = regexp.MustCompile(`^( *)([-+*]|[0-9]{1,9}[.)])([ \t]+)`) + +func (doc *ASTDocument) lineStart(pos int) int { + return bytes.LastIndexByte(doc.Source[:pos], '\n') + 1 +} + +func (doc *ASTDocument) lineEnd(pos int) int { + if n := bytes.IndexByte(doc.Source[pos:], '\n'); n >= 0 { + return pos + n + 1 + } + return len(doc.Source) +} + +func (doc *ASTDocument) newline() string { + if bytes.Contains(doc.Source, []byte("\r\n")) { + return "\r\n" + } + return "\n" +} + +// blockExtent includes lazy continuation lines, code and HTML bodies. Container +// markers themselves are recovered from the corresponding physical source line. +func (doc *ASTDocument) blockExtent(node ast.Node) (start, end int) { + start = len(doc.Source) + _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering && n.Type() != ast.TypeInline { + for i := 0; i < n.Lines().Len(); i++ { + line := n.Lines().At(i) + if line.Start < start { + start = line.Start + } + if line.Stop > end { + end = line.Stop + } + } + if h, ok := n.(*ast.HTMLBlock); ok && h.HasClosure() && h.ClosureLine.Stop > end { + end = h.ClosureLine.Stop + } + } + return ast.WalkContinue, nil + }) + return +} + +func (doc *ASTDocument) itemSource(item *ast.ListItem) (itemSource, error) { + var span itemSource + if !doc.sourceValid { + return span, fmt.Errorf("cannot safely edit a document without source locations") + } + for n := item.Parent(); n != nil; n = n.Parent() { + if _, ok := n.(*ast.Blockquote); ok { + return span, fmt.Errorf("structural edits inside block quotes are not supported") + } + } + start, knownEnd := doc.blockExtent(item) + if start >= len(doc.Source) { + return span, fmt.Errorf("list item source location unavailable") + } + span.start = doc.lineStart(start) + lineEnd := doc.lineEnd(span.start) + match := listPrefix.FindStringSubmatch(string(doc.Source[span.start:lineEnd])) + if match == nil { + return span, fmt.Errorf("list item has unsupported source indentation") + } + span.indent, span.marker, span.space = match[1], match[2], match[3] + if strings.ContainsRune(span.space, '\t') { + return span, fmt.Errorf("structural edits of tab-indented list markers are not supported") + } + span.end = lineEnd + // Include indented blocks and reference definitions missing from the AST. + // Leave trailing whitespace and all following document blocks in place. + for pos := lineEnd; pos < len(doc.Source); { + end := doc.lineEnd(pos) + line := string(doc.Source[pos:end]) + if strings.TrimSpace(line) != "" { + indent := len(line) - len(strings.TrimLeft(line, " ")) + if pos >= knownEnd && indent < len(span.indent)+len(span.marker)+len(span.space) { + break + } + if strings.HasPrefix(line, "\t") { + return span, fmt.Errorf("structural edits of tab-indented continuations are not supported") + } + span.end = end + } + pos = end + } + return span, nil +} + +func sameTask(a, b Todo) bool { + return a.Text == b.Text && a.Checked == b.Checked && a.Depth == b.Depth +} + +func (doc *ASTDocument) patch(edits []sourceEdit, expected []Todo, editedIndex int) error { + if !doc.sourceValid { + return fmt.Errorf("cannot safely edit a document without source locations") + } + sort.SliceStable(edits, func(i, j int) bool { return edits[i].start < edits[j].start }) + var b strings.Builder + pos := 0 + for _, e := range edits { + if e.start < pos || e.end < e.start || e.end > len(doc.Source) { + return fmt.Errorf("overlapping or invalid source edits") + } + b.Write(doc.Source[pos:e.start]) + b.WriteString(e.text) + pos = e.end + } + b.Write(doc.Source[pos:]) + next, err := ParseAST(b.String()) + if err != nil { + return err + } + actual := next.ExtractTodos() + if expected != nil { + if len(actual) != len(expected) { + return fmt.Errorf("edit would change unrelated task structure; document left unchanged") + } + for i := range actual { + if i == editedIndex { + if actual[i].Depth == expected[i].Depth && actual[i].Checked == expected[i].Checked { + continue + } + } + if !sameTask(actual[i], expected[i]) { + return fmt.Errorf("edit would change unrelated task %d; document left unchanged", i+1) + } + } + } + *doc = *next + return nil +} + +func (doc *ASTDocument) UpdateTodoText(index int, value string) error { + node, err := doc.FindTodoNode(index) + if err != nil { + return err + } + container := node.CheckBox.Parent() + if !doc.sourceValid || container == nil || container.Lines().Len() == 0 { + return fmt.Errorf("task text source location unavailable") + } + lines := container.Lines() + start := lines.At(0).Start + 3 + end := lines.At(lines.Len() - 1).Stop + for end > start && (doc.Source[end-1] == '\n' || doc.Source[end-1] == '\r') { + end-- + } + // Preserve spacing between the checkbox and title. + for start < end && (doc.Source[start] == ' ' || doc.Source[start] == '\t') { + start++ + } + if strings.ContainsAny(value, "\r\n") { + return fmt.Errorf("task title must be a single line") + } + if start == lines.At(0).Start+3 && value != "" { + value = " " + value + } + return doc.patch([]sourceEdit{{start, end, value}}, doc.ExtractTodos(), index) +} + +func (doc *ASTDocument) InsertTodoAfter(index int, value string, checked bool) error { + if index < -1 { + return fmt.Errorf("invalid todo index: %d", index) + } + if strings.ContainsAny(value, "\r\n") { + return fmt.Errorf("task title must be a single line") + } + todos := doc.ExtractTodos() + if len(todos) == 0 && index < 0 { + return doc.appendTask(value, checked) + } + lookup := index + if lookup < 0 { + lookup = 0 + } + node, err := doc.FindTodoNode(lookup) + if err != nil { + return err + } + span, err := doc.itemSource(node.ListItem) + if err != nil { + return err + } + at := span.end + insert := index + 1 + for insert < len(todos) && todos[insert].Depth > todos[indexOrZero(index)].Depth { + insert++ + } + marker := span.marker + if index < 0 { + at = span.start + insert = 0 + } else if len(marker) > 1 { + n, _ := strconv.Atoi(marker[:len(marker)-1]) + marker = strconv.Itoa(n+1) + marker[len(marker)-1:] + } + mark := " " + if checked { + mark = "x" + } + text := span.indent + marker + span.space + "[" + mark + "] " + value + doc.newline() + if at > 0 && doc.Source[at-1] != '\n' { + text = doc.newline() + text + } + expected := slices.Insert(slices.Clone(todos), insert, Todo{Text: value, Checked: checked, Depth: todos[lookup].Depth}) + return doc.patch([]sourceEdit{{at, at, text}}, expected, insert) +} + +func indexOrZero(index int) int { + if index < 0 { + return 0 + } + return index +} + +func (doc *ASTDocument) appendTask(value string, checked bool) error { + mark := " " + if checked { + mark = "x" + } + text := "- [" + mark + "] " + value + doc.newline() + if len(doc.Source) > 0 { + if doc.Source[len(doc.Source)-1] != '\n' { + text = doc.newline() + text + } + text = doc.newline() + text + } + return doc.patch([]sourceEdit{{len(doc.Source), len(doc.Source), text}}, []Todo{{Text: value, Checked: checked}}, 0) +} + +func (doc *ASTDocument) AddTodo(value string, checked bool) error { + if strings.ContainsAny(value, "\r\n") { + return fmt.Errorf("task title must be a single line") + } + todos := doc.ExtractTodos() + if len(todos) == 0 { + return doc.appendTask(value, checked) + } + return doc.InsertTodoAfter(len(todos)-1, value, checked) +} + +// relocate retains the raw body, changing only the indentation and list marker +// required by its new parent. Lazy continuations are made explicit when needed. +func (doc *ASTDocument) relocated(span itemSource, indent, marker, space string) string { + raw := string(doc.Source[span.start:span.end]) + lines := strings.SplitAfter(raw, "\n") + oldPrefix := len(span.indent) + len(span.marker) + len(span.space) + newPrefix := len(indent) + len(marker) + len(space) + lines[0] = indent + marker + space + lines[0][oldPrefix:] + if oldPrefix == newPrefix { + return strings.Join(lines, "") + } + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "" { + continue + } + n := len(lines[i]) - len(strings.TrimLeft(lines[i], " ")) + padding := n + newPrefix - oldPrefix + if n < oldPrefix { + padding = newPrefix + } + lines[i] = strings.Repeat(" ", padding) + lines[i][n:] + } + return strings.Join(lines, "") +} + +func (doc *ASTDocument) DeleteTodo(index int) error { + node, err := doc.FindTodoNode(index) + if err != nil { + return err + } + span, err := doc.itemSource(node.ListItem) + if err != nil { + return err + } + var promoted strings.Builder + for child := node.ListItem.FirstChild(); child != nil; child = child.NextSibling() { + if list, ok := child.(*ast.List); ok { + for item := list.FirstChild(); item != nil; item = item.NextSibling() { + s, err := doc.itemSource(item.(*ast.ListItem)) + if err != nil { + return err + } + promoted.WriteString(doc.relocated(s, span.indent, span.marker, span.space)) + } + } + } + todos := doc.ExtractTodos() + expected := slices.Clone(todos) + for i := index + 1; i < len(expected) && expected[i].Depth > todos[index].Depth; i++ { + expected[i].Depth-- + } + expected = slices.Delete(expected, index, index+1) + if expected == nil { + expected = []Todo{} + } + replacement := promoted.String() + if replacement == "" { + replacement = doc.removalSeparator(span) + } + return doc.patch([]sourceEdit{{span.start, span.end, replacement}}, expected, -1) +} + +func (doc *ASTDocument) MoveTodoToPosition(from, target int, after bool) error { + if from == target { + return nil + } + node, err := doc.FindTodoNode(from) + if err != nil { + return err + } + dest, err := doc.FindTodoNode(target) + if err != nil { + return err + } + s, err := doc.itemSource(node.ListItem) + if err != nil { + return err + } + t, err := doc.itemSource(dest.ListItem) + if err != nil { + return err + } + if t.start >= s.start && t.start < s.end { + return fmt.Errorf("cannot move a task into its own descendants") + } + at := t.start + if after { + at = t.end + } + todos := doc.ExtractTodos() + end := from + 1 + for end < len(todos) && todos[end].Depth > todos[from].Depth { + end++ + } + insert := target + if after { + insert++ + for insert < len(todos) && todos[insert].Depth > todos[target].Depth { + insert++ + } + } + group := slices.Clone(todos[from:end]) + delta := todos[target].Depth - todos[from].Depth + for i := range group { + group[i].Depth += delta + } + expected := slices.Delete(slices.Clone(todos), from, end) + if insert > from { + insert -= end - from + } + expected = slices.Insert(expected, insert, group...) + return doc.moveSpan(s, at, t.indent, t.marker, t.space, expected) +} + +func (doc *ASTDocument) moveSpan(span itemSource, at int, indent, marker, space string, expected []Todo) error { + if at > span.start && at < span.end { + return fmt.Errorf("overlapping move") + } + text := doc.relocated(span, indent, marker, space) + if at < len(doc.Source) && !strings.HasSuffix(text, "\n") { + text += doc.newline() + } + if at > 0 && at != span.end && doc.Source[at-1] != '\n' { + text = doc.newline() + text + } + return doc.patch([]sourceEdit{{at, at, text}, {span.start, span.end, doc.removalSeparator(span)}}, expected, -1) +} + +// An ordered list starting above one cannot interrupt a paragraph. Removing its +// first item must leave a separating blank line so remaining items stay a list. +func (doc *ASTDocument) removalSeparator(span itemSource) string { + if len(span.marker) > 1 { + return doc.newline() + } + return "" +} + +func (doc *ASTDocument) MoveTodo(from, to int) error { + return doc.MoveTodoToPosition(from, to, from < to) +} + +func (doc *ASTDocument) IndentTodo(index int) error { + node, err := doc.FindTodoNode(index) + if err != nil { + return err + } + prev, ok := node.ListItem.PreviousSibling().(*ast.ListItem) + if !ok { + return fmt.Errorf("cannot indent: no previous sibling") + } + s, err := doc.itemSource(node.ListItem) + if err != nil { + return err + } + p, err := doc.itemSource(prev) + if err != nil { + return err + } + indent := p.indent + strings.Repeat(" ", len(p.marker)+len(p.space)) + marker, space := "-", " " + at := p.end + for child := prev.FirstChild(); child != nil; child = child.NextSibling() { + if list, ok := child.(*ast.List); ok && list.LastChild() != nil { + nested, e := doc.itemSource(list.LastChild().(*ast.ListItem)) + if e != nil { + return e + } + indent, marker, space = nested.indent, nested.marker, nested.space + at = nested.end + break + } + } + expected := slices.Clone(doc.ExtractTodos()) + depth := expected[index].Depth + for i := index; i < len(expected) && (i == index || expected[i].Depth > depth); i++ { + expected[i].Depth++ + } + return doc.moveSpan(s, at, indent, marker, space, expected) +} + +func (doc *ASTDocument) OutdentTodo(index int) error { + node, err := doc.FindTodoNode(index) + if err != nil { + return err + } + parent, ok := node.ListItem.Parent().Parent().(*ast.ListItem) + if !ok { + return fmt.Errorf("cannot outdent: already at top level") + } + s, err := doc.itemSource(node.ListItem) + if err != nil { + return err + } + p, err := doc.itemSource(parent) + if err != nil { + return err + } + todos := doc.ExtractTodos() + end := index + 1 + for end < len(todos) && todos[end].Depth > todos[index].Depth { + end++ + } + group := slices.Clone(todos[index:end]) + for i := range group { + group[i].Depth-- + } + insert := end + for insert < len(todos) && todos[insert].Depth >= todos[index].Depth { + insert++ + } + expected := slices.Delete(slices.Clone(todos), index, end) + insert -= end - index + expected = slices.Insert(expected, insert, group...) + return doc.moveSpan(s, p.end, p.indent, p.marker, p.space, expected) +} + +func (doc *ASTDocument) SwapTodos(a, b int) error { + if a == b { + return nil + } + n, err := doc.FindTodoNode(a) + if err != nil { + return err + } + m, err := doc.FindTodoNode(b) + if err != nil { + return err + } + if n.ListItem.Parent() != m.ListItem.Parent() { + return fmt.Errorf("swap requires sibling tasks") + } + s, err := doc.itemSource(n.ListItem) + if err != nil { + return err + } + t, err := doc.itemSource(m.ListItem) + if err != nil { + return err + } + // Validate the permutation by moving complete subtrees in a private copy. + copyDoc, _ := ParseAST(string(doc.Source)) + if a > b { + a, b = b, a + } + todos := doc.ExtractTodos() + ae, be := a+1, b+1 + for ae < len(todos) && todos[ae].Depth > todos[a].Depth { + ae++ + } + for be < len(todos) && todos[be].Depth > todos[b].Depth { + be++ + } + expected := slices.Clone(todos[:a]) + expected = append(expected, todos[b:be]...) + expected = append(expected, todos[ae:b]...) + expected = append(expected, todos[a:ae]...) + expected = append(expected, todos[be:]...) + if err = copyDoc.patch([]sourceEdit{{s.start, s.end, string(doc.Source[t.start:t.end])}, {t.start, t.end, string(doc.Source[s.start:s.end])}}, expected, -1); err != nil { + return err + } + *doc = *copyDoc + return nil +} diff --git a/internal/markdown/source_edits_test.go b/internal/markdown/source_edits_test.go new file mode 100644 index 0000000..033b61d --- /dev/null +++ b/internal/markdown/source_edits_test.go @@ -0,0 +1,197 @@ +package markdown + +import ( + "strings" + "testing" +) + +func TestTextEditPreservesReferenceDefinitionsAndDocumentBytes(t *testing.T) { + for _, source := range []string{ + "Title\n=====\n\nSee [guide][ref].\n\n- [ ] before\n\n[ref]: <https://example.com/a>\n \"Keep title\"\n", + "# Work\r\n\r\n9. [X] before\r\n - [ ] child\r\n\r\n[ref]: /guide \"title\"", + "> - [ ] before\n> continuation\n\n[ref]: /guide\n", + } { + doc, _ := ParseAST(source) + if err := doc.UpdateTodoText(0, "after **bold** [ref]"); err != nil { + t.Fatal(err) + } + want := strings.Replace(source, "before", "after **bold** [ref]", 1) + // A continuation belongs to the editable first paragraph, not to a + // separate body block. Replacing the full title replaces it too. + want = strings.Replace(want, "\n> continuation", "", 1) + if got := SerializeAST(doc); got != want { + t.Fatalf("got %q; want %q", got, want) + } + } +} + +func TestTextEditReplacesContinuationButPreservesSeparateBody(t *testing.T) { + for _, quote := range []string{"", "> "} { + for _, newline := range []string{"\n", "\r\n"} { + source := quote + "- [ ] before\n" + quote + " continuation\n" + quote + "\n" + quote + " Separate **body** with [ref].\n\n[ref]: /guide\n" + want := quote + "- [ ] after\n" + quote + "\n" + quote + " Separate **body** with [ref].\n\n[ref]: /guide\n" + source = strings.ReplaceAll(source, "\n", newline) + want = strings.ReplaceAll(want, "\n", newline) + f := ParseMarkdown(source) + if len(f.Todos) != 1 || !strings.Contains(f.Todos[0].Text, "before continuation") { + t.Fatalf("task text must expose the first paragraph continuation: %+v", f.Todos) + } + if err := f.UpdateTodoItem(0, "after", false); err != nil { + t.Fatal(err) + } + if got := SerializeMarkdown(f); got != want { + t.Fatalf("separate body changed: got %q; want %q", got, want) + } + } + } +} + +func TestStructuralEditsPreserveRichDocument(t *testing.T) { + prefix := "Title\n=====\n\n<!-- keep exact -->\n\n| A | B |\n| :--- | ---: |\n| café | `x` |\n\n~~~md\n- [ ] example\n~~~\n\nSee [guide][ref].\n\n" + suffix := "\n[ref]: <https://example.com>\n \"Original title\"\n\n<div>unchanged</div>" + for _, tasks := range []string{ + "- [ ] Parent !p2\n continuation with [guide][ref]\n\n Another paragraph.\n\n ```go\n fmt.Println(\"keep\")\n ```\n\n - [ ] Child !p3\n - [x] Done child !p1\n- [ ] Sibling !p1\n", + "9. [ ] Parent !p2\n continuation with [guide][ref]\n 1) [ ] Child !p3\n 2) [x] Done child !p1\n10. [ ] Sibling !p1\n", + } { + for name, op := range map[string]func(*FileModel) error{ + "add": func(f *FileModel) error { return f.AddTodoItemChecked("Added", false) }, + "insert": func(f *FileModel) error { _, err := f.InsertTodoItemAfterChecked(0, "Inserted", false); return err }, + "delete-parent": func(f *FileModel) error { return f.DeleteTodoItem(0) }, + "delete-child": func(f *FileModel) error { return f.DeleteTodoItem(1) }, + "move-parent": func(f *FileModel) error { return f.MoveTodoItem(0, 3) }, + "move-child": func(f *FileModel) error { return f.MoveTodoItem(1, 3) }, + "indent": func(f *FileModel) error { return f.IndentTodoItem(3) }, + "outdent": func(f *FileModel) error { return f.OutdentTodoItem(1) }, + "sort": func(f *FileModel) error { return f.SortTodos(func(a, b Todo) bool { return a.Priority < b.Priority }) }, + "rename": func(f *FileModel) error { return f.RenameHeading(0, "Renamed") }, + "new-section": func(f *FileModel) error { _, err := f.CreateHeading(-1, 2, "New"); return err }, + "section-task": func(f *FileModel) error { _, err := f.AddTodoInSection(0, "New"); return err }, + } { + t.Run(name, func(t *testing.T) { + f := ParseMarkdown(prefix + tasks + suffix) + if err := op(f); err != nil { + t.Fatal(err) + } + got := SerializeMarkdown(f) + if !strings.Contains(got, suffix) { + t.Fatalf("lost suffix: %q", got) + } + if name != "rename" && name != "section-task" && !strings.HasPrefix(got, prefix) { + t.Fatalf("changed prefix: %q", got) + } + if SerializeMarkdown(f.Clone()) != got { + t.Fatal("clone changes source") + } + }) + } + } +} + +func TestUnsafeSourceOperationsAreAtomic(t *testing.T) { + for _, tc := range []struct { + source string + op func(*FileModel) error + }{ + {"> - [ ] quoted\n\n[ref]: /x\n", func(f *FileModel) error { return f.DeleteTodoItem(0) }}, + {"- [ ] Parent\n - [ ] Child\n", func(f *FileModel) error { return f.MoveTodoItem(0, 1) }}, + {"- [ ] task\n", func(f *FileModel) error { return f.UpdateTodoItem(0, "injected\n- [ ] extra", false) }}, + } { + f := ParseMarkdown(tc.source) + if err := tc.op(f); err == nil { + t.Fatal("expected rejected source edit") + } + if SerializeMarkdown(f) != tc.source { + t.Fatal("rejected operation changed bytes") + } + } +} + +func TestSortPreservesTaskSubtreesAndBodies(t *testing.T) { + source := "# Work\n\n- [x] Parent\n body **bold**\n - [x] Child done\n - [ ] Child open\n- [ ] Open\n\n[ref]: /kept\n" + f := ParseMarkdown(source) + if err := f.SortTodos(func(a, b Todo) bool { return !a.Checked && b.Checked }); err != nil { + t.Fatal(err) + } + want := "# Work\n\n- [ ] Open\n- [x] Parent\n body **bold**\n - [ ] Child open\n - [x] Child done\n\n[ref]: /kept\n" + if got := SerializeMarkdown(f); got != want { + t.Fatalf("got %q; want %q", got, want) + } +} + +func TestStructuralBoundaryPreservesIndentedOutsideParagraph(t *testing.T) { + for _, source := range []string{ + "9. [ ] task\n\n This is outside the ordered list.\n", + "- [ ] task\n\n [ref]: /outside\n", + } { + f := ParseMarkdown(source) + if err := f.DeleteTodoItem(0); err != nil { + t.Fatal(err) + } + want := source[strings.Index(source, "\n")+1:] + if !strings.HasSuffix(SerializeMarkdown(f), want) { + t.Fatalf("deleted unrelated indented source: %q", SerializeMarkdown(f)) + } + } +} + +func TestOpaqueQuotedCheckboxDoesNotShiftTaskIndexes(t *testing.T) { + source := "# Work\n\n- [ ] outer\n > - [ ] quoted example\n\n# Later\n\n- [ ] after\n\n# Tail\n" + for _, first := range []string{"headings", "todos", "edit"} { + t.Run(first, func(t *testing.T) { + doc, _ := ParseAST(source) + if first == "headings" { + headings := doc.ExtractHeadings() + if len(headings) != 3 || headings[1].BeforeTodoIndex != 1 || headings[2].BeforeTodoIndex != 2 { + t.Fatalf("heading task boundaries: %+v", headings) + } + } + if first == "todos" { + if len(doc.ExtractTodos()) != 2 { + t.Fatal("opaque example became an indexed task") + } + } + if err := doc.UpdateTodoText(1, "changed"); err != nil { + t.Fatal(err) + } + if got := SerializeAST(doc); got != strings.Replace(source, "[ ] after", "[ ] changed", 1) { + t.Fatalf("edit selected the wrong checkbox: %q", got) + } + if err := doc.ToggleTodo(1); err != nil { + t.Fatal(err) + } + if got := SerializeAST(doc); got != strings.Replace(source, "[ ] after", "[x] changed", 1) { + t.Fatalf("toggle selected the wrong checkbox: %q", got) + } + if err := doc.InsertTodoAfter(1, "new", false); err != nil { + t.Fatal(err) + } + if err := doc.DeleteTodo(1); err != nil { + t.Fatal(err) + } + if todos := doc.ExtractTodos(); len(todos) != 2 || todos[1].Text != "new" { + t.Fatalf("stale checkbox cache: %+v", todos) + } + if !strings.Contains(SerializeAST(doc), "> - [ ] quoted example") { + t.Fatal("changed opaque quoted example") + } + }) + } +} + +func TestEmptyHeadingOperationsRejectWithoutMutation(t *testing.T) { + source := "# Work\n\n- [ ] task\n\n#\n" + for _, op := range []func(*FileModel) error{ + func(f *FileModel) error { _, err := f.AddTodoInSection(1, "new"); return err }, + func(f *FileModel) error { _, err := f.CreateHeading(0, 1, "new"); return err }, + func(f *FileModel) error { _, err := f.CreateHeading(1, 1, "new"); return err }, + func(f *FileModel) error { return f.RenameHeading(1, "new") }, + } { + f := ParseMarkdown(source) + if err := op(f); err == nil { + t.Fatal("expected missing source location error") + } + if got := SerializeMarkdown(f); got != source { + t.Fatalf("changed source: %q", got) + } + } +} diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go new file mode 100644 index 0000000..afcff6b --- /dev/null +++ b/internal/tui/clipboard.go @@ -0,0 +1,35 @@ +package tui + +import "github.com/niklas-heer/tdx/internal/util" + +type clipboardCopiedMsg struct{ err error } +type clipboardPastedMsg struct { + text string + err error + file, mode, buffer string + cursor int +} + +func (m *Model) clipboard() util.Clipboard { + if c := m.Config().Clipboard; c != nil { + return c + } + return util.SystemClipboard{} +} +func (m *Model) clipboardInputMode() string { + switch { + case m.ViewMode != "": + return "view:" + m.ViewMode + case m.HeadingInput != "": + return "heading:" + m.HeadingInput + case m.InputMode: + return "new" + case m.EditMode: + return "edit" + case m.SearchMode: + return "search" + case m.CommandMode: + return "command" + } + return "" +} diff --git a/internal/tui/clipboard_test.go b/internal/tui/clipboard_test.go new file mode 100644 index 0000000..c8b4d0e --- /dev/null +++ b/internal/tui/clipboard_test.go @@ -0,0 +1,59 @@ +package tui + +import ( + tea "charm.land/bubbletea/v2" + "errors" + "testing" +) + +type testClipboard struct { + err error + text string +} + +func (c testClipboard) Copy(string) error { return c.err } +func (c testClipboard) Paste() (string, error) { return c.text, c.err } +func TestClipboardTruthfulFeedback(t *testing.T) { + for _, failure := range []bool{false, true} { + m := viewModel(t) + var err error + if failure { + err = errors.New("clipboard unavailable") + } + m.Config().Clipboard = testClipboard{err: err} + result, cmd := m.Update(tea.KeyPressMsg{Code: 'c'}) + m = result.(Model) + if m.CopyFeedback { + t.Fatal("success shown before copying") + } + result, _ = m.Update(cmd()) + m = result.(Model) + if m.CopyFeedback == failure || (m.Err != nil) != failure { + t.Fatalf("untruthful feedback: %v %v", m.CopyFeedback, m.Err) + } + } +} +func TestClipboardPasteFailureAndStaleResult(t *testing.T) { + m := viewModel(t) + m.InputMode = true + m.InputBuffer = "keep" + m.CursorPos = 4 + m.Config().Clipboard = testClipboard{err: errors.New("unavailable")} + result, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}) + m = result.(Model) + result, _ = m.Update(cmd()) + m = result.(Model) + if m.InputBuffer != "keep" || m.Err == nil { + t.Fatal("failed paste changed input or hid error") + } + m.Err = nil + m.Config().Clipboard = testClipboard{text: "pasted"} + result, cmd = m.Update(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}) + m = result.(Model) + m.InputBuffer = "changed" + m.CursorPos = 7 + result, _ = m.Update(cmd()) + if result.(Model).InputBuffer != "changed" { + t.Fatal("stale paste changed new input") + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 8ef1d50..0a7b004 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -199,7 +199,7 @@ func InitCommands(cfg ...*ConfigType) []Command { }, { Name: "read-only", - Description: "Toggle read-only mode (changes not saved)", + Description: "Alias for manual-save (edits need :save)", Handler: func(m *Model) { m.ReadOnly = !m.ReadOnly }, @@ -241,6 +241,11 @@ func InitCommands(cfg ...*ConfigType) []Command { m.CursorPos = 0 }, }, + {Name: "manual-save", Description: "Toggle manual saving (temporary edits; :save writes)", Handler: func(m *Model) { m.ReadOnly = !m.ReadOnly }}, + {Name: "save-view", Description: "Save current filters and sections as a named view", Handler: func(m *Model) { m.openViews("save") }}, + {Name: "views", Description: "List and load saved views for this file", Handler: func(m *Model) { m.openViews("load") }}, + {Name: "delete-view", Description: "Delete a saved view for this file", Handler: func(m *Model) { m.openViews("delete") }}, + {Name: "clear-view", Description: "Clear all filters, section focus and folds", Handler: func(m *Model) { m.clearSavedView() }}, {Name: "sections", Description: "Browse, edit, focus, and fold Markdown sections", Handler: func(m *Model) { m.openSections() }}, {Name: "all-sections", Description: "Clear section focus and unfold all sections", Handler: func(m *Model) { m.clearSections() }}, diff --git a/internal/tui/input.go b/internal/tui/input.go index 60db584..fc188e1 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -20,13 +20,16 @@ func singleLineText(text string) string { } func (m Model) handlePaste(text string) (tea.Model, tea.Cmd) { + if m.ViewConfirm { + return m, nil + } text = singleLineText(text) if text == "" { return m, nil } switch { - case m.HeadingInput != "" || m.InputMode || m.EditMode || m.SearchMode || m.CommandMode: + case m.ViewMode == "save" || m.HeadingInput != "" || m.InputMode || m.EditMode || m.SearchMode || m.CommandMode: m.insertInputText(text) if m.SearchMode { m.searchPending = true diff --git a/internal/tui/layout.go b/internal/tui/layout.go new file mode 100644 index 0000000..4f773fb --- /dev/null +++ b/internal/tui/layout.go @@ -0,0 +1,89 @@ +package tui + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +// Leave one row for the inline renderer's cursor. Oversized frames scroll +// previous content out of its redraw area and leave permanent terminal residue. +func (m Model) frameHeight() int { + if m.TermHeight <= 0 { + return 0 + } + return max(1, m.TermHeight-1) +} + +// fitFrame prevents terminal soft wrapping and bounds every mode, including +// overlays. Keep the final footer when a modal is taller than the terminal. +func fitFrame(content string, width, height int) string { + lines := strings.Split(strings.TrimRight(content, "\n"), "\n") + if height > 0 && len(lines) > height { + lines = append(lines[:height-1], lines[len(lines)-1]) + } + if width > 0 { + for i, line := range lines { + lines[i] = ansi.Truncate(line, width, "…") + } + } + return strings.Join(lines, "\n") +} + +// taskViewport scrolls rendered rows, rather than treating a wrapped task or a +// heading as a single task slot. The selected task/editor cursor is the anchor. +func taskViewport(content string, anchor, height int) string { + lines := strings.Split(strings.TrimRight(content, "\n"), "\n") + if len(lines) <= height { + return strings.Join(lines, "\n") + } + anchor = min(max(0, anchor), len(lines)-1) + rows := height + if height >= 3 { + rows -= 2 + } + start := min(max(0, anchor-rows/2), len(lines)-rows) + end := start + rows + visible := append([]string(nil), lines[start:end]...) + if height >= 3 { + above, below := "", "" + if start > 0 { + above = " ▲ more" + } + if end < len(lines) { + below = " ▼ more" + } + visible = append([]string{above}, visible...) + visible = append(visible, below) + } + return strings.Join(visible, "\n") +} + +// renderEditorLine keeps the cursor visible both in wrapped text and when +// wrapping is disabled. Widths use terminal cells, including custom markers. +func (m Model) renderEditorLine(prefix string) (string, int) { + before, after := m.InputBuffer[:m.CursorPos], m.InputBuffer[m.CursorPos:] + cursor := lipgloss.NewStyle().Reverse(true).Render(" ") + text := before + cursor + after + width := m.TermWidth - lipgloss.Width(prefix) + if m.TermWidth <= 0 { + return prefix + text + "\n", 0 + } + width = max(1, width) + if !m.WordWrap { + left := max(0, ansi.StringWidth(before)+1-width) + visible := ansi.Cut(text, left, left+width) + // A cut through a double-width grapheme can include its whole cell. + // Remove that leading cluster rather than truncate the cursor's tail. + if excess := ansi.StringWidth(visible) - width; excess > 0 { + visible = ansi.TruncateLeft(visible, excess+1, "") + } + return prefix + visible + "\n", 0 + } + // Hard wrapping preserves spaces and the exact cursor position during input. + lines := strings.Split(ansi.Hardwrap(text, width, true), "\n") + cursorRow := strings.Count(ansi.Hardwrap(before+cursor, width, true), "\n") + indent := strings.Repeat(" ", lipgloss.Width(prefix)) + return prefix + strings.Join(lines, "\n"+indent) + "\n", cursorRow +} diff --git a/internal/tui/layout_test.go b/internal/tui/layout_test.go new file mode 100644 index 0000000..bcbd182 --- /dev/null +++ b/internal/tui/layout_test.go @@ -0,0 +1,307 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + "time" + "unicode/utf8" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/niklas-heer/tdx/internal/config" +) + +var inlineLayoutSizes = [][2]int{{24, 8}, {80, 24}, {120, 40}, {200, 60}} + +func richLayoutModel() Model { + var source strings.Builder + source.WriteString("# Project\n\n") + for i := 0; i < 80; i++ { + fmt.Fprintf(&source, "## Group %03d — 世界\n\n- [ ] T%03da %s [guide日本語](https://example.com/%s) #work !p2\n - [ ] T%03db %s\n\n", i, i, + strings.Repeat("wide 世界 café 👩🏽‍💻 é ", 8), strings.Repeat("long-path/", 20), i, + strings.Repeat("nested 日本語 🌍 task ", 8)) + } + m := testModelWithMarkdown(source.String()) + m.ReadOnly = true + m.ShowHeadings = true + m.Config().Display.CheckSymbol = "x" + m.styles.Cyan = func(s string) string { return "\x1b[36m" + s + "\x1b[0m" } + m.styles.Dim = func(s string) string { return "\x1b[2m" + s + "\x1b[0m" } + return m +} + +// A full terminal-height inline frame makes the renderer scroll the terminal, +// leaving previous frame rows behind. Reserve one terminal row for its cursor. +func assertInlineFrameBounds(t *testing.T, m Model) string { + t.Helper() + content := m.View().Content + if !utf8.ValidString(content) { + t.Fatal("frame splits a UTF-8 sequence") + } + if height := lipgloss.Height(content); height != max(1, m.TermHeight-1) { + t.Errorf("frame has %d display rows at %dx%d; want exactly %d to keep redraw height stable", height, m.TermWidth, m.TermHeight, max(1, m.TermHeight-1)) + } + for i, row := range strings.Split(content, "\n") { + if width := lipgloss.Width(row); width > m.TermWidth { + t.Errorf("display row %d has width %d at terminal width %d: %q", i, width, m.TermWidth, ansi.Strip(row)) + break + } + } + return ansi.Strip(content) +} + +func TestInlineLayoutKeepsSelectionVisible(t *testing.T) { + for _, size := range inlineLayoutSizes { + for _, visible := range []int{0, 10, 1000} { + for _, wrap := range []bool{false, true} { + t.Run(fmt.Sprintf("%dx%d/max%d/wrap%t", size[0], size[1], visible, wrap), func(t *testing.T) { + m := richLayoutModel() + m.TermWidth, m.TermHeight = size[0], size[1] + m.MaxVisibleOverride, m.WordWrap = visible, wrap + for _, selected := range []int{0, 80, 159} { + m.SelectedIndex = selected + m.InvalidateDocumentTree() + token := strings.Fields(m.FileModel.Todos[selected].Text)[0] + for _, focus := range []bool{false, true, false} { + if focus { + m.focusSection(selected/2 + 1) + } else { + m.clearSections() + } + plain := assertInlineFrameBounds(t, m) + if !strings.Contains(plain, token) { + t.Errorf("selected task %s disappeared (focus=%t):\n%s", token, focus, plain) + } + if focus && !strings.Contains(plain, "Section:") { + t.Errorf("focus banner disappeared: %q", plain) + } + if !focus && strings.Contains(plain, "Section:") { + t.Errorf("cleared focus banner remains: %q", plain) + } + } + } + }) + } + } + } +} + +func TestInlineLayoutKeepsLongInputCursorTailVisible(t *testing.T) { + for _, size := range inlineLayoutSizes { + for _, wrap := range []bool{false, true} { + for _, mode := range []string{"edit", "append", "insert"} { + t.Run(fmt.Sprintf("%dx%d/%s/wrap%t", size[0], size[1], mode, wrap), func(t *testing.T) { + m := richLayoutModel() + m.TermWidth, m.TermHeight = size[0], size[1] + m.MaxVisibleOverride, m.WordWrap, m.SelectedIndex = 1000, wrap, 80 + m.InputBuffer = strings.Repeat("世界 résumé 👩🏽‍💻 long input ", 80) + "TAIL42" + m.CursorPos = len(m.InputBuffer) + m.EditMode = mode == "edit" + m.InputMode = mode != "edit" + m.InsertAfterCursor = mode == "insert" + plain := assertInlineFrameBounds(t, m) + if !strings.Contains(plain, "TAIL42") { + t.Errorf("input tail at cursor is off-screen: %q", plain) + } + }) + } + } + } +} + +func TestInlineLayoutBoundsEveryModal(t *testing.T) { + modes := map[string]func(*Model){ + "help": func(m *Model) { m.HelpMode = true }, + "sections": func(m *Model) { m.SectionsMode = true; m.SectionCursor = 40 }, + "heading-input": func(m *Model) { m.SectionsMode = true; m.HeadingInput = "rename"; m.SectionCursor = 40 }, + "saved-views": func(m *Model) { m.ViewMode = "load"; m.ViewCursor = 40 }, + "save-view": func(m *Model) { m.ViewMode = "save" }, + "delete-view": func(m *Model) { m.ViewMode = "delete"; m.ViewCursor = 40; m.ViewConfirm = true }, + "recent": func(m *Model) { m.RecentFilesMode = true; m.RecentFilesCursor = 40 }, + "tags": func(m *Model) { m.FilterMode = true; m.TagFilterCursor = 4 }, + "priorities": func(m *Model) { m.PriorityFilterMode = true }, + "due-dates": func(m *Model) { m.DueFilterMode = true }, + "themes": func(m *Model) { m.ThemeMode = true; m.ThemeCursor = 40 }, + "commands": func(m *Model) { m.CommandMode = true; m.updateFilteredCommands() }, + "search": func(m *Model) { + m.SearchMode = true + m.InputBuffer = "世界" + m.CursorPos = len(m.InputBuffer) + m.updateSearchResults() + }, + "max-visible": func(m *Model) { m.MaxVisibleInputMode = true; m.InputBuffer = "1000"; m.CursorPos = 4 }, + "move": func(m *Model) { m.MoveMode = true }, + "conflict": func(m *Model) { m.ConflictDiffMode = true; m.ConflictPending = true }, + "versions": func(m *Model) { m.VersionsMode = true; m.VersionsCursor = 30 }, + "restore-confirm": func(m *Model) { m.VersionsMode = true; m.VersionsCursor = 30; m.VersionsConfirmMode = true }, + } + for _, size := range inlineLayoutSizes { + for name, enter := range modes { + t.Run(fmt.Sprintf("%dx%d/%s", size[0], size[1], name), func(t *testing.T) { + m := richLayoutModel() + m.TermWidth, m.TermHeight = size[0], size[1] + m.MaxVisibleOverride, m.SelectedIndex = 1000, 80 + m.InputBuffer = strings.Repeat("long 世界 input ", 40) + m.CursorPos = len(m.InputBuffer) + m.ConflictLocalContent = strings.Repeat("local 世界 ", 120) + "\n" + m.ConflictDiskContent = strings.Repeat("disk café ", 120) + "\n" + m.Config().ReadVersionFunc = func(string, int64) (string, error) { return "- [ ] previous version\n", nil } + for i := 0; i < 60; i++ { + label := fmt.Sprintf("Entry %d %s", i, strings.Repeat("世界", 30)) + m.ViewNames = append(m.ViewNames, label) + m.AvailableThemes = append(m.AvailableThemes, label) + m.AvailableTags = append(m.AvailableTags, label) + m.RecentFiles = append(m.RecentFiles, config.RecentFile{Path: "/tmp/" + label + ".md", AccessCount: 30}) + m.VersionsList = append(m.VersionsList, VersionInfo{ID: int64(i + 1), CreatedAt: time.Unix(0, 0)}) + } + enter(&m) + assertInlineFrameBounds(t, m) + }) + } + } +} + +func TestInlineLayoutResizesWithoutLosingSelection(t *testing.T) { + m := richLayoutModel() + m.SelectedIndex = 80 + m.MaxVisibleOverride = 1000 + for _, size := range [][2]int{{200, 60}, {24, 8}, {120, 40}, {80, 24}, {24, 8}} { + hadSize := m.TermWidth > 0 && m.TermHeight > 0 + next, command := m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) + if hadSize && command == nil { + t.Errorf("actual resize to %dx%d did not request clearing the previous frame", size[0], size[1]) + } + if !hadSize && command != nil { + t.Error("first size notification unnecessarily cleared terminal") + } + m = next.(Model) + plain := assertInlineFrameBounds(t, m) + if !strings.Contains(plain, "T040a") { + t.Errorf("resize to %dx%d hid selection: %q", size[0], size[1], plain) + } + sameSize, command := m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) + if command != nil { + t.Errorf("repeated %dx%d notification unnecessarily cleared terminal", size[0], size[1]) + } + if sameSize.(Model).SelectedIndex != m.SelectedIndex { + t.Error("repeated size notification changed selection") + } + if sameSize.(Model).View().Content != m.View().Content { + t.Error("repeated size notification changed the frame") + } + } +} + +func TestInlineLayoutFrameHeightStableAcrossModeTransitions(t *testing.T) { + for _, size := range inlineLayoutSizes { + t.Run(fmt.Sprintf("%dx%d", size[0], size[1]), func(t *testing.T) { + m := richLayoutModel() + m.TermWidth, m.TermHeight = size[0], size[1] + m.SelectedIndex, m.MaxVisibleOverride = 80, 1000 + assertInlineFrameBounds(t, m) + // Main -> section picker -> focused main -> tag overlay -> focused + // main -> all tasks -> help -> main must use the same redraw height. + for _, key := range []rune{'s', tea.KeyEnter, 't', tea.KeyEsc, 'S', '?', tea.KeyEsc} { + m = viewKey(m, key) + plain := assertInlineFrameBounds(t, m) + if !m.SectionsMode && !m.FilterMode && !m.HelpMode && !strings.Contains(plain, "T040a") { + t.Errorf("mode transition %q hid selected task: %q", key, plain) + } + } + }) + } +} + +func TestInlineLayoutUnsizedPipedViewStillRenders(t *testing.T) { + m := testModelWithMarkdown("# Work\n\n- [ ] PIPED_TASK\n") + if got := m.View().Content; !strings.Contains(ansi.Strip(got), "PIPED_TASK") { + t.Fatalf("unsized piped view disappeared: %q", got) + } +} + +func TestInlineLayoutInteractiveStartupWaitsForSize(t *testing.T) { + m := testModelWithMarkdown("- [ ] STARTUP_TASK\n") + m.waitForSize = true + for _, size := range [][2]int{{0, 0}, {80, 0}, {0, 24}} { + m.TermWidth, m.TermHeight = size[0], size[1] + if got := m.View().Content; got != "" { + t.Fatalf("interactive startup rendered before both dimensions arrived: %q", got) + } + } + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = next.(Model) + if plain := assertInlineFrameBounds(t, m); !strings.Contains(plain, "STARTUP_TASK") { + t.Fatalf("first sized frame omitted task: %q", plain) + } +} + +func TestInlineLayoutUnicodeAndOSCLinks(t *testing.T) { + for _, size := range inlineLayoutSizes { + for _, wrap := range []bool{false, true} { + t.Run(fmt.Sprintf("%dx%d/wrap%t", size[0], size[1], wrap), func(t *testing.T) { + m := testModelWithMarkdown("- [ ] LINK [世界café👩🏽‍💻](https://example.com/" + strings.Repeat("long-path/", 80) + ") " + strings.Repeat("世界 café 👩🏽‍💻 ", 40) + "\n") + m.TermWidth, m.TermHeight = size[0], size[1] + m.WordWrap = wrap + m.Config().Display.SelectMarker = "👉🏽" + m.Config().Display.CheckSymbol = "✓" + m.FileModel.Todos[0].Checked = true + plain := assertInlineFrameBounds(t, m) + if !strings.Contains(plain, "LINK") { + t.Fatalf("selected task label was hidden by Unicode marker or hyperlink width: %q", plain) + } + if strings.Contains(plain, "https://example.com") { + t.Fatal("OSC hyperlink destination leaked into display columns") + } + }) + } + } +} + +func TestInlineLayoutVerySmallTerminalStillBoundsFrame(t *testing.T) { + for _, size := range [][2]int{{1, 1}, {2, 2}, {8, 3}, {12, 4}} { + m := testModelWithMarkdown("- [ ] tiny\n") + m.TermWidth, m.TermHeight = size[0], size[1] + assertInlineFrameBounds(t, m) + m.HelpMode = true + assertInlineFrameBounds(t, m) + } +} + +func TestInlineLayoutVersionDiffKeepsTitleAndRestoreFooter(t *testing.T) { + for _, confirm := range []bool{false, true} { + t.Run(fmt.Sprintf("confirm%t", confirm), func(t *testing.T) { + m := testModelWithMarkdown("- [ ] " + strings.Repeat("Original世界", 120) + "\n") + m.TermWidth, m.TermHeight = 120, 20 + m.VersionsMode, m.VersionsConfirmMode = true, confirm + m.VersionsList = []VersionInfo{{ID: 1, CreatedAt: time.Unix(0, 0)}} + m.styles.Dim = func(s string) string { return "\x1b[2m" + s + "\x1b[0m" } + m.Config().ReadVersionFunc = func(string, int64) (string, error) { + return "- [ ] " + strings.Repeat("Changedcafé", 120) + "\n", nil + } + plain := assertInlineFrameBounds(t, m) + for _, want := range []string{"FILE VERSION HISTORY", "[Enter] Restore"} { + if !strings.Contains(plain, want) { + raw := m.renderVersionsBrowser() + t.Errorf("missing %q in final view; raw browser %dx%d:\n%s", want, lipgloss.Width(raw), lipgloss.Height(raw), ansi.Strip(raw)) + } + } + }) + } +} + +func TestInlineLayoutConflictDiffKeepsResolutionFooter(t *testing.T) { + m := testModelWithMarkdown("- [ ] task\n") + m.TermWidth, m.TermHeight = 120, 20 + m.ConflictDiffMode, m.ConflictPending = true, true + m.ConflictLocalContent = "- [ ] " + strings.Repeat("Local世界", 120) + "\n" + m.ConflictDiskContent = "- [ ] " + strings.Repeat("Diskcafé", 120) + "\n" + plain := assertInlineFrameBounds(t, m) + for _, want := range []string{"FILE CONFLICT", ":reload or :force-save"} { + if !strings.Contains(plain, want) { + raw := m.renderConflictDiff() + t.Errorf("missing %q in final view; raw conflict %dx%d:\n%s", want, lipgloss.Width(raw), lipgloss.Height(raw), ansi.Strip(raw)) + } + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index f75b86d..7bd8949 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -8,6 +8,7 @@ import ( "github.com/niklas-heer/tdx/internal/config" "github.com/niklas-heer/tdx/internal/editor" "github.com/niklas-heer/tdx/internal/markdown" + "github.com/niklas-heer/tdx/internal/util" ) // StyleFuncsType holds style functions for rendering @@ -38,6 +39,9 @@ type VersionInfo struct { // ConfigType holds display configuration type ConfigType struct { + Views config.ViewStore + ViewsRestore bool + Clipboard util.Clipboard Recent config.RecentStore Store markdown.Store AvailableThemes []string @@ -71,11 +75,17 @@ type Runtime struct { // Model holds the TUI application state type Model struct { - SectionsMode bool - SectionCursor int - SectionFocus int // One-based heading index; zero means all sections. - FoldedSections map[int]bool - HeadingInput string + ViewMode string + ViewNames []string + ViewCursor int + ViewConfirm bool + ActiveView string + activeViewState *config.SavedView + SectionsMode bool + SectionCursor int + SectionFocus int // One-based heading index; zero means all sections. + FoldedSections map[int]bool + HeadingInput string FilePath string FileModel markdown.FileModel @@ -109,6 +119,7 @@ type Model struct { WordWrap bool TermWidth int TermHeight int + waitForSize bool HideLineNumbers bool MaxVisibleOverride int ShowHeadings bool @@ -233,6 +244,7 @@ func New(filePath string, fm *markdown.FileModel, readOnly bool, showHeadings bo m.FilterDone = config.Defaults.FilterDone m.WordWrap = config.Defaults.WordWrap m.applyFileMetadata() + m.restoreSavedView() // Position cursor on first visible item if filters are active if m.hasActiveFilters() || m.ShowHeadings { diff --git a/internal/tui/move_depth_test.go b/internal/tui/move_depth_test.go new file mode 100644 index 0000000..0ab4cec --- /dev/null +++ b/internal/tui/move_depth_test.go @@ -0,0 +1,74 @@ +package tui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/niklas-heer/tdx/internal/markdown" +) + +func TestUnfilteredMovePreservesNesting(t *testing.T) { + for _, tc := range []struct { + name string + source string + selected int + key rune + want string + wantAt int + }{ + { + name: "first child cannot move above parent", + source: "- [ ] Parent\n - [ ] Child\n - [ ] Grandchild\n - [ ] Sibling\n", + selected: 1, key: 'k', wantAt: 1, + }, + { + name: "last child cannot move below parent subtree", + source: "- [ ] Parent\n - [ ] Child\n - [ ] Grandchild\n- [ ] Next parent\n", + selected: 1, key: 'j', wantAt: 1, + }, + { + name: "child subtree moves down among siblings", + source: "- [ ] Parent\n - [ ] Child\n - [ ] Grandchild\n - [ ] Sibling\n", + selected: 1, key: 'j', wantAt: 2, + want: "- [ ] Parent\n - [ ] Sibling\n - [ ] Child\n - [ ] Grandchild\n", + }, + { + name: "child subtree moves up among siblings", + source: "- [ ] Parent\n - [ ] Sibling\n - [ ] Sibling child\n - [ ] Child\n - [ ] Grandchild\n", + selected: 3, key: 'k', wantAt: 1, + want: "- [ ] Parent\n - [ ] Child\n - [ ] Grandchild\n - [ ] Sibling\n - [ ] Sibling child\n", + }, + { + name: "root subtree moves past previous subtree", + source: "- [ ] Parent\n - [ ] Child\n- [ ] Next parent\n - [ ] Next child\n", + selected: 2, key: 'k', wantAt: 0, + want: "- [ ] Next parent\n - [ ] Next child\n- [ ] Parent\n - [ ] Child\n", + }, + } { + t.Run(tc.name, func(t *testing.T) { + m := persistentNestedModel(t, tc.source) + m.SelectedIndex = tc.selected + m.ShowHeadings = false + m.FilterDone = false + if m.hasActiveFilters() { + t.Fatal("test must exercise unfiltered movement") + } + m.MoveMode = true + updated, _ := m.handleMoveKey(tea.KeyPressMsg{Code: tc.key}) + m = updated.(Model) + if m.Err != nil { + t.Fatal(m.Err) + } + want := tc.want + if want == "" { + want = tc.source + } + if got := markdown.SerializeMarkdown(&m.FileModel); got != want { + t.Fatalf("movement changed nesting:\n%s\nwant:\n%s", got, want) + } + if m.SelectedIndex != tc.wantAt { + t.Fatalf("selected = %d, want %d", m.SelectedIndex, tc.wantAt) + } + }) + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 5efffe6..3f59881 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -25,12 +25,32 @@ type FileChangedMsg struct{} func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: + resized := m.TermWidth > 0 && m.TermHeight > 0 && (m.TermWidth != msg.Width || m.TermHeight != msg.Height) m.TermWidth = msg.Width m.TermHeight = msg.Height if m.ConflictDiffMode { m.clampConflictDiffScroll(len(m.conflictDiffLines())) } + if resized { + return m, tea.ClearScreen + } return m, nil + case clipboardCopiedMsg: + m.CopyFeedback = msg.err == nil + m.Err = msg.err + if msg.err != nil { + return m, nil + } + return m, tea.Tick(1500*time.Millisecond, func(time.Time) tea.Msg { return ClearCopyFeedbackMsg{} }) + case clipboardPastedMsg: + if msg.file != m.FilePath || msg.mode != m.clipboardInputMode() || msg.buffer != m.InputBuffer || msg.cursor != m.CursorPos { + return m, nil + } + if msg.err != nil { + m.Err = msg.err + return m, nil + } + return m.handlePaste(msg.text) case ClearCopyFeedbackMsg: m.CopyFeedback = false return m, nil @@ -86,6 +106,10 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.handleVersionsKey(msg) } + if m.ViewMode != "" { + return m.handleViewKey(msg) + } + if m.HeadingInput != "" { return m.handleHeadingInput(msg) } @@ -256,11 +280,10 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case "c": if len(m.FileModel.Todos) > 0 { - util.CopyToClipboard(m.FileModel.Todos[m.SelectedIndex].Text) - m.CopyFeedback = true - return m, tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg { - return ClearCopyFeedbackMsg{} - }) + clipboard := m.clipboard() + text := m.FileModel.Todos[m.SelectedIndex].Text + m.CopyFeedback = false + return m, func() tea.Msg { return clipboardCopiedMsg{clipboard.Copy(text)} } } case "m": @@ -483,7 +506,9 @@ func (m Model) handleInputKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.CursorPos = len(m.InputBuffer) case "ctrl+v", "ctrl+shift+v", "ctrl+y": - return m.handlePaste(util.PasteFromClipboard()) + clipboard := m.clipboard() + result := clipboardPastedMsg{file: m.FilePath, mode: m.clipboardInputMode(), buffer: m.InputBuffer, cursor: m.CursorPos} + return m, func() tea.Msg { result.text, result.err = clipboard.Paste(); return result } } @@ -550,81 +575,61 @@ func (m Model) handleMoveKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { key := msg.String() switch key { - case "j", "down": + case "j", "down", "k", "up": + down := key == "j" || key == "down" + action := editor.Action{Kind: editor.Move, Index: m.SelectedIndex} if m.hasActiveFilters() || m.ShowHeadings { - // Use document tree to calculate visible-list-based movement tree := m.GetDocumentTree() - selectedNode := tree.GetSelectedNode() - if selectedNode == nil || selectedNode.Type != DocNodeTodo { + selected := tree.GetSelectedNode() + if selected == nil || selected.Type != DocNodeTodo { break } - - // Remember the todo text so we can find it after the move - movedTodoText := m.FileModel.Todos[selectedNode.TodoIndex].Text - - fromIndex, targetIndex, insertAfter := tree.MoveDown() - if fromIndex != -1 && targetIndex != -1 { - // Move the todo via AST to achieve the visual position - if err := m.applyAction(editor.Action{Kind: editor.MoveToPosition, Index: fromIndex, Target: targetIndex, InsertAfter: insertAfter}); err == nil { - // Rebuild tree and headings from updated AST - m.InvalidateHeadingsCache() // Heading positions may have changed - m.InvalidateDocumentTree() - - // Find where the moved todo ended up by matching text - for i, todo := range m.FileModel.Todos { - if todo.Text == movedTodoText { - m.SelectedIndex = i - break - } - } - } + var from, target int + var after bool + if down { + from, target, after = tree.MoveDown() + } else { + from, target, after = tree.MoveUp() } - } else { - // No filters: simple insertion move - if m.SelectedIndex < len(m.FileModel.Todos)-1 { - if err := m.applyAction(editor.Action{Kind: editor.Move, Index: m.SelectedIndex, Target: m.SelectedIndex + 1}); err == nil { - m.SelectedIndex++ - } + if from < 0 || target < 0 { + break } - } - - case "k", "up": - if m.hasActiveFilters() || m.ShowHeadings { - // Use document tree to calculate visible-list-based movement - tree := m.GetDocumentTree() - selectedNode := tree.GetSelectedNode() - if selectedNode == nil || selectedNode.Type != DocNodeTodo { + action = editor.Action{Kind: editor.MoveToPosition, Index: from, Target: target, InsertAfter: after} + } else { + if action.Index < 0 || action.Index >= len(m.FileModel.Todos) { break } - - // Remember the todo text so we can find it after the move - movedTodoText := m.FileModel.Todos[selectedNode.TodoIndex].Text - - fromIndex, targetIndex, insertAfter := tree.MoveUp() - if fromIndex != -1 && targetIndex != -1 { - // Move the todo via AST to achieve the visual position - if err := m.applyAction(editor.Action{Kind: editor.MoveToPosition, Index: fromIndex, Target: targetIndex, InsertAfter: insertAfter}); err == nil { - // Rebuild tree and headings from updated AST - m.InvalidateHeadingsCache() // Heading positions may have changed - m.InvalidateDocumentTree() - - // Find where the moved todo ended up by matching text - for i, todo := range m.FileModel.Todos { - if todo.Text == movedTodoText { - m.SelectedIndex = i - break - } - } + depth := m.FileModel.Todos[action.Index].Depth + target := action.Index - 1 + if down { + target = action.Index + 1 + for target < len(m.FileModel.Todos) && m.FileModel.Todos[target].Depth > depth { + target++ } - } - } else { - // No filters: simple insertion move - if m.SelectedIndex > 0 { - if err := m.applyAction(editor.Action{Kind: editor.Move, Index: m.SelectedIndex, Target: m.SelectedIndex - 1}); err == nil { - m.SelectedIndex-- + } else { + for target >= 0 && m.FileModel.Todos[target].Depth > depth { + target-- } } + if target < 0 || target >= len(m.FileModel.Todos) { + break + } + // Reordering a subtree must not implicitly outdent it. A shallower + // target is the boundary of its parent's children; nesting changes + // remain explicit through indent/outdent commands. + if m.FileModel.Todos[target].Depth != depth { + break + } + action.Target = target + } + index, err := editor.Apply(&m.FileModel, action) + if err != nil { + m.Err = err + break } + m.SelectedIndex = index + m.InvalidateHeadingsCache() + m.InvalidateDocumentTree() case "enter": m.history.Commit() @@ -1554,7 +1559,7 @@ func (m *Model) ProcessPipedInput(input []byte) { // Check for quit in normal mode (q or esc without other modes active) if !m.InputMode && !m.EditMode && !m.SearchMode && !m.CommandMode && - !m.MoveMode && !m.FilterMode && !m.MaxVisibleInputMode && !m.HelpMode && !m.RecentFilesMode && !m.SectionsMode && m.HeadingInput == "" { + !m.MoveMode && !m.FilterMode && !m.MaxVisibleInputMode && !m.HelpMode && !m.RecentFilesMode && !m.SectionsMode && m.HeadingInput == "" && m.ViewMode == "" { if msg.String() == "q" || msg.Code == tea.KeyEsc { return } @@ -1646,29 +1651,34 @@ func (m Model) handleRecentFilesInput(key string) (tea.Model, tea.Cmd) { // Update model with new file m.FilePath = selectedFile.Path m.FileModel = *fm - m.clearSections() + m.SelectedIndex = 0 + m.SectionFocus = 0 + m.FoldedSections = nil m.history.Clear() m.RecentFilesMode = false m.RecentFilesSearch = "" - // Try to restore cursor position from recent files + m.FilteredTags = nil + m.FilteredPriorities = nil + m.FilteredDueDate = "" + m.FilterDone = m.Config().Defaults.FilterDone + m.applyFileMetadata() + m.RefreshAvailableTags() + m.ActiveView = "" + m.activeViewState = nil + // Invalidate caches to refresh AST, headings, and tree + m.InvalidateHeadingsCache() + m.InvalidateDocumentTree() + m.restoreSavedView() + m.adjustSelectionForFilter() + + // Restore only after the destination's metadata and saved view have + // established visibility; otherwise keep the first visible fallback. if recentFiles, err := m.Config().Recent.Load(); err == nil { - if savedPos := recentFiles.GetCursorPosition(selectedFile.Path); savedPos >= 0 && savedPos < len(m.FileModel.Todos) { + if savedPos := recentFiles.GetCursorPosition(selectedFile.Path); savedPos >= 0 && savedPos < len(m.FileModel.Todos) && m.isTodoVisible(savedPos) { m.SelectedIndex = savedPos - } else { - m.SelectedIndex = 0 } - } else { - m.SelectedIndex = 0 - } - - // Ensure cursor is within bounds - if m.SelectedIndex >= len(m.FileModel.Todos) { - m.SelectedIndex = util.Max(0, len(m.FileModel.Todos)-1) } - - // Invalidate caches to refresh AST, headings, and tree - m.InvalidateHeadingsCache() m.InvalidateDocumentTree() return m, nil @@ -1724,7 +1734,7 @@ func (runtime Runtime) RunPiped(filePath string, input []byte, readOnly bool) st // Try to restore cursor position from recent files (if file content hasn't changed) if recentFiles, err := m.Config().Recent.Load(); err == nil { - if savedPos := recentFiles.GetCursorPosition(filePath); savedPos >= 0 && savedPos < len(m.FileModel.Todos) { + if savedPos := recentFiles.GetCursorPosition(filePath); savedPos >= 0 && savedPos < len(m.FileModel.Todos) && m.isTodoVisible(savedPos) { m.SelectedIndex = savedPos // Invalidate tree to ensure correct positioning m.InvalidateDocumentTree() @@ -1768,7 +1778,7 @@ func (runtime Runtime) Run(filePath string, readOnly bool, showHeadings bool, ma // Try to restore cursor position from recent files (if file content hasn't changed) if recentFiles, err := m.Config().Recent.Load(); err == nil { - if savedPos := recentFiles.GetCursorPosition(filePath); savedPos >= 0 && savedPos < len(m.FileModel.Todos) { + if savedPos := recentFiles.GetCursorPosition(filePath); savedPos >= 0 && savedPos < len(m.FileModel.Todos) && m.isTodoVisible(savedPos) { m.SelectedIndex = savedPos // Invalidate tree to ensure correct positioning m.InvalidateDocumentTree() @@ -1788,6 +1798,7 @@ func (runtime Runtime) Run(filePath string, readOnly bool, showHeadings bool, ma } // Normal TTY - use Bubbletea (no alt screen to keep context visible) + m.waitForSize = true p := tea.NewProgram(m) finalModel, err := p.Run() if err != nil { diff --git a/internal/tui/view.go b/internal/tui/view.go index 3e22cdf..f6fc74a 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -9,15 +9,22 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" "github.com/niklas-heer/tdx/internal/config" "github.com/niklas-heer/tdx/internal/markdown" - "github.com/niklas-heer/tdx/internal/util" "github.com/sergi/go-diff/diffmatchpatch" ) // View renders the TUI func (m Model) View() tea.View { - return tea.NewView(m.renderView()) + if m.waitForSize && (m.TermWidth <= 0 || m.TermHeight <= 0) { + return tea.NewView("") + } + content := fitFrame(m.renderView(), m.TermWidth, m.frameHeight()) + if height := m.frameHeight(); height > 0 { + content += strings.Repeat("\n", max(0, height-lipgloss.Height(content))) + } + return tea.NewView(content) } func compositeOverlay(foreground, background string) string { @@ -39,18 +46,18 @@ func (m Model) renderView() string { return m.renderVersionsBrowser() } + if m.ViewMode != "" { + return m.renderViews() + } if m.SectionsMode { return m.renderSections() } - // Render main content and status bar - mainContent := m.renderMainContent() - statusBar := m.renderStatusBar() - - // Combine main content and status bar - background := mainContent + "\n" + statusBar + // Reserve actual rows for chrome before laying out the task viewport. + statusBar := fitFrame(m.renderStatusBar(), m.TermWidth, 0) + header := "" if m.SectionFocus > 0 && m.SectionFocus <= len(m.GetHeadings()) { - background = styles.Cyan("Section: "+m.GetHeadings()[m.SectionFocus-1].Text) + styles.Dim(" S all tasks · s sections") + "\n\n" + background + header = styles.Cyan("Section: "+m.GetHeadings()[m.SectionFocus-1].Text) + styles.Dim(" S all tasks · s sections") } else if len(m.FoldedSections) > 0 { var names []string for i, h := range m.GetHeadings() { @@ -58,105 +65,51 @@ func (m Model) renderView() string { names = append(names, h.Text) } } - background = styles.Dim("Folded: "+strings.Join(names, ", ")+" · s sections · S show all") + "\n\n" + background - } - - // If there's an overlay active, composite it on top - if m.RecentFilesMode { - // Ensure there's space for overlay positioning - contentLines := strings.Count(mainContent, "\n") - minLines := 10 // Minimum lines to ensure overlay positioning works well - if contentLines < minLines { - for i := contentLines; i < minLines; i++ { - background += "\n" - } - } - - overlayContent := m.renderRecentFilesOverlay() - // Position overlay just above status bar - return compositeOverlay(overlayContent, background) - } - - if m.FilterMode { - // Ensure there's space for overlay positioning - contentLines := strings.Count(mainContent, "\n") - minLines := 10 // Minimum lines to ensure overlay positioning works well - if contentLines < minLines { - for i := contentLines; i < minLines; i++ { - background += "\n" - } - } - - overlayContent := m.renderFilterOverlayCompact() - // Position overlay just above status bar - return compositeOverlay(overlayContent, background) - } - - if m.PriorityFilterMode { - // Ensure there's space for overlay positioning - contentLines := strings.Count(mainContent, "\n") - minLines := 10 // Minimum lines to ensure overlay positioning works well - if contentLines < minLines { - for i := contentLines; i < minLines; i++ { - background += "\n" - } - } - - overlayContent := m.renderPriorityFilterOverlayCompact() - // Position overlay just above status bar - return compositeOverlay(overlayContent, background) - } - - if m.DueFilterMode { - // Ensure there's space for overlay positioning - contentLines := strings.Count(mainContent, "\n") - minLines := 10 // Minimum lines to ensure overlay positioning works well - if contentLines < minLines { - for i := contentLines; i < minLines; i++ { - background += "\n" - } - } - - overlayContent := m.renderDueFilterOverlayCompact() - // Position overlay just above status bar - return compositeOverlay(overlayContent, background) - } - - if m.ThemeMode { - // Ensure there's space for overlay positioning - contentLines := strings.Count(mainContent, "\n") - minLines := 10 // Minimum lines to ensure overlay positioning works well - if contentLines < minLines { - for i := contentLines; i < minLines; i++ { - background += "\n" - } - } - - overlayContent := m.renderThemeOverlayCompact() - // Position overlay just above status bar - return compositeOverlay(overlayContent, background) - } - - if m.CommandMode { - // Ensure there's space for overlay positioning - contentLines := strings.Count(mainContent, "\n") - minLines := 10 // Minimum lines to ensure overlay positioning works well - if contentLines < minLines { - for i := contentLines; i < minLines; i++ { - background += "\n" - } - } - - overlayContent := m.renderCommandOverlayCompact() - // Position overlay just above status bar - return compositeOverlay(overlayContent, background) + header = styles.Dim("Folded: " + strings.Join(names, ", ") + " · s sections · S show all") + } + if header != "" { + header = fitFrame(header, m.TermWidth, 1) + "\n\n" + } + mainContent, anchor := m.renderMainContentWithAnchor() + if height := m.frameHeight(); height > 0 { + statusBar = fitFrame(statusBar, m.TermWidth, max(1, height/3)) + rows := height - strings.Count(header, "\n") - lipgloss.Height(statusBar) + mainContent = taskViewport(mainContent, anchor, max(1, rows)) + mainContent += strings.Repeat("\n", max(0, rows-lipgloss.Height(mainContent))) + } + background := header + mainContent + "\n" + statusBar + + var overlay string + switch { + case m.RecentFilesMode: + overlay = m.renderRecentFilesOverlay() + case m.FilterMode: + overlay = m.renderFilterOverlayCompact() + case m.PriorityFilterMode: + overlay = m.renderPriorityFilterOverlayCompact() + case m.DueFilterMode: + overlay = m.renderDueFilterOverlayCompact() + case m.ThemeMode: + overlay = m.renderThemeOverlayCompact() + case m.CommandMode: + overlay = m.renderCommandOverlayCompact() + } + if overlay != "" { + overlay = fitFrame(overlay, m.TermWidth, m.frameHeight()) + minHeight := max(11, lipgloss.Height(background), lipgloss.Height(overlay)+1) + if height := m.frameHeight(); height > 0 { + minHeight = min(minHeight, height) + } + background += strings.Repeat("\n", max(0, minHeight-lipgloss.Height(background))) + return compositeOverlay(overlay, background) } return background } -// renderMainContent renders the main todo list (without status bar) -func (m Model) renderMainContent() string { +// renderMainContentWithAnchor renders task rows and the selected task/cursor row. +func (m Model) renderMainContentWithAnchor() (string, int) { + anchor := 0 var b strings.Builder styles := m.Styles() config := m.Config() @@ -350,6 +303,10 @@ func (m Model) renderMainContent() string { relIndex = (startIdx + displayIdx) - selectedVisiblePos } + if isSelected { + anchor = strings.Count(b.String(), "\n") + } + // Relative index var indexStr string if m.HideLineNumbers { @@ -388,7 +345,7 @@ func (m Model) renderMainContent() string { // Add indentation based on nesting depth (2 spaces per level) indent := strings.Repeat(" ", todo.Depth) prefix := fmt.Sprintf("%s%s%s%s ", indent, styles.Dim(indexStr), arrow, checkbox) - prefixWidth := (todo.Depth * 2) + 3 + 3 + 3 + 1 // indent + index(3) + arrow(3) + checkbox(3) + space(1) + prefixWidth := lipgloss.Width(prefix) // Text with inline code rendering and tag colorization var text string @@ -405,37 +362,11 @@ func (m Model) renderMainContent() string { text = ColorizeDueDates(text, styles.DueUrgent, styles.DueSoon, styles.DueFuture) } - // Show edit cursor if in edit mode on this item if m.EditMode && isSelected && !m.SearchMode { - plainText = m.InputBuffer - - // If wrapping is enabled, insert cursor and wrap the text - if m.WordWrap && m.TermWidth > 0 { - before := m.InputBuffer[:m.CursorPos] - after := m.InputBuffer[m.CursorPos:] - cursor := lipgloss.NewStyle().Reverse(true).Render(" ") - textWithCursor := before + cursor + after - - // Wrap text with cursor included - availWidth := m.TermWidth - prefixWidth - indent := strings.Repeat(" ", prefixWidth) - wrappedLines := util.WrapText(textWithCursor, availWidth, indent) - - // Render wrapped lines - for i, line := range wrappedLines { - if i == 0 { - b.WriteString(prefix + line + "\n") - } else { - b.WriteString(line + "\n") - } - } - continue // Skip normal rendering - } else { - // No wrapping - simple cursor insertion - before := m.InputBuffer[:m.CursorPos] - after := m.InputBuffer[m.CursorPos:] - text = before + lipgloss.NewStyle().Reverse(true).Render(" ") + after - } + line, cursorRow := m.renderEditorLine(prefix) + anchor += cursorRow + b.WriteString(line) + continue } // Render the todo line @@ -448,14 +379,18 @@ func (m Model) renderMainContent() string { // If in input mode with insert-after-cursor, show input line after selected item if m.InputMode && m.InsertAfterCursor && isSelected { - b.WriteString(m.renderInputLine(styles, config)) + line, cursorRow := m.renderEditorLine(m.inputPrefix(styles, config)) + anchor = strings.Count(b.String(), "\n") + cursorRow + b.WriteString(line) } } // Input mode at end - show new task at end when not inserting after cursor // Also handles the case when inserting after cursor but there are no todos if m.InputMode && (!m.InsertAfterCursor || len(todosToShow) == 0) { - b.WriteString(m.renderInputLine(styles, config)) + line, cursorRow := m.renderEditorLine(m.inputPrefix(styles, config)) + anchor = strings.Count(b.String(), "\n") + cursorRow + b.WriteString(line) } // Show indicator for items below (when scrolling is active) @@ -479,7 +414,7 @@ func (m Model) renderMainContent() string { first, last := sectionBounds(m.GetHeadings(), m.SectionFocus-1, len(m.FileModel.Todos)) if first == last { b.WriteString(styles.Dim("No tasks in this section. Press n to add one, or S for all tasks.") + "\n") - return b.String() + return b.String(), anchor } } b.WriteString(styles.Dim(" No todos match current filters.")) @@ -512,47 +447,12 @@ func (m Model) renderMainContent() string { b.WriteString("\n") - return b.String() + return b.String(), anchor } -// renderInputLine renders the new task input line with word wrap support -func (m Model) renderInputLine(styles *StyleFuncsType, config *ConfigType) string { - var b strings.Builder - - arrow := styles.Cyan(" " + config.Display.SelectMarker + " ") - checkbox := styles.Dim("[ ]") - indexStr := styles.Dim(" 0") - - // Build prefix - prefix := fmt.Sprintf("%s%s%s ", indexStr, arrow, checkbox) - prefixWidth := 3 + 3 + 3 + 1 // index(3) + arrow(3) + checkbox(3) + space(1) - - before := m.InputBuffer[:m.CursorPos] - after := m.InputBuffer[m.CursorPos:] - cursor := lipgloss.NewStyle().Reverse(true).Render(" ") - - // Apply word wrap if enabled - if m.WordWrap && m.TermWidth > 0 { - textWithCursor := before + cursor + after - availWidth := m.TermWidth - prefixWidth - if availWidth > 10 { - indent := strings.Repeat(" ", prefixWidth) - wrappedLines := util.WrapText(textWithCursor, availWidth, indent) - - for i, line := range wrappedLines { - if i == 0 { - b.WriteString(prefix + line + "\n") - } else { - b.WriteString(line + "\n") - } - } - return b.String() - } - } - - // No wrapping - simple output - fmt.Fprintf(&b, "%s%s%s%s\n", prefix, before, cursor, after) - return b.String() +// inputPrefix renders the new task marker and checkbox. +func (m Model) inputPrefix(styles *StyleFuncsType, config *ConfigType) string { + return styles.Dim(" 0") + styles.Cyan(" "+config.Display.SelectMarker+" ") + styles.Dim("[ ]") + " " } // renderStatusBar renders the status bar at the bottom @@ -633,8 +533,11 @@ func (m Model) renderStatusBar() string { } else { // Normal status bar with mode indicators and help var indicators []string + if m.ActiveView != "" { + indicators = append(indicators, m.activeViewLabel()) + } if m.ReadOnly { - indicators = append(indicators, "🔒READ ONLY") + indicators = append(indicators, "MANUAL SAVE · :save to write") } if m.FilterDone { indicators = append(indicators, "⊘ FILTERED") @@ -1263,12 +1166,16 @@ func (m Model) renderConflictDiff() string { if width < 34 { width = 34 } + contentWidth := width - 4 // Width includes both borders and horizontal padding. + for i, line := range visible { + visible[i] = ansi.Truncate(line, contentWidth, "…") + } title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#f7768e")). Render("FILE CONFLICT - LOCAL -> DISK [" + filepath.Base(m.FilePath) + "]") legend := styles.Dim("struck: local only | green: authoritative disk") footer := styles.Dim("[PgUp/PgDn] Scroll | [Esc] Close | then :reload or :force-save") - body := title + "\n" + legend + "\n" + strings.Repeat("─", width) + "\n" + - strings.Join(visible, "\n") + "\n" + strings.Repeat("─", width) + "\n" + footer + body := ansi.Truncate(title, contentWidth, "…") + "\n" + ansi.Truncate(legend, contentWidth, "…") + "\n" + strings.Repeat("─", contentWidth) + "\n" + + strings.Join(visible, "\n") + "\n" + strings.Repeat("─", contentWidth) + "\n" + ansi.Truncate(footer, contentWidth, "…") return lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). @@ -1288,7 +1195,7 @@ func (m Model) conflictDiffLines() []string { } func (m Model) conflictDiffHeight() int { - height := m.TermHeight - 7 + height := m.TermHeight - 8 if height < 3 { height = 3 } @@ -1320,14 +1227,14 @@ func (m Model) renderVersionsBrowser() string { browserWidth = 40 } - // innerWidth is the Width() argument passed to outerStyle (content+padding, excluding borders). - // outerStyle uses Padding(0, 1), so the actual text area = innerWidth - 2. - // All content (left pane, right pane, dividers) must fit in contentWidth to prevent wrapping. - innerWidth := browserWidth - 4 // subtract 2 border chars + 2 to keep box inside terminal - if innerWidth < 22 { - innerWidth = 22 + // Lipgloss Width includes borders and padding. Leave four terminal columns + // outside the box, then reserve its two border and two padding columns. + // Pane rows and dividers must fit the remaining text area without reflow. + outerWidth := browserWidth - 4 + if outerWidth < 22 { + outerWidth = 22 } - contentWidth := innerWidth - 2 // subtract left+right padding (1 each) + contentWidth := outerWidth - 4 if contentWidth < 20 { contentWidth = 20 } @@ -1414,6 +1321,7 @@ func (m Model) renderVersionsBrowser() string { } var rightLines []string for _, line := range visible { + line = ansi.Truncate(line, rightWidth, "…") // Use lipgloss.Width to measure visible width (strips ANSI codes correctly), // then pad manually. Avoid Width().Render() which may reflow ANSI-coded text // at space boundaries and produce spurious spacing inside colored segments. @@ -1447,8 +1355,8 @@ func (m Model) renderVersionsBrowser() string { headerRendered := lipgloss.NewStyle(). Foreground(lipgloss.Color("#7aa2f7")). Bold(true). - Render(title) - footerRendered := styles.Dim(footer) + Render(ansi.Truncate(title, contentWidth, "…")) + footerRendered := styles.Dim(ansi.Truncate(footer, contentWidth, "…")) fullContent := headerRendered + "\n" + divider + "\n" + body + "\n" + @@ -1467,7 +1375,7 @@ func (m Model) renderVersionsBrowser() string { BottomRight: "┘", }). BorderForeground(lipgloss.Color("#7aa2f7")). - Width(innerWidth). + Width(outerWidth). Padding(0, 1) return outerStyle.Render(fullContent) diff --git a/internal/tui/views.go b/internal/tui/views.go new file mode 100644 index 0000000..eceeb28 --- /dev/null +++ b/internal/tui/views.go @@ -0,0 +1,296 @@ +package tui + +import ( + "fmt" + "reflect" + "slices" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/niklas-heer/tdx/internal/config" +) + +func (m *Model) sectionRefs() []config.SectionRef { + var refs []config.SectionRef + var levels []int + var path []string + counts := map[string]int{} + for _, h := range m.GetHeadings() { + for len(levels) > 0 && levels[len(levels)-1] >= h.Level { + levels = levels[:len(levels)-1] + path = path[:len(path)-1] + } + levels = append(levels, h.Level) + path = append(path, h.Text) + key := fmt.Sprintf("%q", path) + counts[key]++ + refs = append(refs, config.SectionRef{Path: slices.Clone(path), Occurrence: counts[key]}) + } + return refs +} +func (m *Model) captureView() config.SavedView { + v := config.SavedView{Tags: slices.Clone(m.FilteredTags), Priorities: slices.Clone(m.FilteredPriorities), Due: m.FilteredDueDate, FilterDone: m.FilterDone, ShowHeadings: m.ShowHeadings} + slices.Sort(v.Tags) + slices.Sort(v.Priorities) + for i, ref := range m.sectionRefs() { + if m.SectionFocus == i+1 { + r := ref + v.Focus = &r + } + if m.FoldedSections[i] { + v.Folded = append(v.Folded, ref) + } + } + return v +} +func (m *Model) applyView(name string, v config.SavedView) { + m.FilteredTags = slices.Clone(v.Tags) + m.FilteredPriorities = slices.Clone(v.Priorities) + m.FilteredDueDate = v.Due + m.FilterDone = v.FilterDone + m.ShowHeadings = v.ShowHeadings + m.SectionFocus = 0 + m.FoldedSections = nil + for i, ref := range m.sectionRefs() { + if v.Focus != nil && reflect.DeepEqual(ref, *v.Focus) { + m.SectionFocus = i + 1 + } + for _, fold := range v.Folded { + if reflect.DeepEqual(ref, fold) { + if m.FoldedSections == nil { + m.FoldedSections = map[int]bool{} + } + m.FoldedSections[i] = true + } + } + } + m.ActiveView = name + state := m.captureView() + m.activeViewState = &state + m.InvalidateDocumentTree() + m.adjustSelectionForFilter() +} +func (m *Model) activeViewLabel() string { + label := "View: " + m.ActiveView + if m.activeViewState != nil && !reflect.DeepEqual(*m.activeViewState, m.captureView()) { + label += " (modified)" + } + return label +} +func (m *Model) restoreSavedView() { + store := m.Config().Views + if store == nil || !m.Config().ViewsRestore { + return + } + state, err := store.Load(m.FilePath) + if err != nil { + m.Err = err + return + } + if view, ok := state.Views[state.Active]; ok { + m.applyView(state.Active, view) + } +} +func (m *Model) openViews(mode string) { + if m.Config().Views == nil { + m.Err = fmt.Errorf("saved views are unavailable in this session") + return + } + state, err := m.Config().Views.Load(m.FilePath) + if err != nil { + m.Err = err + return + } + m.ViewNames = nil + for name := range state.Views { + m.ViewNames = append(m.ViewNames, name) + } + slices.Sort(m.ViewNames) + m.ViewMode = mode + m.ViewCursor = 0 + m.ViewConfirm = false + m.CommandMode = false + m.InputBuffer = "" + m.CursorPos = 0 +} +func (m *Model) closeViews() { + m.ViewMode = "" + m.ViewConfirm = false + m.InputBuffer = "" + m.CursorPos = 0 +} +func (m *Model) clearSavedView() { + // Persist clearing the last-used view before changing the visible state. + if store := m.Config().Views; store != nil { + state, err := store.Load(m.FilePath) + if err != nil { + m.Err = err + return + } + state.Active = "" + if err = store.Save(m.FilePath, state); err != nil { + m.Err = err + return + } + } + m.FilteredTags = nil + m.FilteredPriorities = nil + m.FilteredDueDate = "" + m.FilterDone = false + m.ActiveView = "" + m.activeViewState = nil + m.clearSections() +} +func (m *Model) commitView() { + store := m.Config().Views + state, err := store.Load(m.FilePath) + if err != nil { + m.Err = err + return + } + name := strings.TrimSpace(m.InputBuffer) + if m.ViewMode != "save" { + if len(m.ViewNames) == 0 { + return + } + name = m.ViewNames[m.ViewCursor] + } + switch m.ViewMode { + case "save": + if name == "" { + return + } + if _, ok := state.Views[name]; ok && !m.ViewConfirm { + m.ViewConfirm = true + return + } + state.Views[name] = m.captureView() + state.Active = name + case "delete": + if !m.ViewConfirm { + m.ViewConfirm = true + return + } + delete(state.Views, name) + if state.Active == name { + state.Active = "" + } + case "load": + if _, ok := state.Views[name]; !ok { + m.Err = fmt.Errorf("saved view %q no longer exists", name) + return + } + state.Active = name + } + if err = store.Save(m.FilePath, state); err != nil { + m.Err = err + return + } + if m.ViewMode == "delete" { + if m.ActiveView == name { + m.ActiveView = "" + m.activeViewState = nil + } + } else { + m.applyView(name, state.Views[name]) + } + m.closeViews() +} +func (m Model) handleViewKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + key := msg.String() + if m.ViewConfirm { + switch key { + case "y", "Y": + m.commitView() + case "esc", "n", "N": + m.ViewConfirm = false + } + return m, nil + } + switch key { + case "esc": + m.closeViews() + case "enter": + m.commitView() + default: + if m.ViewMode == "save" { + return m.handleInputKey(msg) + } + switch key { + case "down", "j": + m.ViewCursor = min(m.ViewCursor+1, max(0, len(m.ViewNames)-1)) + case "up", "k": + m.ViewCursor = max(0, m.ViewCursor-1) + } + } + return m, nil +} +func (m Model) renderViews() string { + var b strings.Builder + title := "Saved views" + if m.ViewMode == "delete" { + title = "Delete saved view" + } + if m.ViewMode == "save" { + title = "Save current view" + } + b.WriteString(m.Styles().Cyan(title) + "\n\n") + if m.ViewMode == "save" { + before := m.InputBuffer[:m.CursorPos] + input := before + lipgloss.NewStyle().Reverse(true).Render(" ") + m.InputBuffer[m.CursorPos:] + if m.TermWidth > 8 { + width := m.TermWidth - 7 + left := max(0, lipgloss.Width(before)-width+1) + input = ansi.Cut(input, left, left+width) + } + b.WriteString("Name: " + input + "\n") + b.WriteString("Includes tags, priorities, due date, completion and sections.\n") + } else { + if len(m.ViewNames) == 0 { + b.WriteString("No saved views. Use :save-view to create one.\n") + } + rows := max(1, m.TermHeight-8) + start := max(0, m.ViewCursor-rows/2) + for i := start; i < len(m.ViewNames) && i < start+rows; i++ { + marker := " " + if i == m.ViewCursor { + marker = "➜ " + } + label := marker + m.ViewNames[i] + if m.TermWidth > 4 { + label = ansi.Truncate(label, m.TermWidth-4, "…") + } + if i == m.ViewCursor { + label = m.Styles().Cyan(label) + } + b.WriteString(label + "\n") + } + } + if m.ViewConfirm { + action := "Replace existing view" + if m.ViewMode == "delete" { + action = "Delete selected view" + } + b.WriteString("\n" + action + "? y confirm · n/esc cancel") + } else { + b.WriteString("\nenter confirm · esc cancel") + if m.ViewMode != "save" { + b.WriteString(" · ↑/↓ select") + } + } + if m.Err != nil { + b.WriteString("\n" + m.Err.Error()) + } + lines := strings.Split(b.String(), "\n") + if m.TermHeight > 0 && len(lines) > m.TermHeight { + lines = lines[:m.TermHeight] + } + if m.TermWidth > 0 { + for i, line := range lines { + lines[i] = ansi.Truncate(line, m.TermWidth, "…") + } + } + return strings.Join(lines, "\n") + "\n" +} diff --git a/internal/tui/views_test.go b/internal/tui/views_test.go new file mode 100644 index 0000000..db1cf06 --- /dev/null +++ b/internal/tui/views_test.go @@ -0,0 +1,330 @@ +package tui + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/niklas-heer/tdx/internal/config" + "github.com/niklas-heer/tdx/internal/markdown" + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf8" +) + +func viewModel(t *testing.T) Model { + t.Helper() + m := testModelWithMarkdown("# Work\n## Backend\n- [ ] Ship #work !p1 @due(2030-01-01)\n## Frontend\n- [x] Polish #design\n") + m.FilePath = filepath.Join(t.TempDir(), "todo.md") + m.ReadOnly = true + m.Config().Views = config.NewFileViewStore(t.TempDir()) + return m +} +func viewKey(m Model, key rune) Model { + result, _ := m.Update(tea.KeyPressMsg{Code: key}) + return result.(Model) +} +func TestSavedViewWorkflow(t *testing.T) { + m := viewModel(t) + before := markdown.SerializeMarkdown(&m.FileModel) + m.FilteredTags = []string{"work"} + m.FilteredPriorities = []int{1} + m.FilteredDueDate = "all" + m.FilterDone = true + m.SectionFocus = 2 + m.FoldedSections = map[int]bool{2: true} + m.ShowHeadings = true + want := m.captureView() + m.openViews("save") + m.InputBuffer = "Today" + m.CursorPos = 5 + m = viewKey(m, tea.KeyEnter) + if m.ActiveView != "Today" || m.ViewMode != "" { + t.Fatalf("view not saved: %+v", m) + } + m.FilterDone = false + if !strings.Contains(m.activeViewLabel(), "modified") { + t.Fatal("modified view not identified") + } + m.openViews("load") + m = viewKey(m, tea.KeyEnter) + if !m.FilterDone || m.SectionFocus != 2 || !m.FoldedSections[2] || m.FilteredDueDate != want.Due { + t.Fatal("view not applied") + } + if markdown.SerializeMarkdown(&m.FileModel) != before { + t.Fatal("saving a view edited document") + } + m.openViews("delete") + m = viewKey(m, tea.KeyEnter) + m = viewKey(m, tea.KeyEsc) + state, _ := m.Config().Views.Load(m.FilePath) + if len(state.Views) != 1 { + t.Fatal("cancel deleted view") + } + m = viewKey(m, tea.KeyEnter) + m = viewKey(m, 'y') + state, _ = m.Config().Views.Load(m.FilePath) + if len(state.Views) != 0 || m.ActiveView != "" { + t.Fatal("delete failed") + } +} +func TestSavedViewCancelAndOverwrite(t *testing.T) { + m := viewModel(t) + m.openViews("save") + m.InputBuffer = "Keep" + m.CursorPos = 4 + m = viewKey(m, tea.KeyEnter) + m.FilterDone = true + m.openViews("save") + m.InputBuffer = "Keep" + m.CursorPos = 4 + m = viewKey(m, tea.KeyEnter) + if !m.ViewConfirm { + t.Fatal("overwrite should confirm") + } + m = viewKey(m, tea.KeyEsc) + m = viewKey(m, tea.KeyEsc) + state, _ := m.Config().Views.Load(m.FilePath) + if state.Views["Keep"].FilterDone { + t.Fatal("cancel overwrote saved view") + } + if !m.FilterDone { + t.Fatal("cancel changed current filters") + } +} +func TestSavedViewRestoreOptInAndHeadingChanges(t *testing.T) { + m := viewModel(t) + m.SectionFocus = 2 + m.openViews("save") + m.InputBuffer = "Backend" + m.CursorPos = 7 + m = viewKey(m, tea.KeyEnter) + fm := markdown.ParseMarkdown("# Intro\n- [ ] Intro\n# Work\n## Backend\n- [ ] Ship\n") + off := New(m.FilePath, fm, true, false, -1, m.Config(), testStyles(), "test") + if off.SectionFocus != 0 { + t.Fatal("restore must be opt-in") + } + m.Config().ViewsRestore = true + on := New(m.FilePath, fm, true, false, -1, m.Config(), testStyles(), "test") + if on.SectionFocus != 3 { + t.Fatalf("heading context was not remapped: %d", on.SectionFocus) + } + removed := New(m.FilePath, markdown.ParseMarkdown("# Other\n- [ ] Item\n"), true, false, -1, m.Config(), testStyles(), "test") + if removed.SectionFocus != 0 { + t.Fatal("missing heading should not focus unrelated section") + } +} +func TestClearSavedViewAndStorageError(t *testing.T) { + m := viewModel(t) + m.FilterDone = true + m.openViews("save") + m.InputBuffer = "Done" + m.CursorPos = 4 + m = viewKey(m, tea.KeyEnter) + m.clearSavedView() + state, _ := m.Config().Views.Load(m.FilePath) + if state.Active != "" || m.FilterDone || m.ActiveView != "" { + t.Fatal("clear failed") + } + bad := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(bad, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + m.Config().Views = config.NewFileViewStore(bad) + m.openViews("save") + if m.Err == nil || m.ViewMode != "" { + t.Fatal("storage error should be visible and preserve mode") + } +} +func TestManualSaveAlias(t *testing.T) { + m := viewModel(t) + m.ReadOnly = false + for _, command := range m.Commands { + if command.Name == "manual-save" { + command.Handler(&m) + } + } + if !m.ReadOnly { + t.Fatal("manual-save alias did not enable temporary edits") + } + if !strings.Contains(m.renderStatusBar(), "MANUAL SAVE") { + t.Fatal("manual-save label missing") + } + m = viewKey(m, tea.KeyEnter) + if !m.FileModel.Todos[0].Checked { + t.Fatal("temporary toggle unavailable") + } + if _, err := os.Stat(m.FilePath); !os.IsNotExist(err) { + t.Fatal("temporary edit wrote file") + } +} + +func TestSavedViewPaletteAndPipedCancel(t *testing.T) { + m := viewModel(t) + m.ProcessPipedInput([]byte(":save-view\nToday\n")) + if m.ActiveView != "Today" { + t.Fatalf("palette save failed: %q %v", m.ActiveView, m.Err) + } + m.ProcessPipedInput([]byte(":save-view\nCanceled\x1b:views\n")) + if m.ViewMode != "load" { + t.Fatalf("escape quit instead of canceling name prompt: %q", m.ViewMode) + } + state, _ := m.Config().Views.Load(m.FilePath) + if len(state.Views) != 1 { + t.Fatal("cancel stored a view") + } +} + +func TestSavedViewNarrowUnicodeInput(t *testing.T) { + m := viewModel(t) + m.TermWidth = 24 + m.TermHeight = 8 + m.openViews("save") + m.InputBuffer = strings.Repeat("世界é", 40) + m.CursorPos = len(m.InputBuffer) + rendered := m.renderViews() + if !utf8.ValidString(rendered) { + t.Fatal("narrow render corrupted UTF-8") + } + for _, line := range strings.Split(strings.TrimSuffix(rendered, "\n"), "\n") { + if lipgloss.Width(line) > 24 { + t.Fatalf("overflow: %d %q", lipgloss.Width(line), line) + } + } + if len(strings.Split(strings.TrimSuffix(rendered, "\n"), "\n")) > 8 { + t.Fatal("height overflow") + } + m = viewKey(m, tea.KeyEnter) + if m.ActiveView == "" { + t.Fatal("long Unicode name not saved") + } +} + +func TestMoveSubtreeKeepsSelectionWithDuplicateText(t *testing.T) { + m := testModelWithMarkdown("- [ ] Same\n - [ ] Child A\n- [ ] Same\n - [ ] Child B\n") + m.ReadOnly = true + m = viewKey(m, 'm') + m = viewKey(m, 'j') + if m.Err != nil { + t.Fatal(m.Err) + } + if m.SelectedIndex != 2 || m.FileModel.Todos[1].Text != "Child B" || m.FileModel.Todos[3].Text != "Child A" { + t.Fatalf("down subtree selection/content: %d %+v", m.SelectedIndex, m.FileModel.Todos) + } + m = viewKey(m, 'k') + if m.Err != nil { + t.Fatal(m.Err) + } + if m.SelectedIndex != 0 || m.FileModel.Todos[1].Text != "Child A" { + t.Fatalf("up subtree selection/content: %d %+v", m.SelectedIndex, m.FileModel.Todos) + } +} + +func TestRecentSwitchRestoresCursorUsingDestinationView(t *testing.T) { + for _, tc := range []struct { + name string + metadata bool + savedView bool + savedCursor int + want int + }{ + {name: "outgoing filters do not reject saved cursor", savedCursor: 2, want: 2}, + {name: "metadata hides saved cursor", metadata: true, savedCursor: 0, want: 1}, + {name: "saved view hides saved cursor", savedView: true, savedCursor: 0, want: 1}, + {name: "saved view accepts later cursor", savedView: true, savedCursor: 2, want: 2}, + {name: "missing cursor retains visible fallback", metadata: true, savedCursor: -1, want: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source.md") + destination := filepath.Join(dir, "destination.md") + sourceText := "# Old section\n- [ ] Source #old !p1 @due(2030-01-01)\n" + destinationText := "# Destination\n- [x] Hidden #new !p2\n- [ ] First visible #new !p2\n- [ ] Saved cursor #new !p2\n" + if tc.metadata { + destinationText = "---\nfilter-done: true\n---\n" + destinationText + } + for path, content := range map[string]string{source: sourceText, destination: destinationText} { + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + cfg := testConfig() + cfg.Recent = config.RecentStore{Dir: t.TempDir()} + cfg.Views = config.NewFileViewStore(t.TempDir()) + cfg.ViewsRestore = tc.savedView + if tc.savedCursor >= 0 { + if err := cfg.Recent.SaveFile(destination, tc.savedCursor); err != nil { + t.Fatal(err) + } + } + if tc.savedView { + if err := cfg.Views.Save(destination, &config.SavedViews{Active: "Open", Views: map[string]config.SavedView{"Open": {FilterDone: true, Tags: []string{"new"}}}}); err != nil { + t.Fatal(err) + } + } + m := New(source, markdown.ParseMarkdown(sourceText), true, true, -1, cfg, testStyles(), "test") + m.FilteredTags = []string{"old"} + m.FilteredPriorities = []int{1} + m.FilteredDueDate = "all" + m.SectionFocus = 1 + m.RecentFiles = []config.RecentFile{{Path: destination}} + m.RecentFilesMode = true + m = viewKey(m, tea.KeyEnter) + if m.Err != nil { + t.Fatal(m.Err) + } + if m.SelectedIndex != tc.want || !m.isTodoVisible(m.SelectedIndex) { + t.Fatalf("selected %d, want visible %d", m.SelectedIndex, tc.want) + } + tree := m.GetDocumentTree() + if selected := tree.GetSelectedNode(); selected == nil || selected.TodoIndex != tc.want { + t.Fatalf("tree selection stale: %+v", selected) + } + actual, err := os.ReadFile(destination) + if err != nil || string(actual) != destinationText { + t.Fatal("switch modified destination", err) + } + }) + } +} + +func TestSavedViewsPreserveEmptyHeadingLabels(t *testing.T) { + for _, tc := range []struct { + name, content string + focus int + fold int + }{ + {"blank focus", "#\n- [ ] Task\n", 1, -1}, + {"blank folded heading", "#\n- [ ] Task\n", 0, 0}, + {"blank ancestor", "#\n## Child\n- [ ] Task\n", 2, 0}, + {"repeated blank headings", "#\n- [ ] First\n#\n- [ ] Second\n", 2, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + m := New(filepath.Join(t.TempDir(), "todo.md"), markdown.ParseMarkdown(tc.content), true, true, -1, testConfig(), testStyles(), "test") + m.Config().Views = config.NewFileViewStore(t.TempDir()) + m.Config().ViewsRestore = true + m.SectionFocus = tc.focus + if tc.fold >= 0 { + m.FoldedSections = map[int]bool{tc.fold: true} + } + expected := m.captureView() + m.openViews("save") + m.InputBuffer = "Blank heading view" + m.CursorPos = len(m.InputBuffer) + m = viewKey(m, tea.KeyEnter) + if m.Err != nil { + t.Fatal(m.Err) + } + restored := New(m.FilePath, markdown.ParseMarkdown(tc.content), true, true, -1, m.Config(), testStyles(), "test") + if restored.Err != nil { + t.Fatal(restored.Err) + } + if restored.SectionFocus != tc.focus || (tc.fold >= 0 && !restored.FoldedSections[tc.fold]) { + t.Fatalf("heading state not restored: focus=%d folds=%v expected=%+v", restored.SectionFocus, restored.FoldedSections, expected) + } + if restored.ActiveView != "Blank heading view" { + t.Fatal("saved view not restored") + } + }) + } +} diff --git a/internal/usage/minimize.go b/internal/usage/minimize.go new file mode 100644 index 0000000..ad92df5 --- /dev/null +++ b/internal/usage/minimize.go @@ -0,0 +1,79 @@ +package usage + +import ( + "fmt" + "regexp" + "slices" + "strings" +) + +var failureLocation = regexp.MustCompile(`^seed [0-9]+ step [0-9]+(?: \([^)]*\))?: `) + +func failureClass(err error) string { + if err == nil { + return "" + } + message := err.Error() + if !failureLocation.MatchString(message) { + return "" + } + message = failureLocation.ReplaceAllString(message, "") + class, _, _ := strings.Cut(message, ":") + return class +} + +// MinimizeFailure removes action chunks while reproducing the same failure +// category. Flat traces regenerate expectations through their independent oracle; +// candidates with invalid positional actions are discarded. The bounded budget +// prevents a failing CLI or platform dependency from stalling a CI campaign. +func MinimizeFailure(trace Trace, binary string, original error, budget int) (Trace, string) { + class := failureClass(original) + if class == "" { + return trace, "reduction unavailable: failure is not a reproducible step invariant" + } + attempts := 0 + steps := reduceSteps(trace.Steps, func(candidate []Step) bool { + if attempts >= budget { + return false + } + attempts++ + next := trace + next.Steps = slices.Clone(candidate) + if trace.Driver != "structural" { + oracle := Oracle{Tasks: slices.Clone(trace.Initial)} + for i := range next.Steps { + if err := oracle.Apply(next.Steps[i]); err != nil { + return false + } + next.Steps[i].Expected = slices.Clone(oracle.Tasks) + } + } + _, err := Replay(next, binary) + return failureClass(err) == class + }) + trace.Steps = slices.Clone(steps) + if trace.Driver != "structural" { + oracle := Oracle{Tasks: slices.Clone(trace.Initial)} + for i := range trace.Steps { + _ = oracle.Apply(trace.Steps[i]) + trace.Steps[i].Expected = slices.Clone(oracle.Tasks) + } + } + return trace, fmt.Sprintf("reduced to %d steps after %d/%d replay attempts; failure category: %s", len(steps), attempts, budget, class) +} + +func reduceSteps(steps []Step, fails func([]Step) bool) []Step { + current := slices.Clone(steps) + for chunk := max(1, len(current)/2); chunk >= 1; chunk /= 2 { + for start := 0; start < len(current); { + end := min(len(current), start+chunk) + candidate := slices.Concat(current[:start], current[end:]) + if fails(candidate) { + current = candidate + } else { + start = end + } + } + } + return current +} diff --git a/internal/usage/replay.go b/internal/usage/replay.go index 994589d..f7de534 100644 --- a/internal/usage/replay.go +++ b/internal/usage/replay.go @@ -53,6 +53,9 @@ func initialSource(tasks []Task) string { // Replay exercises real TUI Update and disk saves, or an independent executable's CLI. // Think time is accounted for, not slept. Timers and terminal I/O have a separate PTY suite. func Replay(trace Trace, binary string) (result Result, err error) { + if trace.Driver == "structural" { + return ReplayStructural(trace) + } start := time.Now() result = Result{Seed: trace.Seed, Driver: trace.Driver, Counts: map[string]int{}} encoded, marshalErr := json.Marshal(trace) diff --git a/internal/usage/structural.go b/internal/usage/structural.go new file mode 100644 index 0000000..df33d6a --- /dev/null +++ b/internal/usage/structural.go @@ -0,0 +1,245 @@ +package usage + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "math/rand/v2" + "os" + "path/filepath" + "reflect" + "runtime" + "slices" + "strings" + "time" + + "github.com/niklas-heer/tdx/internal/editor" + "github.com/niklas-heer/tdx/internal/markdown" +) + +// GenerateStructural supplements the flat independent oracle with rich source +// preservation, complete task subtrees, rejected-edit atomicity and exact undo. +func GenerateStructural(seed uint64, count int) Trace { + protected := []string{ + "<!-- preserve this comment exactly -->\n\n| A | B |\n| :--- | ---: |\n| café | `code` |\n\n~~~md\n- [ ] example only\n~~~\n\n", + "[guide]: <https://example.com/guide>\n \"Do not normalize\"\n", + } + source := "# Work\n\n" + protected[0] + "- [ ] Parent !p2 #work\n continuation with [guide].\n\n Body paragraph with **bold**.\n\n ~~~text\n Parent owned code.\n ~~~\n\n - [x] Child done !p1\n - [ ] Child open !p3\n- [ ] Sibling !p1\n\n ~~~text\n Sibling owned code.\n ~~~\n\n## Ordered\n\n9. [ ] Nine\n 1) [ ] Nested\n 2) [x] Nested done\n10. [ ] Ten\n\n" + protected[1] + if seed%2 == 1 { + source = strings.ReplaceAll(source, "\n", "\r\n") + for i := range protected { + protected[i] = strings.ReplaceAll(protected[i], "\n", "\r\n") + } + } + trace := Trace{Schema: Schema, Seed: seed, Driver: "structural", Source: source, Protected: protected} + r := rand.New(rand.NewPCG(seed, seed^0x737472756374)) + kinds := []editor.Kind{editor.Edit, editor.Toggle, editor.Insert, editor.Delete, editor.Move, editor.Indent, editor.Outdent, editor.SortDone, editor.SortPriority, editor.SortDue, editor.AddInSection, editor.RenameHeading, editor.CreateHeading, editor.ClearDone, editor.SetAllChecked, editor.Add, editor.Add} + for i := 0; i < count; i++ { + step := Step{Op: "action", Seconds: 10} + switch i % 11 { + case 9: + step.Op = "undo" + case 10: + step.Op = "reopen" + default: + kind := kinds[r.IntN(len(kinds))] + if i < len(kinds) { + kind = kinds[i] + } + a := editor.Action{Kind: kind, Index: r.IntN(8), Target: r.IntN(8), Text: fmt.Sprintf("Task-%d-%d café !p%d #work @due(2027-01-12)", seed, i, 1+r.IntN(3)), Level: 2, Checked: r.IntN(2) == 0} + if kind == editor.AddInSection || kind == editor.RenameHeading { + a.Index = r.IntN(2) + } + if kind == editor.CreateHeading { + a.Index = -1 + } + step.Action = &a + } + trace.Steps = append(trace.Steps, step) + } + return trace +} + +func ReplayStructural(trace Trace) (result Result, err error) { + started := time.Now() + result = Result{Seed: trace.Seed, Driver: trace.Driver, Counts: map[string]int{}} + encoded, _ := json.Marshal(trace) + result.TraceSHA256 = fmt.Sprintf("%x", sha256.Sum256(encoded)) + var latency []float64 + var initialMem runtime.MemStats + runtime.ReadMemStats(&initialMem) + defer func() { + var finalMem runtime.MemStats + runtime.ReadMemStats(&finalMem) + result.AllocatedBytes = finalMem.TotalAlloc - initialMem.TotalAlloc + result.WallSeconds = time.Since(started).Seconds() + slices.Sort(latency) + if len(latency) > 0 { + result.P50Millis = latency[len(latency)/2] + result.P95Millis = latency[(len(latency)-1)*95/100] + result.MaxMillis = latency[len(latency)-1] + } + if err != nil { + result.Error = err.Error() + } + }() + if trace.Schema != Schema || trace.Driver != "structural" { + return result, fmt.Errorf("unsupported structural trace") + } + dir, err := os.MkdirTemp("", "tdx-structural-") + if err != nil { + return result, err + } + defer func() { err = errors.Join(err, os.RemoveAll(dir)) }() + path := filepath.Join(dir, "tasks.md") + if err = os.WriteFile(path, []byte(trace.Source), 0600); err != nil { + return result, err + } + doc, err := markdown.ReadFile(path) + if err != nil { + return result, err + } + var history editor.History + var past []string + fail := func(index int, kind, detail string) error { + return fmt.Errorf("seed %d step %d: %s: %s", trace.Seed, index, kind, detail) + } + for i, step := range trace.Steps { + start := time.Now() + if step.Seconds <= 0 || step.Seconds > 3600 { + return result, fail(i, "invalid trace", "invalid think time") + } + before := markdown.SerializeMarkdown(doc) + beforeTodos := slices.Clone(doc.Todos) + label := step.Op + switch step.Op { + case "undo": + if len(past) > 0 { + want := past[len(past)-1] + past = past[:len(past)-1] + if !history.Undo(doc) || markdown.SerializeMarkdown(doc) != want { + return result, fail(i, "undo fidelity", "snapshot did not restore original bytes") + } + } + case "reopen": + doc, err = markdown.ReadFile(path) + if err != nil { + return result, err + } + history.Clear() + past = nil + if markdown.SerializeMarkdown(doc) != before { + return result, fail(i, "reopen fidelity", "reopened document changed bytes") + } + case "action": + if step.Action == nil { + return result, fail(i, "invalid trace", "missing action") + } + label = string(step.Action.Kind) + history.Begin(doc) + _, actionErr := editor.Apply(doc, *step.Action) + if actionErr != nil { + if markdown.SerializeMarkdown(doc) != before || !reflect.DeepEqual(doc.Todos, beforeTodos) { + return result, fail(i, "rejection atomicity", actionErr.Error()) + } + history.Cancel(doc) + label += "-rejected" + } else { + if err = checkTaskConservation(beforeTodos, doc.Todos, *step.Action); err != nil { + return result, fail(i, "task conservation", err.Error()) + } + if err = checkStructuralOwnership(before, markdown.SerializeMarkdown(doc), *step.Action, trace.Protected); err != nil { + return result, fail(i, "structural ownership", err.Error()) + } + history.Commit() + past = append(past, before) + if len(past) > editor.HistoryLimit { + past = past[1:] + } + } + default: + return result, fail(i, "invalid trace", "unknown operation") + } + source := markdown.SerializeMarkdown(doc) + for _, protected := range trace.Protected { + if strings.Count(source, protected) != strings.Count(trace.Source, protected) { + return result, fail(i, "source preservation", "protected Markdown changed") + } + } + if err = markdown.WriteFile(path, doc); err != nil { + return result, err + } + data, e := os.ReadFile(path) + if e != nil { + return result, e + } + if string(data) != source { + return result, fail(i, "save fidelity", "disk bytes differ from editor source") + } + parsed := markdown.ParseMarkdown(source) + if !reflect.DeepEqual(parsed.Todos, doc.Todos) { + return result, fail(i, "parse stability", "cached task structure differs after reopening") + } + result.Actions++ + result.SimulatedSeconds += step.Seconds + result.Counts[label]++ + latency = append(latency, float64(time.Since(start).Microseconds())/1000) + } + return result, nil +} + +// Check task identity independently of the source-patch implementation. Bodies +// and descendants must travel with their task through sorting and movement. +func checkTaskConservation(before, after []markdown.Todo, a editor.Action) error { + key := func(t markdown.Todo) string { return fmt.Sprintf("%t:%s", t.Checked, t.Text) } + keys := func(tasks []markdown.Todo) []string { + out := make([]string, len(tasks)) + for i, t := range tasks { + out[i] = key(t) + } + slices.Sort(out) + return out + } + expected := slices.Clone(before) + switch a.Kind { + case editor.Toggle: + expected[a.Index].Checked = !expected[a.Index].Checked + case editor.SetChecked: + expected[a.Index].Checked = a.Checked + case editor.SetAllChecked: + for i := range expected { + expected[i].Checked = a.Checked + } + case editor.Delete: + expected = slices.Delete(expected, a.Index, a.Index+1) + case editor.ClearDone: + expected = slices.DeleteFunc(expected, func(t markdown.Todo) bool { return t.Checked }) + case editor.Edit: + if len(before) != len(after) { + return fmt.Errorf("edit changed task count") + } + expected = slices.Delete(expected, a.Index, a.Index+1) + after = slices.Delete(slices.Clone(after), a.Index, a.Index+1) + case editor.Add, editor.Insert, editor.AddInSection: + if len(after) != len(before)+1 { + return fmt.Errorf("insert did not add exactly one task") + } + // Remove exactly one new task, leaving all previous bodies untouched. + found := -1 + for i, t := range after { + if t.Text == a.Text { + found = i + break + } + } + if found < 0 { + return fmt.Errorf("inserted task missing") + } + after = slices.Delete(slices.Clone(after), found, found+1) + } + if !slices.Equal(keys(expected), keys(after)) { + return fmt.Errorf("unrelated task text or completion changed during %s", a.Kind) + } + return nil +} diff --git a/internal/usage/structural_oracle.go b/internal/usage/structural_oracle.go new file mode 100644 index 0000000..8c0c675 --- /dev/null +++ b/internal/usage/structural_oracle.go @@ -0,0 +1,325 @@ +package usage + +import ( + "fmt" + "slices" + "strings" + + "github.com/niklas-heer/tdx/internal/editor" + "github.com/niklas-heer/tdx/internal/markdown" + "github.com/yuin/goldmark/ast" +) + +// Structural campaigns use unique titles as identities. This oracle checks +// ownership independently of the production source ranges and move algorithm. +// Ambiguous hand-written traces retain the multiset and source checks. +func checkStructuralOwnership(beforeSource, afterSource string, a editor.Action, protected []string) error { + before, _ := markdown.ParseAST(beforeSource) + after, _ := markdown.ParseAST(afterSource) + old, current := before.ExtractTodos(), after.ExtractTodos() + switch a.Kind { + case editor.Edit, editor.Toggle, editor.SetChecked, editor.Delete, editor.Move, editor.MoveToPosition, editor.Indent, editor.Outdent, editor.Insert: + if a.Index < 0 || a.Index >= len(old) { + return fmt.Errorf("successful action has invalid task index") + } + } + if (a.Kind == editor.Move || a.Kind == editor.MoveToPosition) && (a.Target < 0 || a.Target >= len(old)) { + return fmt.Errorf("successful move has invalid destination") + } + if a.Kind == editor.Outdent && old[a.Index].ParentIndex < 0 { + return fmt.Errorf("successful outdent has no parent") + } + oldHeadings, newHeadings := before.ExtractHeadings(), after.ExtractHeadings() + if a.Kind == editor.CreateHeading { + insert := len(oldHeadings) + if a.Index >= 0 && a.Index < len(oldHeadings) { + for i := a.Index + 1; i < len(oldHeadings); i++ { + if oldHeadings[i].Level <= oldHeadings[a.Index].Level { + insert = i + break + } + } + } + if insert >= len(newHeadings) || newHeadings[insert].Text != strings.TrimSpace(a.Text) || newHeadings[insert].Level != a.Level { + return fmt.Errorf("new heading missing from requested position") + } + newHeadings = slices.Delete(newHeadings, insert, insert+1) + } + if len(oldHeadings) != len(newHeadings) { + return fmt.Errorf("action changed unrelated heading structure") + } + for i, h := range oldHeadings { + want := h.Text + if a.Kind == editor.RenameHeading && a.Index == i { + want = strings.TrimSpace(a.Text) + } + if newHeadings[i].Text != want || newHeadings[i].Level != h.Level { + return fmt.Errorf("action changed unrelated heading %d", i) + } + } + for _, block := range protected { + left, right := strings.Index(beforeSource, block), strings.Index(afterSource, block) + if left < 0 || right < 0 { + continue // The byte-preservation check reports missing blocks. + } + for i := range oldHeadings { + if (left < oldHeadings[i].LineNo) != (right < newHeadings[i].LineNo) { + return fmt.Errorf("protected block crossed heading %d", i) + } + } + for _, other := range protected { + if (left < strings.Index(beforeSource, other)) != (right < strings.Index(afterSource, other)) { + return fmt.Errorf("protected blocks changed document order") + } + } + } + identities := map[string]int{} + for i, task := range old { + if _, duplicate := identities[task.Text]; duplicate { + return nil + } + identities[task.Text] = i + } + if a.Kind == editor.Edit { + if len(current) != len(old) { + return fmt.Errorf("edit changed task count") + } + node, _ := after.FindTodoNode(a.Index) + lines := node.CheckBox.Parent().Lines() + line := lines.At(0) + if current[a.Index].Checked != old[a.Index].Checked || (a.Text != old[a.Index].Text && (lines.Len() != 1 || strings.TrimSpace(string(line.Value(after.Source))[3:]) != strings.TrimSpace(a.Text))) { + return fmt.Errorf("edit did not replace the selected paragraph") + } + if other, collision := identities[current[a.Index].Text]; collision && other != a.Index { + return nil // This edit made title-based identity ambiguous. + } + delete(identities, old[a.Index].Text) + identities[current[a.Index].Text] = a.Index + } + ids := make([]int, len(current)) + positions := map[int]int{} + for i, task := range current { + id, known := identities[task.Text] + if !known { + id = -1 // The conservation oracle validates the one inserted task. + } + if _, duplicate := positions[id]; duplicate && id >= 0 { + return fmt.Errorf("task identity duplicated") + } + ids[i] = id + positions[id] = i + } + parents, depths := make([]int, len(old)), make([]int, len(old)) + for i, task := range old { + parents[i], depths[i] = task.ParentIndex, task.Depth + } + removed := map[int]bool{} + switch a.Kind { + case editor.Delete: + removed[a.Index] = true + case editor.ClearDone: + for i, task := range old { + removed[i] = task.Checked + } + } + for i := range old { + for p := old[i].ParentIndex; p >= 0; p = old[p].ParentIndex { + if removed[p] { + depths[i]-- + } + } + for parents[i] >= 0 && removed[parents[i]] { + parents[i] = old[parents[i]].ParentIndex + } + } + moved := -1 + switch a.Kind { + case editor.Move, editor.MoveToPosition: + moved = a.Index + parents[moved] = old[a.Target].ParentIndex + if a.Index == a.Target { + moved = -1 + } + case editor.Indent: + moved = a.Index + selected, _ := before.FindTodoNode(moved) + parents[moved] = -1 + for i := moved - 1; i >= 0; i-- { + node, _ := before.FindTodoNode(i) + if node.ListItem == selected.ListItem.PreviousSibling() { + parents[moved] = i + break + } + } + case editor.Outdent: + moved = a.Index + parents[moved] = old[old[moved].ParentIndex].ParentIndex + } + if moved >= 0 { + depth := old[moved].Depth + switch a.Kind { + case editor.Indent: + depth++ + case editor.Outdent: + depth-- + default: + depth = old[a.Target].Depth + } + delta := depth - old[moved].Depth + for i := moved; i < len(old) && (i == moved || old[i].Depth > old[moved].Depth); i++ { + depths[i] += delta + } + } + section := func(headings []markdown.Heading, offset int) int { + index := -1 + for i, h := range headings { + if h.LineNo < offset { + index = i + } + } + return index + } + if a.Kind == editor.Add || a.Kind == editor.Insert || a.Kind == editor.AddInSection { + pos, exists := positions[-1] + if !exists { + return fmt.Errorf("inserted task identity missing") + } + parent, depth, heading := -1, 0, a.Index + anchor := a.Index + if a.Kind == editor.Add { + anchor = len(old) - 1 + } + if a.Kind != editor.AddInSection { + heading = len(newHeadings) - 1 + if anchor >= 0 { + parent, depth = old[anchor].ParentIndex, old[anchor].Depth + heading = section(oldHeadings, old[anchor].LineNo) + } + } + actualParent := -1 + if current[pos].ParentIndex >= 0 { + actualParent = ids[current[pos].ParentIndex] + } + if current[pos].Depth != depth || actualParent != parent || section(newHeadings, current[pos].LineNo) != heading { + return fmt.Errorf("inserted task has incorrect depth, parent or section") + } + if a.Kind == editor.Add && pos != len(current)-1 { + return fmt.Errorf("add did not append after the last task") + } + if a.Kind == editor.Insert { + end := a.Index + 1 + for end < len(old) && old[end].Depth > old[a.Index].Depth { + end++ + } + if pos != end { + return fmt.Errorf("insert did not follow the selected subtree") + } + } + } + if a.Kind != editor.SortDone && a.Kind != editor.SortDue && a.Kind != editor.SortPriority { + var wantOrder, actualOrder []int + for i := range old { + if !removed[i] { + wantOrder = append(wantOrder, i) + } + } + for _, id := range ids { + if id >= 0 { + actualOrder = append(actualOrder, id) + } + } + if moved >= 0 && a.Kind != editor.Indent { + end := moved + 1 + for end < len(old) && old[end].Depth > old[moved].Depth { + end++ + } + at := a.Target + if a.Kind == editor.Outdent { + at = old[moved].ParentIndex + 1 + for at < len(old) && old[at].Depth > old[old[moved].ParentIndex].Depth { + at++ + } + } else if (a.Kind == editor.Move && moved < a.Target) || (a.Kind == editor.MoveToPosition && a.InsertAfter) { + at++ + for at < len(old) && old[at].Depth > old[a.Target].Depth { + at++ + } + } + group := slices.Clone(wantOrder[moved:end]) + wantOrder = slices.Delete(wantOrder, moved, end) + insert := 0 + for insert < len(wantOrder) && wantOrder[insert] < at { + insert++ + } + wantOrder = slices.Insert(wantOrder, insert, group...) + } + if !slices.Equal(wantOrder, actualOrder) { + return fmt.Errorf("action produced incorrect subtree order") + } + } + for id, task := range old { + if removed[id] { + continue + } + pos, exists := positions[id] + if !exists { + return fmt.Errorf("task %d lost its identity", id) + } + actual := current[pos] + parent := -1 + if actual.ParentIndex >= 0 { + parent = ids[actual.ParentIndex] + } + if actual.Depth != depths[id] || parent != parents[id] { + return fmt.Errorf("task %d changed depth or parent ownership", id) + } + wantSection := section(oldHeadings, task.LineNo) + if moved >= 0 && (a.Kind == editor.Move || a.Kind == editor.MoveToPosition) { + for ancestor := id; ancestor >= 0; ancestor = old[ancestor].ParentIndex { + if ancestor == moved { + wantSection = section(oldHeadings, old[a.Target].LineNo) + break + } + } + } + if section(newHeadings, actual.LineNo) != wantSection { + return fmt.Errorf("task %d changed section ownership", id) + } + if taskBody(before, id) != taskBody(after, pos) { + return fmt.Errorf("task %d changed body ownership or content", id) + } + } + return nil +} + +// First paragraphs are editable task titles; direct nested lists have their own +// owners. Other body blocks belong to this task. Ignore only relocation indent. +func taskBody(doc *markdown.ASTDocument, index int) string { + node, err := doc.FindTodoNode(index) + if err != nil { + return "" + } + var body strings.Builder + for child := node.ListItem.FirstChild(); child != nil; child = child.NextSibling() { + if child == node.CheckBox.Parent() || child.Kind() == ast.KindList { + continue + } + _ = ast.Walk(child, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering && n.Type() != ast.TypeInline { + fmt.Fprintln(&body, n.Kind()) + if code, ok := n.(*ast.FencedCodeBlock); ok && code.Info != nil { + body.Write(code.Info.Segment.Value(doc.Source)) + } + for i := 0; i < n.Lines().Len(); i++ { + line := n.Lines().At(i) + body.WriteString(strings.TrimLeft(string(line.Value(doc.Source)), " \t")) + } + if html, ok := n.(*ast.HTMLBlock); ok && html.HasClosure() { + body.WriteString(strings.TrimLeft(string(html.ClosureLine.Value(doc.Source)), " \t")) + } + } + return ast.WalkContinue, nil + }) + } + return body.String() +} diff --git a/internal/usage/structural_test.go b/internal/usage/structural_test.go new file mode 100644 index 0000000..9f548f6 --- /dev/null +++ b/internal/usage/structural_test.go @@ -0,0 +1,165 @@ +package usage + +import ( + "encoding/json" + "fmt" + "os" + "reflect" + "slices" + "strconv" + "strings" + "testing" + + "github.com/niklas-heer/tdx/internal/editor" + "github.com/niklas-heer/tdx/internal/markdown" +) + +func TestStructuralCampaign(t *testing.T) { + for _, seed := range []uint64{100, 101, 102, 103, 4096} { + t.Run(strconv.FormatUint(seed, 10), func(t *testing.T) { + trace := Generate(seed, 180, "structural") + encoded, _ := json.Marshal(trace) + var decoded Trace + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(trace, decoded) || !reflect.DeepEqual(trace, Generate(seed, 180, "structural")) { + t.Fatal("non-deterministic structural trace") + } + result, err := Replay(decoded, "") + if err != nil { + minimal, note := MinimizeFailure(trace, "", err, 32) + data, _ := json.Marshal(minimal) + t.Fatalf("%v\n%s\n%s", err, note, data) + } + if result.Actions != 180 || result.Counts["undo"] == 0 || result.Counts["reopen"] == 0 { + t.Fatalf("incomplete campaign: %+v", result) + } + }) + } +} + +func TestStructuralOracleRejectsOwnershipCorruption(t *testing.T) { + source := "# Work\n\n<!-- protected -->\n\n- [ ] Parent\n\n ```text\n Parent body.\n ```\n\n - [ ] Child\n- [ ] Sibling\n\n ```text\n Sibling body.\n ```\n\n# Later\n\n- [ ] Last\n" + for name, corrupted := range map[string]string{ + "lost depth": strings.Replace(source, " - [ ] Child", "- [ ] Child", 1), + "changed parent": strings.Replace(strings.Replace(source, " - [ ] Child\n", "", 1), " ```text\n Sibling body.\n ```\n", " ```text\n Sibling body.\n ```\n\n - [ ] Child\n", 1), + "swapped bodies": strings.NewReplacer("Parent body.", "Sibling body.", "Sibling body.", "Parent body.").Replace(source), + "body deleted": strings.Replace(source, " ```text\n Parent body.\n ```\n", "", 1), + "protected region": strings.Replace(strings.Replace(source, "<!-- protected -->\n", "", 1), "# Later\n", "# Later\n\n<!-- protected -->\n", 1), + "task section": strings.Replace(strings.Replace(source, "- [ ] Last\n", "", 1), "# Later\n", "- [ ] Last\n\n# Later\n", 1), + } { + t.Run(name, func(t *testing.T) { + action := editor.Action{Kind: editor.SetChecked, Index: 0, Checked: false} + // These corruptions all pass the old multiset oracle. + if err := checkTaskConservation(markdown.ParseMarkdown(source).Todos, markdown.ParseMarkdown(corrupted).Todos, action); err != nil { + t.Fatalf("fixture must retain all task titles and completion states: %v", err) + } + if err := checkStructuralOwnership(source, corrupted, action, []string{"<!-- protected -->\n"}); err == nil { + t.Fatal("ownership corruption escaped the structural oracle") + } + }) + } +} + +func TestStructuralOracleAcceptsAuthorizedOwnershipChanges(t *testing.T) { + source := "# Work\n\n- [x] Parent\n\n ~~~text\n Owned code.\n ~~~\n\n - [ ] Child\n - [x] Other child\n- [ ] Sibling\n\n# Later\n\n- [ ] Last\n" + for _, action := range []editor.Action{ + {Kind: editor.Move, Index: 0, Target: 4}, + {Kind: editor.MoveToPosition, Index: 0, Target: 4, InsertAfter: true}, + {Kind: editor.Move, Index: 0, Target: 0}, + {Kind: editor.Indent, Index: 3}, + {Kind: editor.Outdent, Index: 1}, + {Kind: editor.Delete, Index: 0}, + {Kind: editor.ClearDone}, + {Kind: editor.SortDone}, + {Kind: editor.Edit, Index: 3, Text: "Last"}, // Duplicate title remains a valid edit. + {Kind: editor.CreateHeading, Index: 0, Level: 1, Text: "Work"}, + } { + t.Run(fmt.Sprintf("%s-%d-%d", action.Kind, action.Index, action.Target), func(t *testing.T) { + doc := markdown.ParseMarkdown(source) + if _, err := editor.Apply(doc, action); err != nil { + t.Fatal(err) + } + if err := checkStructuralOwnership(source, markdown.SerializeMarkdown(doc), action, nil); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestStructuralOracleRejectsImpossibleSuccessWithoutPanic(t *testing.T) { + for _, action := range []editor.Action{ + {Kind: editor.Edit, Index: -1}, + {Kind: editor.Move, Index: 0, Target: 1}, + {Kind: editor.Outdent, Index: 0}, + } { + if err := checkStructuralOwnership("- [ ] task\n", "- [ ] task\n", action, nil); err == nil { + t.Fatalf("accepted impossible success: %+v", action) + } + } +} + +func TestStructuralOracleRejectsIncorrectActionResults(t *testing.T) { + source := "# A\n\n- [ ] one\n- [ ] two\n\n# B\n" + for _, tc := range []struct { + action editor.Action + after string + }{ + {editor.Action{Kind: editor.Move, Index: 0, Target: 1}, source}, + {editor.Action{Kind: editor.Edit, Index: 0, Text: "changed"}, source}, + {editor.Action{Kind: editor.AddInSection, Index: 0, Text: "new"}, source + "\n- [ ] new\n"}, + {editor.Action{Kind: editor.Insert, Index: 0, Text: "new"}, strings.Replace(source, "- [ ] two\n", "- [ ] two\n- [ ] new\n", 1)}, + } { + if err := checkTaskConservation(markdown.ParseMarkdown(source).Todos, markdown.ParseMarkdown(tc.after).Todos, tc.action); err != nil { + t.Fatalf("fixture must pass the former multiset oracle: %v", err) + } + if err := checkStructuralOwnership(source, tc.after, tc.action, nil); err == nil { + t.Fatalf("accepted incorrect action result: %+v", tc.action) + } + } +} + +func TestMinimizedStructuralTraceReproducesFailure(t *testing.T) { + trace := Trace{Schema: Schema, Seed: 42, Driver: "structural", Source: "- [ ] original\n", Protected: []string{"original"}, Steps: []Step{ + {Op: "reopen", Seconds: 10}, + {Op: "action", Seconds: 10, Action: &editor.Action{Kind: editor.Edit, Text: "changed"}}, + {Op: "undo", Seconds: 10}, + }} + _, err := Replay(trace, "") + if err == nil { + t.Fatal("expected protected-source violation") + } + minimal, _ := MinimizeFailure(trace, "", err, 32) + if len(minimal.Steps) != 1 { + t.Fatalf("expected one-step reproducer, got %d", len(minimal.Steps)) + } + _, replayErr := Replay(minimal, "") + if failureClass(replayErr) != failureClass(err) { + t.Fatalf("reduced failure differs: %v", replayErr) + } +} + +func TestHeadingCacheRegressionTrace(t *testing.T) { + data, err := os.ReadFile("../../experiments/usage/heading-cache-regression.json") + if err != nil { + t.Fatal(err) + } + var trace Trace + if err = json.Unmarshal(data, &trace); err != nil { + t.Fatal(err) + } + if _, err = Replay(trace, ""); err != nil { + t.Fatal(err) + } +} + +func TestTraceChunkReduction(t *testing.T) { + steps := []Step{{Op: "noise"}, {Op: "trigger-a"}, {Op: "noise"}, {Op: "trigger-b"}, {Op: "noise"}} + got := reduceSteps(steps, func(candidate []Step) bool { + return slices.ContainsFunc(candidate, func(s Step) bool { return s.Op == "trigger-a" }) && slices.ContainsFunc(candidate, func(s Step) bool { return s.Op == "trigger-b" }) + }) + if len(got) != 2 || got[0].Op != "trigger-a" || got[1].Op != "trigger-b" { + t.Fatalf("incorrect reduction: %+v", got) + } +} diff --git a/internal/usage/trace.go b/internal/usage/trace.go index db58689..17bee08 100644 --- a/internal/usage/trace.go +++ b/internal/usage/trace.go @@ -5,6 +5,8 @@ import ( "fmt" "math/rand/v2" "slices" + + "github.com/niklas-heer/tdx/internal/editor" ) const Schema = 1 @@ -14,18 +16,21 @@ type Task struct { Checked bool `json:"checked"` } type Step struct { - Op string `json:"op"` - Index int `json:"index"` - Text string `json:"text,omitempty"` - Seconds int `json:"seconds"` - Expected []Task `json:"expected"` + Action *editor.Action `json:"action,omitempty"` + Op string `json:"op"` + Index int `json:"index"` + Text string `json:"text,omitempty"` + Seconds int `json:"seconds"` + Expected []Task `json:"expected"` } type Trace struct { - Schema int `json:"schema"` - Seed uint64 `json:"seed"` - Driver string `json:"driver"` - Initial []Task `json:"initial"` - Steps []Step `json:"steps"` + Source string `json:"source,omitempty"` + Protected []string `json:"protected,omitempty"` + Schema int `json:"schema"` + Seed uint64 `json:"seed"` + Driver string `json:"driver"` + Initial []Task `json:"initial"` + Steps []Step `json:"steps"` } // Oracle deliberately uses only flat value slices, never the editor or Markdown parser. @@ -79,6 +84,9 @@ func (o *Oracle) Apply(s Step) error { } func Generate(seed uint64, count int, driver string) Trace { + if driver == "structural" { + return GenerateStructural(seed, count) + } r := rand.New(rand.NewPCG(seed, seed^0x746478)) trace := Trace{Schema: Schema, Seed: seed, Driver: driver} for i := 0; i < 24; i++ { diff --git a/internal/util/clipboard.go b/internal/util/clipboard.go index 957d142..ce4675d 100644 --- a/internal/util/clipboard.go +++ b/internal/util/clipboard.go @@ -1,30 +1,126 @@ package util import ( + "context" + "fmt" + "os" "os/exec" + "runtime" "strings" + "time" ) -// CopyToClipboard copies text to the system clipboard -func CopyToClipboard(text string) { - // Use pbcopy on macOS - cmd := exec.Command("pbcopy") - cmd.Stdin = strings.NewReader(text) - _ = cmd.Run() // Ignore error - clipboard may not be available +// Clipboard is injected into each TUI session so failures can be reported truthfully. +type Clipboard interface { + Copy(string) error + Paste() (string, error) } -// PasteFromClipboard retrieves text from the system clipboard -func PasteFromClipboard() string { - // Use pbpaste on macOS - cmd := exec.Command("pbpaste") - out, err := cmd.Output() +type SystemClipboard struct { + Platform string + LookPath func(string) (string, error) + Run func(context.Context, string, []string, string) (string, error) + Getenv func(string) string + Timeout time.Duration +} + +func (c SystemClipboard) command(copying bool) (string, []string, error) { + platform := c.Platform + if platform == "" { + platform = runtime.GOOS + } + lookup := c.LookPath + if lookup == nil { + lookup = exec.LookPath + } + env := c.Getenv + if env == nil { + env = os.Getenv + } + type command struct { + name string + args []string + } + var candidates []command + switch platform { + case "darwin": + name := "pbpaste" + if copying { + name = "pbcopy" + } + candidates = []command{{name, nil}} + case "windows": + script := "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Clipboard -Raw" + if copying { + script = "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())" + } + candidates = []command{{"powershell.exe", []string{"-NoProfile", "-NonInteractive", "-Command", script}}} + case "linux", "freebsd", "openbsd": + if env("WAYLAND_DISPLAY") != "" { + if copying { + candidates = append(candidates, command{"wl-copy", nil}) + } else { + candidates = append(candidates, command{"wl-paste", []string{"--no-newline"}}) + } + } + if env("DISPLAY") != "" { + args := []string{"-selection", "clipboard"} + if !copying { + args = append(args, "-o") + } + candidates = append(candidates, command{"xclip", args}) + flag := "--input" + if !copying { + flag = "--output" + } + candidates = append(candidates, command{"xsel", []string{"--clipboard", flag}}) + } + } + for _, candidate := range candidates { + if path, err := lookup(candidate.name); err == nil { + return path, candidate.args, nil + } + } + return "", nil, fmt.Errorf("clipboard unavailable: use terminal paste, or install a clipboard helper (wl-clipboard/xclip on Linux; PowerShell on Windows)") +} + +func (c SystemClipboard) execute(copying bool, input string) (string, error) { + name, args, err := c.command(copying) if err != nil { - return "" + return "", err + } + timeout := c.Timeout + if timeout <= 0 { + timeout = 2 * time.Second } - // Remove trailing newline and return first line only - text := strings.TrimRight(string(out), "\n\r") - if idx := strings.Index(text, "\n"); idx != -1 { - text = text[:idx] + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + run := c.Run + if run == nil { + run = func(ctx context.Context, name string, args []string, input string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdin = strings.NewReader(input) + cmd.WaitDelay = 100 * time.Millisecond + output, err := cmd.Output() + return string(output), err + } } + output, err := run(ctx, name, args, input) + if ctx.Err() != nil { + return "", fmt.Errorf("clipboard timed out: check your desktop clipboard service") + } + if err != nil { + return "", fmt.Errorf("clipboard operation failed (%s): %w", name, err) + } + return output, nil +} +func (c SystemClipboard) Copy(text string) error { _, err := c.execute(true, text); return err } +func (c SystemClipboard) Paste() (string, error) { return c.execute(false, "") } + +// Legacy wrappers retain source compatibility for existing callers. +func CopyToClipboard(text string) { _ = (SystemClipboard{}).Copy(text) } +func PasteFromClipboard() string { + text, _ := (SystemClipboard{}).Paste() + text, _, _ = strings.Cut(strings.TrimRight(text, "\r\n"), "\n") return text } diff --git a/internal/util/clipboard_test.go b/internal/util/clipboard_test.go new file mode 100644 index 0000000..ebcb92e --- /dev/null +++ b/internal/util/clipboard_test.go @@ -0,0 +1,74 @@ +package util + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" +) + +func TestClipboardPlatforms(t *testing.T) { + for _, tt := range []struct{ platform, display, copy, paste string }{{"darwin", "", "pbcopy", "pbpaste"}, {"linux", "WAYLAND_DISPLAY", "wl-copy", "wl-paste"}, {"linux", "DISPLAY", "xclip", "xclip"}, {"windows", "", "powershell.exe", "powershell.exe"}} { + t.Run(tt.platform+tt.display, func(t *testing.T) { + var calls []string + var inputs []string + c := SystemClipboard{Platform: tt.platform, Getenv: func(key string) string { + if key == tt.display { + return "yes" + } + return "" + }, LookPath: func(s string) (string, error) { return s, nil }, Run: func(_ context.Context, name string, args []string, input string) (string, error) { + calls = append(calls, name) + inputs = append(inputs, input) + return "café\n", nil + }} + if err := c.Copy("世界"); err != nil { + t.Fatal(err) + } + text, err := c.Paste() + if err != nil || text != "café\n" { + t.Fatal(text, err) + } + if !reflect.DeepEqual(calls, []string{tt.copy, tt.paste}) || inputs[0] != "世界" { + t.Fatalf("wrong command or input: %v %v", calls, inputs) + } + }) + } +} +func TestClipboardUnavailableFailureAndTimeout(t *testing.T) { + c := SystemClipboard{Platform: "linux", Getenv: func(string) string { return "" }} + if err := c.Copy("test"); err == nil || !strings.Contains(err.Error(), "unavailable") { + t.Fatal(err) + } + c = SystemClipboard{Platform: "darwin", LookPath: func(s string) (string, error) { return s, nil }, Run: func(context.Context, string, []string, string) (string, error) { return "", errors.New("failed") }} + if _, err := c.Paste(); err == nil { + t.Fatal("paste failure hidden") + } + c.Timeout = time.Millisecond + c.Run = func(ctx context.Context, _ string, _ []string, _ string) (string, error) { + <-ctx.Done() + return "", ctx.Err() + } + if err := c.Copy("test"); err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatal(err) + } +} +func TestClipboardXselFallback(t *testing.T) { + c := SystemClipboard{Platform: "linux", Getenv: func(s string) string { + if s == "DISPLAY" { + return ":0" + } + return "" + }, LookPath: func(s string) (string, error) { + if s == "xsel" { + return s, nil + } + return "", errors.New("missing") + }} + name, args, err := c.command(false) + if err != nil || name != "xsel" || !reflect.DeepEqual(args, []string{"--clipboard", "--output"}) { + t.Fatal(name, args, err) + } +} diff --git a/mise.toml b/mise.toml index bbac099..1a18b28 100644 --- a/mise.toml +++ b/mise.toml @@ -69,7 +69,7 @@ run = "go test ./pipeline" [tasks.check] description = "Run formatting, vet, lint, tests, and workflow validation" -run = [{ task = "fmt:check" }, { task = "vet" }, { task = "lint" }, { task = "test" }, { task = "test:pipeline" }, { task = "test:release-notes" }, { task = "ci-workflows" }] +run = [{ task = "fmt:check" }, { task = "vet" }, { task = "lint" }, { task = "test" }, { task = "test:pipeline" }, { task = "test:release-notes" }, { task = "test:installer" }, { task = "ci-workflows" }] [tasks.ci] description = "Run full portable CI in Dagger (requires Docker)" @@ -204,3 +204,28 @@ go test ./internal/markdown -run '^$' -bench BenchmarkLoadedCheckbox -benchmem - description = "Verify reviewed release notes offline" tools.python = "3.14.7" run = "bash scripts/test_release_notes.sh" + +[tasks."test:installer"] +description = "Verify installs and checksum/download failures against offline fixtures" +tools.python = "3.14.7" +run = "python3 scripts/test_install.py" + +[tasks."test:terminal-windows"] +description = "Run native Windows ConPTY smoke (requires pywinpty==3.0.2)" +run = "python scripts/usage-conpty.py --binary ./tdx.exe" + +[tasks."test:usage-structural"] +description = "Replay source-preserving structural edits and undo with minimized failure traces" +run = 'go run ./cmd/tdx-usage -driver structural -sessions 2 -steps 100 -output dist/usage/structural "$@"' + +[tasks."test:terminal-visual"] +description = "Verify inline terminal screens and scrollback in an isolated pyte test environment (macOS/Linux)" +depends = ["build"] +tools.python = "3.14.7" +run = ''' +visual_test_env=$(mktemp -d) +trap 'rm -rf "$visual_test_env"' EXIT +python3 -m venv "$visual_test_env" +"$visual_test_env/bin/python" -m pip install pyte==0.8.2 wcwidth==0.8.3 +"$visual_test_env/bin/python" scripts/visual-pty.py "$@" +''' diff --git a/openspec/changes/archive/2026-09-12-fix-inline-rendering/proposal.md b/openspec/changes/archive/2026-09-12-fix-inline-rendering/proposal.md new file mode 100644 index 0000000..974fb14 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-fix-inline-rendering/proposal.md @@ -0,0 +1,11 @@ +## Why +Oversized inline frames leave old headings in terminal scrollback when task wrapping and section transitions change frame height, as reported in the user screenshot. + +## What Changes +- Budget by rendered rows, preserve the active task/cursor, and constrain every terminal frame. +- Add layout regressions and verify section transitions and resizing in a real PTY. + +## Impact +- Affected spec: tdx-cli. +- Affected code: internal/tui rendering and tests. +- Approved scope: the user explicitly requested fixing these visual defects and testing them. diff --git a/openspec/changes/archive/2026-09-12-fix-inline-rendering/specs/tdx-cli/spec.md b/openspec/changes/archive/2026-09-12-fix-inline-rendering/specs/tdx-cli/spec.md new file mode 100644 index 0000000..6028ff0 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-fix-inline-rendering/specs/tdx-cli/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements +### Requirement: Bounded inline terminal frames +The interactive TUI SHALL keep each rendered frame within the measured terminal width and height, reserving a row for the inline cursor and keeping frame height stable across modes at the current terminal size. Actual terminal resizes SHALL reset the inline redraw origin. Headings, wrapped task content, section banners and status rows SHALL count toward the height budget. The selected task or text cursor SHALL remain in the visible task viewport. Before terminal dimensions arrive, the interactive application SHALL defer document rendering. + +#### Scenario: Focus after an overflowing document +- **WHEN** a user navigates a document with many headings and wrapped tasks and focuses a section +- **THEN** prior frames SHALL NOT leave repeated headings in terminal scrollback +- **AND** the focused section and selected task SHALL remain visible + +#### Scenario: Resize with an active editor or overlay +- **WHEN** the terminal becomes narrower or shorter during editing or an overlay +- **THEN** the frame SHALL fit the new dimensions without terminal soft wrapping or excess rows diff --git a/openspec/changes/archive/2026-09-12-fix-inline-rendering/tasks.md b/openspec/changes/archive/2026-09-12-fix-inline-rendering/tasks.md new file mode 100644 index 0000000..4607547 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-fix-inline-rendering/tasks.md @@ -0,0 +1,4 @@ +## Implementation +- [x] 1. Reproduce overflowing frames and add regression coverage. +- [x] 2. Bound inline frames and keep the active task/editor visible. +- [x] 3. Run Go and real terminal checks, rebuild the binary, archive and push. diff --git a/openspec/changes/archive/2026-09-12-fix-review-edge-cases/proposal.md b/openspec/changes/archive/2026-09-12-fix-review-edge-cases/proposal.md new file mode 100644 index 0000000..c14e750 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-fix-review-edge-cases/proposal.md @@ -0,0 +1,11 @@ +## Why +Final PR review identified edge cases in saved section references, recent-file cursor restoration and structural replay coverage, plus ambiguous spec examples. + +## What Changes +- Correct verified state-restoration and nesting edge cases with regression tests. +- Strengthen structural ownership checks and clarify editable task text semantics. +- Clarify opt-in view restoration and CLI read-only scenarios; initialize centralized view defaults explicitly. + +## Impact +- Affected specs: saved-views, usage-replay and editor-core; existing editor contracts remain authoritative. +- Scope authorized by the user request to finish, test and shepherd PR #24 to completion. diff --git a/openspec/changes/archive/2026-09-12-fix-review-edge-cases/specs/editor-core/spec.md b/openspec/changes/archive/2026-09-12-fix-review-edge-cases/specs/editor-core/spec.md new file mode 100644 index 0000000..ea51dca --- /dev/null +++ b/openspec/changes/archive/2026-09-12-fix-review-edge-cases/specs/editor-core/spec.md @@ -0,0 +1,18 @@ +## MODIFIED Requirements + +### Requirement: Safe structural Markdown editing +Task and heading edits SHALL retain unrelated Markdown content, including reference definitions, HTML, tables, multiline bodies and line-ending boundaries. Unsupported operations SHALL fail without changing document content. + +#### Scenario: A task is renamed near a reference link +- **WHEN** a task is renamed near a reference link +- **THEN** the reference definition and unrelated content remain intact through saving and undo + +#### Scenario: Replace a task paragraph with a continuation +- **WHEN** task text is replaced and its checkbox paragraph contains continuation lines +- **THEN** those continuation lines SHALL be replaced with the edited text +- **AND** separate body blocks and unrelated reference definitions SHALL remain intact + +#### Scenario: Reorder a nested task without filters +- **WHEN** an unfiltered move would cross the boundary of the selected task's parent +- **THEN** the task and its descendants SHALL retain their position and nesting +- **AND** moving among siblings SHALL move the complete subtree without changing its depth diff --git a/openspec/changes/archive/2026-09-12-fix-review-edge-cases/specs/saved-views/spec.md b/openspec/changes/archive/2026-09-12-fix-review-edge-cases/specs/saved-views/spec.md new file mode 100644 index 0000000..015f345 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-fix-review-edge-cases/specs/saved-views/spec.md @@ -0,0 +1,31 @@ +## MODIFIED Requirements +### Requirement: Named per-file views +The TUI SHALL save, load, list and delete named views per canonical file. Views SHALL capture section focus/folds and tag, priority, due-date and completion filters, with explicit active-view feedback. Restoration on opening SHALL be opt-in. + +#### Scenario: A user saves and reopens a project view +- **WHEN** a user saves a project view and either explicitly loads it or reopens the project with restoration enabled +- **THEN** the same filters and valid section context are applied without changing Markdown + +#### Scenario: Default opening leaves saved views inactive +- **WHEN** a project with a saved view is opened without opting into restoration or explicitly loading the view +- **THEN** the saved view SHALL NOT be restored automatically + +#### Scenario: Blank headings are valid saved context +- **WHEN** an empty heading is focused, folded or an ancestor of saved section context +- **THEN** its empty label and occurrence SHALL round-trip without rejecting the view + +#### Scenario: Restore a recent file cursor +- **WHEN** a user switches to a recent file with a remembered task position +- **THEN** cursor visibility SHALL be evaluated after the destination file's metadata and optional saved view are applied +- **AND** a hidden remembered position SHALL fall back to a visible task + +### Requirement: Compatible manual-save mode +The TUI SHALL describe edits that require explicit saving as manual-save mode and retain existing read-only flags/configuration as compatible aliases. CLI read-only mutation rejection SHALL remain intact. + +#### Scenario: An existing read-only checklist is opened +- **WHEN** an existing read-only checklist is opened +- **THEN** temporary edits remain possible and only an explicit save writes them + +#### Scenario: A CLI mutation targets a read-only checklist +- **WHEN** a CLI mutation targets a checklist with read-only mode enabled +- **THEN** the mutation SHALL fail without modifying its Markdown diff --git a/openspec/changes/archive/2026-09-12-fix-review-edge-cases/specs/usage-replay/spec.md b/openspec/changes/archive/2026-09-12-fix-review-edge-cases/specs/usage-replay/spec.md new file mode 100644 index 0000000..6daee7b --- /dev/null +++ b/openspec/changes/archive/2026-09-12-fix-review-edge-cases/specs/usage-replay/spec.md @@ -0,0 +1,11 @@ +## MODIFIED Requirements +### Requirement: Structural regression campaigns +Regression checks SHALL cover nested/ordered tasks, sections, multiline bodies, unrelated Markdown preservation and exact undo. Failing generated campaigns SHALL retain replayable reduced cases or clearly report why reduction is unavailable. Scheduled campaigns SHALL record rotating seeds and retain failure artifacts. + +#### Scenario: A generated edit sequence loses unrelated content +- **WHEN** a generated edit sequence loses unrelated content +- **THEN** the campaign fails and retains a reproducible regression artifact + +#### Scenario: Structural content changes ownership +- **WHEN** an edit unexpectedly relocates a protected block, detaches a descendant, or attaches a task body to the wrong task +- **THEN** the structural campaign SHALL reject the resulting state even if content occurrence counts remain unchanged diff --git a/openspec/changes/archive/2026-09-12-fix-review-edge-cases/tasks.md b/openspec/changes/archive/2026-09-12-fix-review-edge-cases/tasks.md new file mode 100644 index 0000000..39d9c22 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-fix-review-edge-cases/tasks.md @@ -0,0 +1,6 @@ +## Review follow-up +- [x] 1. Verify each review finding and fix actual behavior defects with regressions. +- [x] 2. Strengthen structural ownership validation and clarify view spec examples. +- [x] 3. Run relevant tests and full local checks; prepare the verified follow-up for native CI validation. + +Validation: `mise run check`, 1,317 race-detected tests, both PTY suites, 1,000 TUI actions and 4,000 structural actions passed. Native CI and merge status are tracked on PR #24. diff --git a/openspec/changes/archive/2026-09-12-improve-workflow-reliability/design.md b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/design.md new file mode 100644 index 0000000..18ba1d5 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/design.md @@ -0,0 +1,22 @@ +## Context +v0.14.0 supplies a shared editor, guarded saves, source-preserving checkbox updates, a JSON CLI, bounded undo and replay/PTY contracts. Build on those boundaries without a language rewrite. + +## Decisions +- Keep unchanged source bytes authoritative; source patches are preferred for localized edits. Reject unsafe unsupported edits without mutating the document. +- Store named views per canonical file outside Markdown, combining section focus/folds and existing filters; restore only explicitly or through an opt-in setting. View commands must be discoverable and cancellable. +- Retain read-only aliases while explaining the TUI behavior as manual save. CLI read-only writes remain rejected. +- Keep existing list JSON arrays stable; opt-in revision output uses a versioned envelope. Revision tokens identify exact loaded bytes and distinguish missing files. Compare a supplied token with the loaded snapshot; existing conditional saves reject later external changes. +- Add done/undone operations and list filters without renumbering results. Shell completions are generated without accessing task files or history. +- Release validation runs on the exact tagged SHA, independent of the local release helper. Installers verify the selected binary against a published checksum manifest before replacing installations. +- Extend replay with structural invariants and reduced failure artifacts; keep ordinary checks bounded and longer rotating-seed campaigns scheduled. Native executable checks cover macOS/Windows, with actual terminal contracts where supported. + +## Usability validation +Recruit five actual users before calling the saved-view hypothesis validated. Each participant captures a task, creates/reopens a Today or project view, edits a nested task, clears filters, and recovers a mistake. Record success, elapsed time, assistance and confusion; compare with the current workflow. Automated smoke checks are separate evidence and never substitute for participants. + +## Risks and limits +Arbitrary Markdown has difficult source boundaries. Prefer an explicit unsupported-operation error over data loss. Native Windows execution requires CI; local cross-compilation alone cannot prove terminal behavior. Existing released binaries lack the new checksum manifest and require a documented compatible installation route. + +## Synthetic rehearsal data +At the user's request, experiments/usability/synthetic-five-person-study.json contains five invented profiles and thirty illustrative task attempts. It is explicitly synthetic, with zero real participants. Timings, assistance, success and observations are fabricated for rehearsing analysis; they are not product validation or measured performance. Keep this dataset separate from future consented study observations and actual automated test results. Both slower first-time tasks and faster repeated tasks are included to avoid treating all improvements as predetermined. + +For a real study, use the six task IDs in the dataset, record the same fields from actual observations, alternate condition order across participants where practical, and record assistance separately from unaided success. Avoid quoting synthetic observations as user feedback. Report participant counts, failed tasks and individual timings alongside medians. diff --git a/openspec/changes/archive/2026-09-12-improve-workflow-reliability/proposal.md b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/proposal.md new file mode 100644 index 0000000..0ca1353 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/proposal.md @@ -0,0 +1,15 @@ +## Why +The approved review identified remaining Markdown preservation risks, platform clipboard failures, repeated filtering effort, unsafe positional automation, and gaps between local checks and release validation. + +## What Changes +- Preserve unrelated document content during edits and reject unsupported unsafe structural changes. +- Add saved per-file views and clearer manual-save terminology with compatible aliases. +- Support platform clipboards with truthful feedback. +- Extend CLI queries, idempotent completion, revision-guarded writes, shell completion and compatibility documentation. +- Expand structural replay, failure reduction, native terminal checks and scheduled campaigns. +- Gate tagged releases on portable/native checks and verify checksummed artifacts during installation. + +## Impact +- Affected specs: editor-core, tdx-cli, saved-views, clipboard, usage-replay, ci-pipeline. +- Affected code: internal/markdown, internal/editor, internal/tui, internal/cmd, cmd/tdx, scripts, .github, .dagger. +- User approved implementation of all six review recommendations in the current conversation. Go and existing Markdown/configuration compatibility are retained. diff --git a/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/ci-pipeline/spec.md b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/ci-pipeline/spec.md new file mode 100644 index 0000000..5572a99 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/ci-pipeline/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Exact-commit release validation +Tagged release publication SHALL depend on successful portable and native validation of the exact tagged commit, even when the tag was created outside the release helper. + +#### Scenario: A tag points at a failing commit +- **WHEN** a tag points at a failing commit +- **THEN** publication is blocked + +### Requirement: Verified release installation +Releases SHALL include a SHA-256 checksum manifest and build provenance. The installer SHALL verify the selected artifact before replacing the destination, and checksum/download failures SHALL leave existing installations unchanged. + +#### Scenario: A downloaded artifact has an incorrect checksum +- **WHEN** a downloaded artifact has an incorrect checksum +- **THEN** installation fails and the previous executable remains intact diff --git a/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/clipboard/spec.md b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/clipboard/spec.md new file mode 100644 index 0000000..f1f2721 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/clipboard/spec.md @@ -0,0 +1,8 @@ +## ADDED Requirements + +### Requirement: Platform clipboard feedback +Clipboard operations SHALL support macOS, Linux and Windows through available platform or terminal mechanisms. Failed or unavailable copy/paste SHALL report an actionable error and SHALL NOT report successful copying. + +#### Scenario: The clipboard backend is unavailable +- **WHEN** the clipboard backend is unavailable +- **THEN** the document remains unchanged and an error is displayed diff --git a/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/editor-core/spec.md b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/editor-core/spec.md new file mode 100644 index 0000000..02fe929 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/editor-core/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Safe structural Markdown editing +Task and heading edits SHALL retain unrelated Markdown content, including reference definitions, HTML, tables, multiline bodies and line-ending boundaries. Unsupported operations SHALL fail without changing document content. + +#### Scenario: A task is renamed near a reference link +- **WHEN** a task is renamed near a reference link +- **THEN** the reference definition and unrelated content remain intact through saving and undo + +## MODIFIED Requirements +### Requirement: Source-preserving checkbox editing +Toggling a task in a freshly parsed document SHALL change only the AST-identified checkbox byte. Repeated toggles, snapshots, saves and undo SHALL retain surrounding Markdown, frontmatter and line endings while settings are unchanged. Checkbox changes SHALL update cached checked state without re-extracting task text and metadata. + +#### Scenario: Rich Markdown +- **WHEN** a checkbox is toggled in a file with HTML, multiline paragraphs, tables, fenced examples or unknown frontmatter +- **THEN** unrelated bytes remain identical through save and undo + +#### Scenario: Structural source patches +- **WHEN** a task is added, removed, moved or renamed +- **THEN** validated patches SHALL update only affected source ranges and reparse the candidate document before committing +- **AND** unsupported source boundaries SHALL produce an error without mutation +- **AND** subsequent checkbox edits SHALL use the reparsed source locations diff --git a/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/saved-views/spec.md b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/saved-views/spec.md new file mode 100644 index 0000000..fbecda8 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/saved-views/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Named per-file views +The TUI SHALL save, load, list and delete named views per canonical file. Views SHALL capture section focus/folds and tag, priority, due-date and completion filters, with explicit active-view feedback. Restoration on opening SHALL be opt-in. + +#### Scenario: A user saves and reopens a project view +- **WHEN** a user saves a project view and either explicitly loads it or reopens the project with restoration enabled +- **THEN** the same filters and valid section context are applied without changing Markdown + +#### Scenario: Default opening leaves saved views inactive +- **WHEN** a project with a saved view is opened without opting into restoration or explicitly loading the view +- **THEN** the saved view SHALL NOT be restored automatically + +### Requirement: Compatible manual-save mode +The TUI SHALL describe edits that require explicit saving as manual-save mode and retain existing read-only flags/configuration as compatible aliases. CLI read-only mutation rejection SHALL remain intact. + +#### Scenario: An existing read-only checklist is opened +- **WHEN** an existing read-only checklist is opened +- **THEN** temporary edits remain possible and only an explicit save writes them + +#### Scenario: A CLI mutation targets a read-only checklist +- **WHEN** a CLI mutation targets a checklist with read-only mode enabled +- **THEN** the mutation SHALL fail without modifying its Markdown diff --git a/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/tdx-cli/spec.md b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/tdx-cli/spec.md new file mode 100644 index 0000000..2b0ec75 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/tdx-cli/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: Extended automation contracts +The CLI SHALL support composable due-date, priority and section queries, explicit idempotent done/undone commands, and optional revision-bearing queries and guarded mutations. Existing JSON array output SHALL remain compatible and unfiltered indexes SHALL be preserved. + +#### Scenario: A script attempts a mutation using a stale revision +- **WHEN** a script attempts a mutation using a stale revision +- **THEN** the mutation fails without changing the document + +### Requirement: Shell completion and schema policy +The CLI SHALL generate documented shell completions without reading task files or opening history. The JSON compatibility policy SHALL distinguish additive changes from breaking schema changes. + +#### Scenario: A user requests shell completion +- **WHEN** a user requests shell completion +- **THEN** completion output is produced without task-file side effects + +## MODIFIED Requirements +### Requirement: Markdown todo storage in todo.md +The system SHALL use the configured file, defaulting to ./todo.md, as the task source of truth. Missing files SHALL be treated as empty documents without being created by listing or revision queries; a successful mutation SHALL create the file. Actual Goldmark task-list nodes, including supported unordered, ordered and nested containers, SHALL determine task indexes. Mutations SHALL preserve unrelated document bytes and use guarded atomic replacement. Unsupported operations SHALL fail without modifying the document. + +#### Scenario: Query a missing file +- **WHEN** a user lists tasks or requests a revision for a nonexistent file +- **THEN** no file SHALL be created and the result SHALL represent an empty/missing document + +#### Scenario: First successful edit +- **WHEN** a user adds a task to a nonexistent file +- **THEN** the file SHALL be created through the guarded save with the new task + +#### Scenario: Preserve non-task Markdown +- **WHEN** a supported edit changes a task near reference definitions, HTML or tables +- **THEN** unrelated source bytes SHALL remain intact + +### Requirement: AST-based Markdown parser and writer +The system SHALL use Goldmark to identify tasks and headings and validate source patches before committing edits. It SHALL preserve unrelated Markdown source and reject unsupported operations without mutation. The legacy serializer SHALL NOT silently rewrite unsupported source during normal editing. + +#### Scenario: Round-trip consistency with no changes +- **WHEN** a document is parsed and serialized without edits +- **THEN** source content SHALL remain identical + +#### Scenario: Correct parsing of examples +- **WHEN** a document includes fenced examples that resemble tasks +- **THEN** only actual task-list nodes SHALL be editable diff --git a/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/usage-replay/spec.md b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/usage-replay/spec.md new file mode 100644 index 0000000..a3ffeec --- /dev/null +++ b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/specs/usage-replay/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Structural regression campaigns +Regression checks SHALL cover nested/ordered tasks, sections, multiline bodies, unrelated Markdown preservation and exact undo. Failing generated campaigns SHALL retain replayable reduced cases or clearly report why reduction is unavailable. Scheduled campaigns SHALL record rotating seeds and retain failure artifacts. + +#### Scenario: A generated edit sequence loses unrelated content +- **WHEN** a generated edit sequence loses unrelated content +- **THEN** the campaign fails and retains a reproducible regression artifact + +### Requirement: Native executable coverage +CI SHALL exercise CLI contracts on supported native macOS and Windows runners and terminal contracts on macOS and Linux, with Windows terminal checks where supported by ConPTY. + +#### Scenario: Native verification runs +- **WHEN** native verification runs +- **THEN** argument handling, paths, Unicode, configuration isolation and terminal lifecycle are checked on their native platforms diff --git a/openspec/changes/archive/2026-09-12-improve-workflow-reliability/tasks.md b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/tasks.md new file mode 100644 index 0000000..658c8f8 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-improve-workflow-reliability/tasks.md @@ -0,0 +1,19 @@ +## 1. Document integrity +- [x] 1.1 Preserve reference definitions and unrelated rich Markdown across text/structural edits; reject unsafe cases. +- [x] 1.2 Expand structural/nested/undo property checks and replayable regression cases. +## 2. User workflows +- [x] 2.1 Implement saved per-file views and opt-in restoration with clear filter state. +- [x] 2.2 Implement cross-platform clipboard operations and accurate failure feedback. +- [x] 2.3 Clarify manual-save naming while retaining compatibility; add interaction tests. +## 3. Automation +- [x] 3.1 Add due/priority/section queries and idempotent done/undone commands. +- [x] 3.2 Add revision query and guarded mutations with regression tests. +- [x] 3.3 Add shell completions and document compatibility guarantees. +## 4. Developer and release guarantees +- [x] 4.1 Extend native CLI/terminal CI and scheduled rotating-seed replay with failure artifacts. +- [x] 4.2 Gate tagged publication on exact-commit validation. +- [x] 4.3 Publish/verify checksums and provenance; test installer failures. +## 5. Integration +- [x] 5.1 Update user documentation and prepare the five-participant usability protocol. +- [x] 5.2 Run formatting, vet, tests/race, replay and terminal checks plus available pipeline checks. +- [x] 5.3 Review, commit, archive the approved spec change and push the implementation branch. diff --git a/openspec/specs/ci-pipeline/spec.md b/openspec/specs/ci-pipeline/spec.md index d9b30b3..c670448 100644 --- a/openspec/specs/ci-pipeline/spec.md +++ b/openspec/specs/ci-pipeline/spec.md @@ -2,9 +2,7 @@ ## Purpose Define the portable Dagger CI pipeline, native platform verification, reproducible release artifacts, GitHub orchestration, and coverage-badge responsibilities. - ## Requirements - ### Requirement: Locally reproducible portable CI The project SHALL provide a version-pinned Dagger pipeline written in Go that runs the same portable formatting, vet, lint, race-test, coverage, workflow-lint, and build logic locally and in hosted CI. @@ -98,3 +96,17 @@ The pipeline SHALL validate the version configured in tdx.toml using synchronize #### Scenario: Build a release candidate - **WHEN** release artifacts are built - **THEN** all target binaries SHALL embed the version from tdx.toml + +### Requirement: Exact-commit release validation +Tagged release publication SHALL depend on successful portable and native validation of the exact tagged commit, even when the tag was created outside the release helper. + +#### Scenario: A tag points at a failing commit +- **WHEN** a tag points at a failing commit +- **THEN** publication is blocked + +### Requirement: Verified release installation +Releases SHALL include a SHA-256 checksum manifest and build provenance. The installer SHALL verify the selected artifact before replacing the destination, and checksum/download failures SHALL leave existing installations unchanged. + +#### Scenario: A downloaded artifact has an incorrect checksum +- **WHEN** a downloaded artifact has an incorrect checksum +- **THEN** installation fails and the previous executable remains intact diff --git a/openspec/specs/clipboard/spec.md b/openspec/specs/clipboard/spec.md new file mode 100644 index 0000000..aca6ca2 --- /dev/null +++ b/openspec/specs/clipboard/spec.md @@ -0,0 +1,11 @@ +# clipboard Specification + +## Purpose +Provide asynchronous system clipboard operations with truthful feedback and safe cancellation in the terminal UI. +## Requirements +### Requirement: Platform clipboard feedback +Clipboard operations SHALL support macOS, Linux and Windows through available platform or terminal mechanisms. Failed or unavailable copy/paste SHALL report an actionable error and SHALL NOT report successful copying. + +#### Scenario: The clipboard backend is unavailable +- **WHEN** the clipboard backend is unavailable +- **THEN** the document remains unchanged and an error is displayed diff --git a/openspec/specs/editor-core/spec.md b/openspec/specs/editor-core/spec.md index 79fb929..3bca639 100644 --- a/openspec/specs/editor-core/spec.md +++ b/openspec/specs/editor-core/spec.md @@ -2,9 +2,7 @@ ## Purpose Keep document actions, undo, and application dependencies independent of terminal input while preserving consistent editing behavior. - ## Requirements - ### Requirement: Shared document actions The CLI and TUI SHALL express task and heading mutations as actions in a UI-independent editor package, sharing index validation and document mutation behavior. Invalid actions SHALL return errors without changing document content. UI selection and filter navigation SHALL remain outside the editor package. #### Scenario: Same edit from two interfaces @@ -42,9 +40,11 @@ Toggling a task in a freshly parsed document SHALL change only the AST-identifie - **WHEN** a checkbox is toggled in a file with HTML, multiline paragraphs, tables, fenced examples or unknown frontmatter - **THEN** unrelated bytes remain identical through save and undo -#### Scenario: Structural edit fallback +#### Scenario: Structural source patches - **WHEN** a task is added, removed, moved or renamed -- **THEN** the AST serializer remains the structural editing path and subsequent checkbox edits reflect the current tree rather than stale source offsets +- **THEN** validated patches SHALL update only affected source ranges and reparse the candidate document before committing +- **AND** unsupported source boundaries SHALL produce an error without mutation +- **AND** subsequent checkbox edits SHALL use the reparsed source locations ### Requirement: Derived task metadata stays current Available tag and priority options SHALL reflect current task content after editing, undo and reload, removing active filters for metadata no longer present. @@ -59,3 +59,20 @@ Structural task edits SHALL retain HTML blocks, valid table syntax and ordinary #### Scenario: Add a task after rich content - **WHEN** a task is added to a document containing HTML, a table and a multiline paragraph - **THEN** those blocks remain present and the saved file can be reopened with all tasks intact + +### Requirement: Safe structural Markdown editing +Task and heading edits SHALL retain unrelated Markdown content, including reference definitions, HTML, tables, multiline bodies and line-ending boundaries. Unsupported operations SHALL fail without changing document content. + +#### Scenario: A task is renamed near a reference link +- **WHEN** a task is renamed near a reference link +- **THEN** the reference definition and unrelated content remain intact through saving and undo + +#### Scenario: Replace a task paragraph with a continuation +- **WHEN** task text is replaced and its checkbox paragraph contains continuation lines +- **THEN** those continuation lines SHALL be replaced with the edited text +- **AND** separate body blocks and unrelated reference definitions SHALL remain intact + +#### Scenario: Reorder a nested task without filters +- **WHEN** an unfiltered move would cross the boundary of the selected task's parent +- **THEN** the task and its descendants SHALL retain their position and nesting +- **AND** moving among siblings SHALL move the complete subtree without changing its depth diff --git a/openspec/specs/saved-views/spec.md b/openspec/specs/saved-views/spec.md new file mode 100644 index 0000000..0208853 --- /dev/null +++ b/openspec/specs/saved-views/spec.md @@ -0,0 +1,35 @@ +# saved-views Specification + +## Purpose +Let users save and restore named task views per file without changing their Markdown documents. +## Requirements +### Requirement: Named per-file views +The TUI SHALL save, load, list and delete named views per canonical file. Views SHALL capture section focus/folds and tag, priority, due-date and completion filters, with explicit active-view feedback. Restoration on opening SHALL be opt-in. + +#### Scenario: A user saves and reopens a project view +- **WHEN** a user saves a project view and either explicitly loads it or reopens the project with restoration enabled +- **THEN** the same filters and valid section context are applied without changing Markdown + +#### Scenario: Default opening leaves saved views inactive +- **WHEN** a project with a saved view is opened without opting into restoration or explicitly loading the view +- **THEN** the saved view SHALL NOT be restored automatically + +#### Scenario: Blank headings are valid saved context +- **WHEN** an empty heading is focused, folded or an ancestor of saved section context +- **THEN** its empty label and occurrence SHALL round-trip without rejecting the view + +#### Scenario: Restore a recent file cursor +- **WHEN** a user switches to a recent file with a remembered task position +- **THEN** cursor visibility SHALL be evaluated after the destination file's metadata and optional saved view are applied +- **AND** a hidden remembered position SHALL fall back to a visible task + +### Requirement: Compatible manual-save mode +The TUI SHALL describe edits that require explicit saving as manual-save mode and retain existing read-only flags/configuration as compatible aliases. CLI read-only mutation rejection SHALL remain intact. + +#### Scenario: An existing read-only checklist is opened +- **WHEN** an existing read-only checklist is opened +- **THEN** temporary edits remain possible and only an explicit save writes them + +#### Scenario: A CLI mutation targets a read-only checklist +- **WHEN** a CLI mutation targets a checklist with read-only mode enabled +- **THEN** the mutation SHALL fail without modifying its Markdown diff --git a/openspec/specs/tdx-cli/spec.md b/openspec/specs/tdx-cli/spec.md index 3908b12..be67e17 100644 --- a/openspec/specs/tdx-cli/spec.md +++ b/openspec/specs/tdx-cli/spec.md @@ -2,9 +2,7 @@ ## Purpose Provide a standalone Go CLI and Bubble Tea TUI for managing Markdown tasks, with consistent storage, styling, and guarded atomic saves. - ## Requirements - ### Requirement: Go CLI entry point The system SHALL provide a Go command-line tool named tdx with entry point cmd/tdx/main.go, built with the toolchain pinned in mise.toml and metadata from tdx.toml. #### Scenario: Build and run tdx CLI @@ -15,57 +13,30 @@ The system SHALL provide a Go command-line tool named tdx with entry point cmd/t --- ### Requirement: Markdown todo storage in todo.md -The system SHALL store and manage todos in a markdown file named `todo.md` located in the current working directory. - -- The primary todo storage file SHALL be `./todo.md` relative to the process working directory. -- If `todo.md` does not exist when any todo-related command is invoked, the system SHALL create it automatically with a simple markdown header and an empty task list (e.g., `# Todos` followed by a blank line). -- A todo item SHALL be represented only by lines that start with one of the following exact prefixes: - - `- [ ] ` (unchecked) - - `- [x] ` (checked, using lowercase `x`) -- Todo text SHALL be defined as all content after the checkbox prefix on the same line. -- The system SHALL preserve: - - The content of unrelated Markdown nodes (including headers, paragraphs, and comments). - - The original ordering of all lines (todo and non-todo). -- On write, the system SHALL: - - Update the selected task or heading without changing other tasks or heading titles. - - Preserve unrelated Markdown content while permitting serializer whitespace normalization. - -- All modifications to `todo.md` SHALL be performed as safe atomic writes: - - Write to a temporary file in the same directory. - - Flush and close the temporary file. - - Replace the original file with the temporary file using an atomic rename, where supported by the platform. - -#### Scenario: Create missing todo.md -- **WHEN** the user runs any `tdx` command that reads or writes todos -- **AND** `todo.md` does not exist in the current working directory -- **THEN** the system SHALL create `todo.md` with a simple markdown header (e.g., `# Todos`) and no tasks -- **AND** the command SHALL continue using this new file without failing. - -#### Scenario: Preserve non-todo markdown content -- **WHEN** `todo.md` contains a mix of markdown headings, paragraphs, blank lines, comments, and todo lines -- **AND WHEN** the user toggles or edits one todo through any CLI or TUI operation -- **THEN** only the corresponding todo line in `todo.md` SHALL be modified -- **AND** all unrelated Markdown content SHALL remain in document order; serializer whitespace normalization is permitted. - -#### Scenario: Atomic write on modification -- **WHEN** any command or TUI interaction changes todo state or text -- **THEN** the system SHALL write the new file contents to a temporary file in the same directory as `todo.md` -- **AND** then replace `todo.md` with the temporary file in a single rename operation -- **AND** at no point SHALL a partially written `todo.md` be visible on disk. +The system SHALL use the configured file, defaulting to ./todo.md, as the task source of truth. Missing files SHALL be treated as empty documents without being created by listing or revision queries; a successful mutation SHALL create the file. Actual Goldmark task-list nodes, including supported unordered, ordered and nested containers, SHALL determine task indexes. Mutations SHALL preserve unrelated document bytes and use guarded atomic replacement. Unsupported operations SHALL fail without modifying the document. ---- +#### Scenario: Query a missing file +- **WHEN** a user lists tasks or requests a revision for a nonexistent file +- **THEN** no file SHALL be created and the result SHALL represent an empty/missing document + +#### Scenario: First successful edit +- **WHEN** a user adds a task to a nonexistent file +- **THEN** the file SHALL be created through the guarded save with the new task + +#### Scenario: Preserve non-task Markdown +- **WHEN** a supported edit changes a task near reference definitions, HTML or tables +- **THEN** unrelated source bytes SHALL remain intact ### Requirement: AST-based Markdown parser and writer -The system SHALL parse Markdown using Goldmark and perform task and heading edits through its AST. Serialization SHALL preserve unrelated content semantically; normalizing Markdown spacing is permitted. +The system SHALL use Goldmark to identify tasks and headings and validate source patches before committing edits. It SHALL preserve unrelated Markdown source and reject unsupported operations without mutation. The legacy serializer SHALL NOT silently rewrite unsupported source during normal editing. + #### Scenario: Round-trip consistency with no changes - **WHEN** a document is parsed and serialized without edits -- **THEN** its tasks, headings, and other Markdown content SHALL remain equivalent -#### Scenario: Correct parsing of todos and non-todos -- **WHEN** a document includes headings, ordinary lists, fenced code, and checkboxes -- **THEN** only actual task-list nodes SHALL be treated as todos -- **AND** unrelated Markdown constructs SHALL be retained +- **THEN** source content SHALL remain identical ---- +#### Scenario: Correct parsing of examples +- **WHEN** a document includes fenced examples that resemble tasks +- **THEN** only actual task-list nodes SHALL be editable ### Requirement: TUI layout and styling The system SHALL present an interactive terminal UI (TUI) using Bubble Tea that renders todos with a specific layout and styling. @@ -282,3 +253,29 @@ The CLI SHALL support `--file`/`-f`, retain positional Markdown paths, honor `-- #### Scenario: Read-only script tries to mutate a file - **WHEN** a developer runs a mutating command with read-only mode enabled - **THEN** the command SHALL fail on stderr without changing the task file or opening history storage + +### Requirement: Extended automation contracts +The CLI SHALL support composable due-date, priority and section queries, explicit idempotent done/undone commands, and optional revision-bearing queries and guarded mutations. Existing JSON array output SHALL remain compatible and unfiltered indexes SHALL be preserved. + +#### Scenario: A script attempts a mutation using a stale revision +- **WHEN** a script attempts a mutation using a stale revision +- **THEN** the mutation fails without changing the document + +### Requirement: Shell completion and schema policy +The CLI SHALL generate documented shell completions without reading task files or opening history. The JSON compatibility policy SHALL distinguish additive changes from breaking schema changes. + +#### Scenario: A user requests shell completion +- **WHEN** a user requests shell completion +- **THEN** completion output is produced without task-file side effects + +### Requirement: Bounded inline terminal frames +The interactive TUI SHALL keep each rendered frame within the measured terminal width and height, reserving a row for the inline cursor and keeping frame height stable across modes at the current terminal size. Actual terminal resizes SHALL reset the inline redraw origin. Headings, wrapped task content, section banners and status rows SHALL count toward the height budget. The selected task or text cursor SHALL remain in the visible task viewport. Before terminal dimensions arrive, the interactive application SHALL defer document rendering. + +#### Scenario: Focus after an overflowing document +- **WHEN** a user navigates a document with many headings and wrapped tasks and focuses a section +- **THEN** prior frames SHALL NOT leave repeated headings in terminal scrollback +- **AND** the focused section and selected task SHALL remain visible + +#### Scenario: Resize with an active editor or overlay +- **WHEN** the terminal becomes narrower or shorter during editing or an overlay +- **THEN** the frame SHALL fit the new dimensions without terminal soft wrapping or excess rows diff --git a/openspec/specs/usage-replay/spec.md b/openspec/specs/usage-replay/spec.md index 5a5105c..97e3b65 100644 --- a/openspec/specs/usage-replay/spec.md +++ b/openspec/specs/usage-replay/spec.md @@ -2,9 +2,7 @@ ## Purpose Provide reproducible sustained-use contracts that expose editing and persistence regressions and support comparisons with future tdx implementations. - ## Requirements - ### Requirement: Reproducible sustained usage The developer harness SHALL generate versioned language-neutral traces with deterministic seeds, independent expected task states, and explicit simulated durations. It SHALL report actual elapsed time separately and fail on state, persistence or replay-contract violations. @@ -24,3 +22,21 @@ Normal tests SHALL include bounded seeded replay and regression cases discovered #### Scenario: Cancellation and conflicts - **WHEN** input is cancelled at undo capacity or a save meets an external edit - **THEN** earlier undo entries and external disk content remain protected + +### Requirement: Structural regression campaigns +Regression checks SHALL cover nested/ordered tasks, sections, multiline bodies, unrelated Markdown preservation and exact undo. Failing generated campaigns SHALL retain replayable reduced cases or clearly report why reduction is unavailable. Scheduled campaigns SHALL record rotating seeds and retain failure artifacts. + +#### Scenario: A generated edit sequence loses unrelated content +- **WHEN** a generated edit sequence loses unrelated content +- **THEN** the campaign fails and retains a reproducible regression artifact + +#### Scenario: Structural content changes ownership +- **WHEN** an edit unexpectedly relocates a protected block, detaches a descendant, or attaches a task body to the wrong task +- **THEN** the structural campaign SHALL reject the resulting state even if content occurrence counts remain unchanged + +### Requirement: Native executable coverage +CI SHALL exercise CLI contracts on supported native macOS and Windows runners and terminal contracts on macOS and Linux, with Windows terminal checks where supported by ConPTY. + +#### Scenario: Native verification runs +- **WHEN** native verification runs +- **THEN** argument handling, paths, Unicode, configuration isolation and terminal lifecycle are checked on their native platforms diff --git a/scripts/install.sh b/scripts/install.sh index aadc753..bdf198d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -29,12 +29,31 @@ if [[ -n "${TDX_VERSION:-}" ]]; then release_path="download/v${TDX_VERSION#v}" fi tmp_dir=$(mktemp -d) -trap 'rm -rf "$tmp_dir"' EXIT +staged="" +trap 'rm -rf "$tmp_dir"; [[ -z "$staged" ]] || rm -f "$staged"' EXIT printf 'Downloading %s...\n' "$ARTIFACT" -curl --fail --show-error --silent --location --retry 3 \ +curl --fail --show-error --silent --location --retry 3 --connect-timeout 15 --max-time 120 \ "https://github.com/${REPO}/releases/${release_path}/${ARTIFACT}" -o "$tmp_dir/$BINARY" +curl --fail --show-error --silent --location --retry 3 --connect-timeout 15 --max-time 120 \ + "https://github.com/${REPO}/releases/${release_path}/SHA256SUMS" -o "$tmp_dir/SHA256SUMS" +expected=$(awk -v artifact="$ARTIFACT" '$2 == artifact { print $1; count++ } END { if (count != 1) exit 1 }' "$tmp_dir/SHA256SUMS") || { + echo "Checksum manifest must contain exactly one entry for $ARTIFACT." >&2; exit 1; +} +[[ "$expected" =~ ^[[:xdigit:]]{64}$ ]] || { echo 'Invalid SHA-256 checksum.' >&2; exit 1; } +if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$tmp_dir/$BINARY") +elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$tmp_dir/$BINARY") +else + echo 'SHA-256 verification requires sha256sum or shasum.' >&2; exit 1 +fi +[[ "${actual%% *}" == "$expected" ]] || { echo 'SHA-256 verification failed; installation unchanged.' >&2; exit 1; } +# Stage on the destination filesystem so replacement is a single rename. mkdir -p "$INSTALL_DIR" -install -m 755 "$tmp_dir/$BINARY" "$INSTALL_DIR/$BINARY" +staged=$(mktemp "$INSTALL_DIR/.tdx-install.XXXXXX") +install -m 755 "$tmp_dir/$BINARY" "$staged" +mv -f "$staged" "$INSTALL_DIR/$BINARY" +staged="" printf 'Installed %s/%s\n' "$INSTALL_DIR" "$BINARY" case ":$PATH:" in *":$INSTALL_DIR:"*) ;; diff --git a/scripts/test_install.py b/scripts/test_install.py new file mode 100644 index 0000000..f33a349 --- /dev/null +++ b/scripts/test_install.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Run the real installer against an offline release server and disposable PATH.""" +import hashlib +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +INSTALLER = Path(__file__).resolve().with_name("install.sh") + + +class InstallerTests(unittest.TestCase): + def install(self, scenario, *, version="0.15.0"): + with tempfile.TemporaryDirectory(prefix="tdx-installer-") as tmp: + root = Path(tmp) + commands = root / "commands" + commands.mkdir() + destination = root / "bin" + destination.mkdir() + previous = destination / "tdx" + previous.write_bytes(b"previous installation") + artifact = root / "artifact" + artifact.write_bytes(b"#!/bin/sh\necho new installation\n") + digest = hashlib.sha256(artifact.read_bytes()).hexdigest() + manifest = f"{digest} tdx-linux-amd64\n" + if scenario == "corrupt": + manifest = f"{'0' * 64} tdx-linux-amd64\n" + if scenario == "missing": + manifest = f"{digest} tdx-darwin-amd64\n" + if scenario == "duplicate": + manifest *= 2 + if scenario == "malformed": + manifest = "not-a-hash tdx-linux-amd64\n" + (root / "manifest").write_text(manifest) + for name, script in { + "uname": '#!/bin/sh\ncase "$1" in -s) echo Linux;; -m) echo x86_64;; esac\n', + "curl": '''#!/bin/sh +url="" +output="" +while [ "$#" -gt 0 ]; do + case "$1" in + https:*) url="$1" ;; + -o) shift; output="$1" ;; + esac + shift +done +echo "$url" >> "$FIXTURE/requests" +case "$url" in + */SHA256SUMS) + [ "$SCENARIO" != "manifest-download" ] || exit 22 + cp "$FIXTURE/manifest" "$output" ;; + */tdx-linux-amd64) + [ "$SCENARIO" != "binary-download" ] || exit 22 + cp "$FIXTURE/artifact" "$output" ;; + *) exit 1 ;; +esac +''', + }.items(): + path = commands / name + path.write_text(script) + path.chmod(0o755) + if scenario == "staging-failure": + path = commands / "install" + path.write_text('#!/bin/sh\nprintf partial > "$4"\nexit 1\n') + path.chmod(0o755) + env = dict(os.environ, PATH=str(commands)+os.pathsep+os.environ["PATH"], + FIXTURE=str(root), SCENARIO=scenario, + TDX_INSTALL_DIR=str(destination), TDX_VERSION=version) + result = subprocess.run([shutil.which("bash"), str(INSTALLER)], env=env, + capture_output=True, text=True, timeout=10) + if scenario == "success": + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(previous.read_bytes(), artifact.read_bytes()) + self.assertTrue(os.access(previous, os.X_OK)) + else: + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertEqual(previous.read_bytes(), b"previous installation") + self.assertEqual(sorted(p.name for p in destination.iterdir()), ["tdx"]) + if version == "0.15.0": + self.assertIn("/download/v0.15.0/", (root / "requests").read_text()) + + def test_verified_install(self): + self.install("success") + + def test_latest_install(self): + self.install("success", version="") + + def test_failures_preserve_previous_installation(self): + for scenario in ("corrupt", "missing", "duplicate", "malformed", + "binary-download", "manifest-download", "staging-failure"): + with self.subTest(scenario=scenario): + self.install(scenario) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/usage-conpty.py b/scripts/usage-conpty.py new file mode 100644 index 0000000..eca89c0 --- /dev/null +++ b/scripts/usage-conpty.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Windows ConPTY smoke: render, resize, edit, undo, manual-save isolation, exit. + +Requires pywinpty==3.0.2. Runs on Windows only and explicitly selects ConPTY. +This does not claim parity with the POSIX suite's symlink/history/conflict checks +or validate clipboard integration with a desktop terminal. +""" +import argparse +import json +import os +from pathlib import Path +import select +import tempfile +import time + + +def run(binary, output): + from winpty import PtyProcess + from winpty.enums import Backend + + results = [] + for manual in (False, True): + with tempfile.TemporaryDirectory(prefix="tdx-conpty-") as tmp: + base = Path(tmp) + path = base / "tasks.md" + original = b"# Work\n\n- [ ] Task\n" + path.write_bytes(original) + env = dict(os.environ, XDG_CONFIG_HOME=str(base / "config"), + TERM="xterm-256color", PYWINPTY_BACKEND="0") + args = [str(binary), "--file", str(path)] + if manual: + args.append("--read-only") + # pywinpty 3.0.2 treats integer 0 as an omitted backend; its public + # spawn adapter accepts the string form and then converts to int. + process = PtyProcess.spawn(args, env=env, dimensions=(25, 100), + backend=str(Backend.ConPTY)) + transcript = [] + queries = "" + + def pump(): + nonlocal queries + # pywinpty forwards ConPTY output to a socket; fd is that + # socket's fileno, supported by Windows select (not a pipe). + if select.select([process.fd], [], [], 0.05)[0]: + try: + data = process.read(65536) + except EOFError: + return + transcript.append(data) + queries += data + for query, reply in (("\x1b[6n", "\x1b[1;1R"), ("\x1b[c", "\x1b[?1;2c")): + if query in queries: + process.write(reply) + queries = queries.replace(query, "") + queries = queries[-8:] + + def until(predicate, label): + deadline = time.monotonic() + 10 + last_read_error = None + while time.monotonic() < deadline: + pump() + try: + if predicate(): + return + last_read_error = None + except PermissionError as exc: + # Atomic replacement briefly denies new readers on + # Windows. Each read_bytes call closes its own handle; + # retry within the original deadline, never hold it. + last_read_error = exc + if last_read_error is not None: + raise last_read_error + raise AssertionError(f"timeout: {label}; output={''.join(transcript)[-600:]!r}") + + label = "manual-save" if manual else "editing" + try: + until(lambda: "Task" in "".join(transcript), "initial render") + process.setwinsize(20, 40) + process.write(" ") + if not manual: + until(lambda: b"[x] Task" in path.read_bytes(), "checkbox saved") + process.write("u") + until(lambda: path.read_bytes() == original, "undo exact bytes") + process.write("e\x1b[200~ edited\x1b[201~\r") + until(lambda: b"Task edited" in path.read_bytes(), "text edited") + process.write("u") + until(lambda: path.read_bytes() == original, "edit undo") + process.setwinsize(30, 120) + process.write("\x04") + until(lambda: not process.isalive(), "clean exit") + assert process.exitstatus == 0, process.exitstatus + assert path.read_bytes() == original, "unsaved edits changed disk" + results.append({"scenario": label, "passed": True}) + finally: + (output / f"{label}.ansi").write_text("".join(transcript), encoding="utf-8") + process.close(force=True) + return results + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, default=Path("tdx.exe")) + parser.add_argument("--output", type=Path, default=Path("dist/usage/conpty")) + args = parser.parse_args() + if os.name != "nt": + parser.error("ConPTY smoke tests require a native Windows runner") + args.output.mkdir(parents=True, exist_ok=True) + report = {"backend": "ConPTY", "status": "running"} + try: + report["scenarios"] = run(args.binary.resolve(), args.output) + report["status"] = "passed" + except Exception as exc: + report.update(status="failed", error=str(exc)) + raise + finally: + (args.output / "report.json").write_text(json.dumps(report, indent=2)+"\n") + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/visual-pty.py b/scripts/visual-pty.py new file mode 100644 index 0000000..593c182 --- /dev/null +++ b/scripts/visual-pty.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Check real inline terminal frames with pyte's screen and scrollback model. + +Run `mise run test:terminal-visual` for isolated test dependencies, or install +pyte==0.8.2 and wcwidth==0.8.3 in a test environment and invoke this script. +Requires a POSIX PTY (macOS/Linux); no production dependency is introduced. +""" +import argparse +import importlib.util +import json +from pathlib import Path +import re +import tempfile + +import pyte + + +spec = importlib.util.spec_from_file_location( + "usage_pty", Path(__file__).with_name("usage-pty.py")) +pty_suite = importlib.util.module_from_spec(spec) +spec.loader.exec_module(pty_suite) + + +class VisualTerminal(pty_suite.Terminal): + def __init__(self, *args): + self.screen = pyte.HistoryScreen(100, 32, history=2000) + self.stream = pyte.ByteStream(self.screen) + self.fed = 0 + super().__init__(*args) + + def resize(self, width, height): + self.screen.resize(lines=height, columns=width) + super().resize(width, height) + + def pump(self, seconds=0.05): + super().pump(seconds) + self.stream.feed(bytes(self.output[self.fed:])) + self.fed = len(self.output) + + def text(self): + return "\n".join(self.screen.display) + + def history_text(self): + return "\n".join( + "".join(line[column].data for column in sorted(line)) + for line in self.screen.history.top) + + def selected_task(self, number): + return re.search(r"0\s*[➜→]\s*\[ \] Task " + str(number) + r"\b", self.text()) + + +def run_case(binary, output, resize): + output.mkdir(parents=True, exist_ok=True) + snapshots = [] + with tempfile.TemporaryDirectory(prefix="tdx-visual-pty-") as tmp: + base = Path(tmp) + path = base / "tasks.md" + content = "---\nshow-headings: true\nfilter-done: true\nmax-visible: 0\n---\n# Project\n" + for number in range(9): + content += f"## Old {number}\n- [x] Done {number}\n" + content += "## Other features\n" + for number in range(10): + content += f"- [ ] Task {number} " + "long description with Unicode café 界 " * 4 + "\n" + path.write_text(content) + terminal = VisualTerminal(binary, path, base / "config") + + def capture(label, focused=False): + terminal.pump(0.15) + text, history = terminal.text(), terminal.history_text() + (output / f"{label}.txt").write_text(text + "\n--- HISTORY ---\n" + history) + snapshot = { + "label": label, + "old_headings_on_screen": text.count("## Old"), + "old_headings_in_history": history.count("## Old"), + "section_banner": "Section: Other features" in text, + "selected_task": bool(re.search(r"0\s*[➜→]\s*\[ \] Task", text)), + "history_lines": len(terminal.screen.history.top), + } + snapshots.append(snapshot) + (output / "snapshots.json").write_text(json.dumps(snapshots, indent=2) + "\n") + assert snapshot["old_headings_in_history"] == 0, f"{label}: headings leaked into scrollback" + if focused: + assert snapshot["old_headings_on_screen"] == 0, f"{label}: stale headings remain above focused view" + assert snapshot["section_banner"], f"{label}: section banner is clipped" + assert snapshot["selected_task"], f"{label}: selected task is clipped" + return snapshot + + try: + terminal.until(lambda: terminal.selected_task(0), "initial selected task") + initial = capture("initial") + assert initial["old_headings_on_screen"] > 0, "fixture must exercise headings preceding the first task" + if resize: + terminal.resize(80, 24) + terminal.send(b"G") + terminal.until(lambda: terminal.selected_task(9), "last task") + capture("all-bottom") + terminal.send(b"s") + terminal.until(lambda: "Sections" in terminal.text(), "sections picker") + terminal.send(b"G\r") + terminal.until(lambda: "Section: Other features" in terminal.text(), "section focus") + capture("focused", focused=True) + terminal.send(b"kkj") + terminal.until(lambda: terminal.selected_task(8), "focused task navigation") + capture("focused-navigated", focused=True) + if resize: + terminal.resize(40, 12) + terminal.until(lambda: terminal.selected_task(8) and "Section: Other features" in terminal.text(), "small focused frame") + capture("focused-small", focused=True) + terminal.resize(100, 32) + terminal.until(lambda: terminal.selected_task(8) and "Section: Other features" in terminal.text(), "grown focused frame") + capture("focused-grown", focused=True) + terminal.send(b"S") + terminal.until(lambda: "Section: Other features" not in terminal.text(), "all sections") + terminal.send(b"s") + terminal.until(lambda: "Sections" in terminal.text(), "sections picker again") + terminal.send(b"G\r") + terminal.until(lambda: "Section: Other features" in terminal.text(), "section focus again") + capture("focused-again", focused=True) + assert path.read_text() == content, "navigation changed Markdown" + terminal.close() + finally: + terminal.cleanup(output / "transcript.ansi") + return {"scenario": "resize" if resize else "fixed-size", "passed": True, "snapshots": snapshots} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, default=Path("tdx")) + parser.add_argument("--output", type=Path, default=Path("dist/usage/visual-pty")) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + report_path = args.output / "report.json" + report_path.write_text(json.dumps({"status": "running"}) + "\n") + try: + scenarios = [run_case(args.binary.resolve(), args.output / name, resize) + for name, resize in (("fixed-size", False), ("resize", True))] + except Exception as exc: + report_path.write_text(json.dumps({"status": "failed", "error": str(exc)}, indent=2) + "\n") + raise + report = {"status": "passed", "scenarios": scenarios} + report_path.write_text(json.dumps(report, indent=2) + "\n") + print(json.dumps({"status": "passed", "scenarios": len(scenarios), "output": str(args.output)})) + + +if __name__ == "__main__": + main()