From 8167dbbdf981d1a27798837f89314a38dac7f695 Mon Sep 17 00:00:00 2001 From: Ilya Voronin Date: Sat, 29 Aug 2026 16:38:49 +0300 Subject: [PATCH] zig rewrite --- .github/dependabot.yml | 14 - .github/workflows/automerge.yml | 16 - .github/workflows/release.yml | 6 +- .github/workflows/test.yml | 7 +- .gitignore | 51 +- .golangci.yml | 20 - .goreleaser.yaml | 55 +- .tool-versions | 1 - CONTEXT.md | 35 + LICENSE | 674 ------------------ Makefile | 35 +- README.md | 80 --- build.zig | 60 ++ build.zig.zon | 29 + cmd/wch/main.go | 124 ---- go.mod | 29 - go.sum | 46 -- internal/diff/align.go | 206 ------ internal/diff/align_test.go | 188 ----- internal/diff/diff.go | 53 -- internal/diff/diff_test.go | 195 ----- internal/diff/lcs.go | 45 -- internal/diff/mapline.go | 56 -- internal/diff/similarity.go | 35 - internal/diff/token.go | 39 - internal/diff/worddiff.go | 56 -- internal/pathutil/pathutil.go | 26 - internal/pathutil/pathutil_test.go | 36 - internal/recording/flow.go | 141 ---- internal/recording/flow_test.go | 205 ------ internal/recording/inmem.go | 96 --- internal/recording/inmem_test.go | 68 -- internal/recording/jsonl.go | 82 --- internal/recording/jsonl_test.go | 241 ------- internal/recording/load.go | 92 --- internal/recording/load_test.go | 37 - internal/recording/schema.go | 52 -- internal/runner/runner.go | 74 -- internal/session/port.go | 21 - internal/session/session.go | 124 ---- internal/session/session_test.go | 140 ---- internal/tui/bar.go | 101 --- internal/tui/bar_test.go | 175 ----- internal/tui/cursor.go | 68 -- internal/tui/cursor_test.go | 88 --- internal/tui/diffrender/diffrender.go | 79 -- internal/tui/diffrender/diffrender_test.go | 98 --- internal/tui/framemodel.go | 95 --- internal/tui/framemodel_test.go | 97 --- internal/tui/help.go | 123 ---- internal/tui/help_test.go | 166 ----- internal/tui/helprender/helprender.go | 25 - internal/tui/helprender/helprender_test.go | 67 -- internal/tui/keys.go | 88 --- internal/tui/layout.go | 47 -- internal/tui/messages.go | 15 - internal/tui/model.go | 444 ------------ internal/tui/model_test.go | 53 -- internal/tui/notify/notify.go | 110 --- internal/tui/notify/notify_test.go | 248 ------- internal/tui/notify/render.go | 73 -- internal/tui/notify/style.go | 37 - internal/tui/overlay/overlay.go | 83 --- internal/tui/overlay/overlay_test.go | 98 --- internal/tui/preferences.go | 18 - internal/tui/preferences_test.go | 33 - internal/tui/recording.go | 86 --- internal/tui/recording_test.go | 259 ------- internal/tui/scrollview/scrollview.go | 264 ------- internal/tui/scrollview/scrollview_test.go | 171 ----- internal/tui/search.go | 62 -- internal/tui/search_test.go | 80 --- internal/tui/searchrender/searchrender.go | 37 - .../tui/searchrender/searchrender_test.go | 47 -- internal/tui/state.go | 79 -- internal/tui/state_common.go | 32 - internal/tui/state_input.go | 163 ----- internal/tui/state_input_test.go | 118 --- internal/tui/state_picker.go | 154 ---- internal/tui/state_picker_test.go | 154 ---- internal/tui/state_search.go | 131 ---- internal/tui/state_search_test.go | 402 ----------- internal/tui/state_view.go | 80 --- internal/tui/state_view_test.go | 113 --- internal/tui/styles.go | 86 --- internal/tui/testhelpers_test.go | 78 -- internal/tui/view.go | 53 -- src/args.zig | 71 ++ src/bar.zig | 186 +++++ src/diff.zig | 315 ++++++++ src/history.zig | 155 ++++ src/main.zig | 127 ++++ src/model.zig | 152 ++++ src/output.zig | 257 +++++++ src/run.zig | 62 ++ src/viewport.zig | 279 ++++++++ 96 files changed, 1757 insertions(+), 8615 deletions(-) delete mode 100644 .github/dependabot.yml delete mode 100644 .github/workflows/automerge.yml delete mode 100644 .golangci.yml delete mode 100644 .tool-versions create mode 100644 CONTEXT.md delete mode 100644 LICENSE delete mode 100644 README.md create mode 100644 build.zig create mode 100644 build.zig.zon delete mode 100644 cmd/wch/main.go delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 internal/diff/align.go delete mode 100644 internal/diff/align_test.go delete mode 100644 internal/diff/diff.go delete mode 100644 internal/diff/diff_test.go delete mode 100644 internal/diff/lcs.go delete mode 100644 internal/diff/mapline.go delete mode 100644 internal/diff/similarity.go delete mode 100644 internal/diff/token.go delete mode 100644 internal/diff/worddiff.go delete mode 100644 internal/pathutil/pathutil.go delete mode 100644 internal/pathutil/pathutil_test.go delete mode 100644 internal/recording/flow.go delete mode 100644 internal/recording/flow_test.go delete mode 100644 internal/recording/inmem.go delete mode 100644 internal/recording/inmem_test.go delete mode 100644 internal/recording/jsonl.go delete mode 100644 internal/recording/jsonl_test.go delete mode 100644 internal/recording/load.go delete mode 100644 internal/recording/load_test.go delete mode 100644 internal/recording/schema.go delete mode 100644 internal/runner/runner.go delete mode 100644 internal/session/port.go delete mode 100644 internal/session/session.go delete mode 100644 internal/session/session_test.go delete mode 100644 internal/tui/bar.go delete mode 100644 internal/tui/bar_test.go delete mode 100644 internal/tui/cursor.go delete mode 100644 internal/tui/cursor_test.go delete mode 100644 internal/tui/diffrender/diffrender.go delete mode 100644 internal/tui/diffrender/diffrender_test.go delete mode 100644 internal/tui/framemodel.go delete mode 100644 internal/tui/framemodel_test.go delete mode 100644 internal/tui/help.go delete mode 100644 internal/tui/help_test.go delete mode 100644 internal/tui/helprender/helprender.go delete mode 100644 internal/tui/helprender/helprender_test.go delete mode 100644 internal/tui/keys.go delete mode 100644 internal/tui/layout.go delete mode 100644 internal/tui/messages.go delete mode 100644 internal/tui/model.go delete mode 100644 internal/tui/model_test.go delete mode 100644 internal/tui/notify/notify.go delete mode 100644 internal/tui/notify/notify_test.go delete mode 100644 internal/tui/notify/render.go delete mode 100644 internal/tui/notify/style.go delete mode 100644 internal/tui/overlay/overlay.go delete mode 100644 internal/tui/overlay/overlay_test.go delete mode 100644 internal/tui/preferences.go delete mode 100644 internal/tui/preferences_test.go delete mode 100644 internal/tui/recording.go delete mode 100644 internal/tui/recording_test.go delete mode 100644 internal/tui/scrollview/scrollview.go delete mode 100644 internal/tui/scrollview/scrollview_test.go delete mode 100644 internal/tui/search.go delete mode 100644 internal/tui/search_test.go delete mode 100644 internal/tui/searchrender/searchrender.go delete mode 100644 internal/tui/searchrender/searchrender_test.go delete mode 100644 internal/tui/state.go delete mode 100644 internal/tui/state_common.go delete mode 100644 internal/tui/state_input.go delete mode 100644 internal/tui/state_input_test.go delete mode 100644 internal/tui/state_picker.go delete mode 100644 internal/tui/state_picker_test.go delete mode 100644 internal/tui/state_search.go delete mode 100644 internal/tui/state_search_test.go delete mode 100644 internal/tui/state_view.go delete mode 100644 internal/tui/state_view_test.go delete mode 100644 internal/tui/styles.go delete mode 100644 internal/tui/testhelpers_test.go delete mode 100644 internal/tui/view.go create mode 100644 src/args.zig create mode 100644 src/bar.zig create mode 100644 src/diff.zig create mode 100644 src/history.zig create mode 100644 src/main.zig create mode 100644 src/model.zig create mode 100644 src/output.zig create mode 100644 src/run.zig create mode 100644 src/viewport.zig diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 74083f5..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,14 +0,0 @@ -version: 2 -updates: - - package-ecosystem: gomod - directory: / - schedule: - interval: weekly - commit-message: - prefix: deps - labels: - - dependencies - groups: - go-modules: - patterns: - - "*" diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml deleted file mode 100644 index 8296a83..0000000 --- a/.github/workflows/automerge.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: automerge - -on: - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: write - pull-requests: write - -jobs: - automerge: - uses: ivoronin/github-workflows/.github/workflows/automerge.yml@main - with: - dependabot: true - secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 524e543..3041d2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: release on: push: tags: - - 'v*' + - "v*" permissions: contents: write @@ -11,8 +11,8 @@ permissions: jobs: release: - uses: ivoronin/github-workflows/.github/workflows/release.yml@main + uses: ivoronin/github-workflows/.github/workflows/release.yml@a56926c483d814dd0ab9543491d07ac64fd49b2f # main with: - language: go + language: zig brew: true secrets: inherit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 024f78b..09abf8b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,8 +6,11 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: test: - uses: ivoronin/github-workflows/.github/workflows/test.yml@main + uses: ivoronin/github-workflows/.github/workflows/test.yml@a56926c483d814dd0ab9543491d07ac64fd49b2f # main with: - language: go + language: zig diff --git a/.gitignore b/.gitignore index d9de979..378c6be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,48 +1,7 @@ -# Binaries -bin/ +.zig-cache/ +zig-out/ dist/ -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary -*.test - -# Coverage -*.out -coverage.html - -# IDE / AI -.idea/ -.vscode/ +zig-pkg/ +/release/ .claude/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# Go -vendor/ - -# wch recordings -*.wch.jsonl - -# Python -.venv/ -__pycache__/ -*.pyc -*.pyo -.ruff_cache/ -*.egg-info/ - -# Node -node_modules/ - -# Docs (keep only README) -*.md -!README.md +.wayfinder/ diff --git a/.golangci.yml b/.golangci.yml deleted file mode 100644 index 18e6693..0000000 --- a/.golangci.yml +++ /dev/null @@ -1,20 +0,0 @@ -version: "2" - -formatters: - enable: - - gofmt - -linters: - enable: - - govet - - errcheck - - staticcheck - - unused - - ineffassign - -linters-settings: - errcheck: - check-type-assertions: true - -issues: - exclude-use-default: false diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ed2a81c..2985145 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,49 +1,16 @@ -# yaml-language-server: $schema=https://goreleaser.com/static/schema.json version: 2 -before: - hooks: - - go mod tidy - builds: - - main: ./cmd/{{ .ProjectName }} - binary: "{{ .ProjectName }}" - env: - - CGO_ENABLED=0 - goos: - - linux - - darwin - - windows - goarch: - - amd64 - - arm64 - ldflags: - - -s -w - - -X main.version={{ .Version }} + - builder: zig + binary: wch + flags: + - -Doptimize=ReleaseSafe + - "-Dversion={{ .Version }}" + targets: + - x86_64-linux + - aarch64-linux + - x86_64-macos + - aarch64-macos archives: - - formats: - - tar.gz - format_overrides: - - goos: windows - formats: - - zip - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - files: - - LICENSE - - README.md - -checksum: - name_template: checksums.txt - -changelog: - sort: asc - filters: - exclude: - - '^docs:' - - '^test:' - - '^ci:' - - '^chore:' - -snapshot: - version_template: "{{ incpatch .Version }}-next" + - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index 8f33385..0000000 --- a/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -golangci-lint 2.8.0 diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..fd0b702 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,35 @@ +# Watch TUI + +`wch` repeatedly runs one command and keeps recent output available in a terminal interface. + +## Language + +**Run**: +One execution attempt of the watched command, with its completion time and captured output. +_Avoid_: Sample, result + +**Output**: +The normalized, display-ready form of one run. +_Avoid_: Picture, pane content + +**History**: +The retained sequence of distinct runs, ordered from oldest to newest. +_Avoid_: Log, archive + +**History cursor**: +A stable selection that can be resolved while its run remains in history. +_Avoid_: Anchor, index + +**Live mode**: +The mode that follows the newest run. + +**History mode**: +The mode that shows one selected run instead of following the newest run. + +**Viewport**: +The scrollable visible region of output. +_Avoid_: Pane, picture + +**Status bar**: +The bottom row that reports mode, run time, history position, and available keys. +_Avoid_: Dock, footer diff --git a/LICENSE b/LICENSE deleted file mode 100644 index f288702..0000000 --- a/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/Makefile b/Makefile index 50da824..702edab 100644 --- a/Makefile +++ b/Makefile @@ -1,38 +1,13 @@ -PROJECT := $(shell basename $(CURDIR)) -VERSION ?= dev -GO_VERSION := $(shell grep '^go ' go.mod | awk '{print $$2}') -LDFLAGS := -s -w -X main.version=$(VERSION) +.PHONY: build test-all release clean -.PHONY: build test test-integration test-e2e test-all lint release clean - -# Build binary locally build: - go build -ldflags "$(LDFLAGS)" -o bin/$(PROJECT) ./cmd/$(PROJECT) - -# Unit tests -test: - go test -race ./... - -# Integration tests (optional) -test-integration: build - go test -tags=integration ./... - -# E2E tests (optional) -test-e2e: build - go test -tags=e2e ./... - -# CI calls this target -test-all: lint test - @echo "All tests passed" + zig build -Doptimize=ReleaseSafe -# Linting -lint: - golangci-lint run +test-all: + zig build test -# CI calls this target for releases release: goreleaser release --clean -# Clean build artifacts clean: - rm -rf bin/ dist/ + rm -rf .zig-cache zig-out dist diff --git a/README.md b/README.md deleted file mode 100644 index 62c4120..0000000 --- a/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# wch - -Watch command output with history you can rewind - -[![CI](https://github.com/ivoronin/wch/actions/workflows/test.yml/badge.svg)](https://github.com/ivoronin/wch/actions/workflows/test.yml) -[![Release](https://img.shields.io/github/v/release/ivoronin/wch)](https://github.com/ivoronin/wch/releases) - -## Table of Contents - -[Overview](#overview) · [Features](#features) · [Installation](#installation) · [Usage](#usage) · [Configuration](#configuration) · [License](#license) - -```bash -# Instead of: -watch -n 1 kubectl get pods - -# Use: -wch kubectl get pods -``` - -## Overview - -wch runs a command on an interval and displays the scrollable output. Two design choices set it apart from `watch(1)` and other modern replacements. Rows in the output are matched across refreshes by identity (a stable token like a pod `NAME`) instead of exact line equality, so the line you're reading stays anchored in place when rows insert above the viewport, and a row whose `AGE` ticks every refresh reads as one cell change rather than a delete + insert. The TUI itself shows only the command's output and a single status line — no border, no line numbers, no help banner, no config file, no keymap rebinding, no theme to pick (light/dark is detected from the terminal background at startup). - -## Features - -- Scroll position anchored to content (row identity, not line offset) -- Minimal UI surface (no border, line numbers, help banner, config file, keymap rebinding; theme auto-detected) -- Word-level diff highlighting between executions, tolerant of volatile fields (`AGE`, `RESTARTS`) so a row whose value ticks each refresh doesn't read as a delete + insert -- History keeps up to `-l` past executions (default 86400 ≈ 24h at 1s interval; `-l 0` for unlimited), navigable with arrow keys -- Record sessions to a JSONL file (`-w `) and replay them offline with full history navigation (`-r `) -- Scrollable view for output that exceeds terminal height (unlike `watch(1)`) -- Terminal notifications on output change (OSC 9, supported by iTerm2 and others) -- Keyboard navigation (arrow keys, PgUp/PgDn, Home/End) -- Pause/resume execution -- Toggleable status bar and diff highlighting -- Horizontal scrolling for wide output -- Configurable refresh interval - -## Installation - -### GitHub Releases - -Download from [Releases](https://github.com/ivoronin/wch/releases). - -### Homebrew - -```bash -brew install ivoronin/ivoronin/wch -``` - -## Usage - -### Basic - -```bash -wch kubectl get pods # watch with 1s interval -wch -i 5s kubectl get pods # 5 second interval -wch -d kubectl get pods # disable diff highlighting -wch -t kubectl get pods # hide status bar -wch -b kubectl get pods # enable notifications -wch -w session.wch.jsonl kubectl get pods # record session while watching -wch -r session.wch.jsonl # replay recorded session offline -``` - -## Configuration - -### Flags - -| Flag | Description | Default | -|------|-------------|---------| -| `-i` | Refresh interval | `1s` | -| `-d` | Disable diff highlighting | `false` | -| `-t` | Hide status bar | `false` | -| `-b` | Enable notifications | `false` | -| `-w` | Write recording to path (must not exist) | — | -| `-r` | Read a recorded session (offline replay) | — | - -## License - -[GPL-3.0](LICENSE) diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..c6e977d --- /dev/null +++ b/build.zig @@ -0,0 +1,60 @@ +const std = @import("std"); + +/// Configure the executable, dependencies, and developer commands. +pub fn build(build_system: *std.Build) void { + const target = build_system.standardTargetOptions(.{}); + const optimization = build_system.standardOptimizeOption(.{}); + const version = build_system.option([]const u8, "version", "Application version") orelse "dev"; + + // One module. The program is the only thing that reads this source, so + // there is nobody to hand a library module to. + const executable = build_system.addExecutable(.{ + .name = "wch", + .root_module = build_system.createModule(.{ + .root_source_file = build_system.path("src/main.zig"), + .target = target, + .optimize = optimization, + }), + }); + + const build_metadata = build_system.addOptions(); + build_metadata.addOption([]const u8, "version", version); + executable.root_module.addOptions("build_metadata", build_metadata); + + const dizzy = build_system.dependency("dizzy", .{}); + const clap = build_system.dependency("clap", .{}); + const zeit = build_system.dependency("zeit", .{}); + const vaxis = build_system.dependency("vaxis", .{ + .target = target, + .optimize = optimization, + }); + + // diff.zig aligns text with dizzy. + executable.root_module.addImport("dizzy", dizzy.module("dizzy")); + + // args.zig reads the command line with clap. + executable.root_module.addImport("clap", clap.module("clap")); + + // bar.zig shows a clock, which means a local time zone. + executable.root_module.addImport("zeit", zeit.module("zeit")); + + // output.zig prepares vaxis segments, and viewport.zig paints them. + executable.root_module.addImport("vaxis", vaxis.module("vaxis")); + + build_system.installArtifact(executable); + + // Depends on the install step, so it runs from the install directory + // rather than from within the cache. + const run_command = build_system.addRunArtifact(executable); + run_command.step.dependOn(build_system.getInstallStep()); + // `zig build run -- arg1 arg2`. + if (build_system.args) |application_arguments| run_command.addArgs(application_arguments); + + build_system.step("run", "Run the app").dependOn(&run_command.step); + + const test_executable = build_system.addTest(.{ .root_module = executable.root_module }); + + build_system.step("test", "Run tests").dependOn( + &build_system.addRunArtifact(test_executable).step, + ); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..c641b6e --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,29 @@ +.{ + .name = .wch, + .version = "0.0.0", + .fingerprint = 0xa34676c985ea5406, // Changing this has security and trust implications. + .minimum_zig_version = "0.16.0", + .dependencies = .{ + .dizzy = .{ + .url = "git+https://github.com/neurocyte/dizzy#4e7c47d882540f6ca6b2d8321451430ba2b6f55d", + .hash = "dizzy-1.0.0-q40X4aGRAADKbMiB82-6NSGBDG4Cd4wZxQN4kiYDQu-8", + }, + .clap = .{ + .url = "git+https://github.com/Hejsil/zig-clap?ref=0.12.0#8d97efa1ee1e575443c7888d5c38e1c3fc145cf5", + .hash = "clap-0.12.0-oBajB7foAQDqlSwaSG5g0yq7xGbQARUsBk5T64gAOqP5", + }, + .vaxis = .{ + .url = "git+https://github.com/rockorager/libvaxis.git#7d4aed7fef1944c4446b8e70d6b71b7229794281", + .hash = "vaxis-0.6.0-BWNV_MK5CgCkRxCML4hTf4nb1Ma18mZeT8swSprge7f1", + }, + .zeit = .{ + .url = "git+https://github.com/rockorager/zeit#b1c1c2fcbc71fd7799a316bbcf0ff88d06d80ccc", + .hash = "zeit-0.9.0-5I6bk2m9AgBSMH8-L6rYJkwuQAyhXplnfxnvTSGzVHUR", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/cmd/wch/main.go b/cmd/wch/main.go deleted file mode 100644 index 8c34ba5..0000000 --- a/cmd/wch/main.go +++ /dev/null @@ -1,124 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "strings" - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/ivoronin/wch/internal/recording" - "github.com/ivoronin/wch/internal/tui" -) - -var version = "dev" - -func main() { - interval := flag.Duration("i", time.Second, "refresh interval") - historyLimit := flag.Int("l", 86400, "history limit (executions retained in memory; 0 = unlimited)") - disableDiff := flag.Bool("d", false, "disable diff highlighting") - hideStatus := flag.Bool("t", false, "hide status bar") - enableNotify := flag.Bool("b", false, "enable terminal notification on change") - openPath := flag.String("r", "", "read a recorded session in replay mode (offline)") - writePath := flag.String("w", "", "write a recording to (started immediately; file must not already exist)") - showVersion := flag.Bool("version", false, "show version") - - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: wch [flags] \n wch -r \n\nFlags:\n") - flag.PrintDefaults() - } - - flag.Parse() - - if *showVersion { - fmt.Printf("wch %s\n", version) - os.Exit(0) - } - - if *openPath != "" && *writePath != "" { - fmt.Fprintln(os.Stderr, "Error: -r and -w are mutually exclusive") - flag.Usage() - os.Exit(1) - } - - var model tea.Model - - if *openPath != "" { - if len(flag.Args()) > 0 { - fmt.Fprintln(os.Stderr, "Error: -r is exclusive with a command") - flag.Usage() - os.Exit(1) - } - s, err := recording.Load(*openPath) - if err != nil { - if s == nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - // Partial load (corrupt/truncated lines): surface as a warning but proceed - // with the frames that did decode. - fmt.Fprintf(os.Stderr, "wch: warning: %v\n", err) - } - model = tui.NewReplay(tui.Config{ - Command: s.Command, - Interval: s.Interval, - DiffEnabled: !*disableDiff, - ShowStatus: !*hideStatus, - }, s) - } else { - args := flag.Args() - if len(args) == 0 { - fmt.Fprintln(os.Stderr, "Error: command required") - flag.Usage() - os.Exit(1) - } - var autoStart *recording.AutoStartRequest - if *writePath != "" { - p, err := recording.NormalizePath(*writePath) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - // Best-effort fast-fail before launching the TUI. The atomic guard against - // clobber is JSONLRecorder's O_EXCL open; a race here still surfaces as an - // in-TUI warning rather than data loss. - if err := recording.PreflightCheck(p); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - autoStart = &recording.AutoStartRequest{Path: p} - } - command := strings.Join(args, " ") - model = tui.New(tui.Config{ - Command: command, - Interval: *interval, - DiffEnabled: !*disableDiff, - ShowStatus: !*hideStatus, - NotifyOnChange: *enableNotify, - AutoStart: autoStart, - MaxHistory: *historyLimit, - }) - } - - p := tea.NewProgram(model) - - finalModel, err := p.Run() - // Bubble Tea v2 short-circuits Model.Update on QuitMsg, so the TUI cannot finalise its - // own recording on quit. Cleanup runs unconditionally — even when p.Run returned an - // error — so an active recording is closed before we exit. Cleanup is idempotent - // (no-op when no recording is active). - var cleanupErr error - if m, ok := finalModel.(tui.Model); ok { - cleanupErr = m.Cleanup() - } - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - if cleanupErr != nil { - fmt.Fprintf(os.Stderr, "wch: recording: %v\n", cleanupErr) - os.Exit(1) - } -} diff --git a/go.mod b/go.mod deleted file mode 100644 index 528d06a..0000000 --- a/go.mod +++ /dev/null @@ -1,29 +0,0 @@ -module github.com/ivoronin/wch - -go 1.25.6 - -require ( - charm.land/bubbles/v2 v2.1.0 - charm.land/bubbletea/v2 v2.0.2 - charm.land/lipgloss/v2 v2.0.2 - github.com/charmbracelet/x/ansi v0.11.6 - github.com/charmbracelet/x/cellbuf v0.0.15 -) - -require ( - github.com/atotto/clipboard v0.1.4 // indirect - github.com/charmbracelet/colorprofile v0.4.2 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/charmbracelet/x/termios v0.1.1 // indirect - github.com/charmbracelet/x/windows v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-runewidth v0.0.21 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.42.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 43ef0b3..0000000 --- a/go.sum +++ /dev/null @@ -1,46 +0,0 @@ -charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= -charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= -charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= -charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= -charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= -charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= -github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= -github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= -github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= -github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= -github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= -github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= -github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= -github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= -github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/diff/align.go b/internal/diff/align.go deleted file mode 100644 index 6fd0b17..0000000 --- a/internal/diff/align.go +++ /dev/null @@ -1,206 +0,0 @@ -// Package diff turns two snapshots of a command's output into a structured description of -// what changed. It is tuned for live "watch"-style output where most lines persist across -// refreshes but their volatile fields (a kubectl AGE, a counter) change every time. -// -// The package is dependency-free (stdlib only) and does no rendering: it reports what -// changed and where lines moved, leaving all styling to the caller. -// -// - Line alignment (Align): anchor on lines that are byte-identical and unique in both -// snapshots, then align the gaps between anchors by token similarity (Jaccard), so a row -// whose AGE ticked still pairs with its old self instead of looking like a delete+insert. -// This is patience diff with a similarity gap-filler; anchoring keeps it linear on large -// output. -// - Structured diff (Lines): each new-output line tagged Equal/Changed/Added, with a -// word-level Span breakdown for changed lines. The caller styles the Changed spans. -// - Position mapping (MapLine): where an old line moved to, for preserving a scroll or -// cursor position across refreshes. -// -// The design is order-preserving: it does not track moves, so reordered output (e.g. -// `kubectl top --sort-by`) shows moved rows as changed. -// -// Internally the concerns form a one-directional chain: -// -// {lcs, similarity, token} -> align -> { mapline, worddiff } -> diff -package diff - -import "strings" - -const ( - // simThreshold is the minimum token-overlap (Jaccard) score for two lines to be treated - // as the same row across a refresh. The identifier token (e.g. a pod NAME) dominates the - // score, so volatile fields like AGE ticking still match. Tunable. - simThreshold = 0.5 - // alignCellCap bounds a single gap's similarity DP (and a single line's word DP). A gap - // with no usable anchors that is larger than this falls back to positional pairing (and - // MapLine to a similarity scan). With anchoring, this is reached only by mostly-changed - // output that has no stable lines to anchor on. - alignCellCap = 2_000_000 -) - -type opKind uint8 - -const ( - opMatch opKind = iota - opInsert - opDelete -) - -type op struct { - kind opKind - oldIdx int // set for opMatch and opDelete, else -1 - newIdx int // set for opMatch and opInsert, else -1 -} - -// Alignment is the order-preserving correspondence between the lines of two outputs, built -// by anchoring on unchanged unique lines and similarity-aligning the gaps between them. It -// answers where a line moved (MapLine) and what changed per line (Lines). -type Alignment struct { - ops []op - oldLines []string - newLines []string - coarse bool -} - -func splitLines(s string) []string { - if s == "" { - return nil - } - return strings.Split(s, "\n") -} - -// Align builds a similarity-based, order-preserving alignment of oldText to newText. -func Align(oldText, newText string) Alignment { - a := Alignment{oldLines: splitLines(oldText), newLines: splitLines(newText)} - n, m := len(a.oldLines), len(a.newLines) - - // Peel byte-identical common prefix/suffix (cheap; also handles duplicate runs). - p := 0 - for p < n && p < m && a.oldLines[p] == a.newLines[p] { - p++ - } - s := 0 - for s < n-p && s < m-p && a.oldLines[n-1-s] == a.newLines[m-1-s] { - s++ - } - - for i := range p { - a.ops = append(a.ops, op{opMatch, i, i}) - } - a.ops = append(a.ops, a.alignRange(p, n-s, p, m-s)...) - for k := range s { - a.ops = append(a.ops, op{opMatch, n - s + k, m - s + k}) - } - return a -} - -// anchor pairs an old-line index with the identical new-line index it pins to. -type anchor struct{ oi, ni int } - -// findAnchors returns, increasing on both sides, the lines that are byte-identical and -// appear exactly once in each range. These are unambiguous correspondences: an unchanged row -// pins to its twin no matter how large or scattered the surrounding diff is. The greedy -// increasing scan is optimal for stable-ordered output (insert/delete preserve the relative -// order of survivors); reordered lines simply do not anchor. -func (a *Alignment) findAnchors(oLo, oHi, nLo, nHi int) []anchor { - oCount := make(map[string]int, oHi-oLo) - for i := oLo; i < oHi; i++ { - oCount[a.oldLines[i]]++ - } - nCount := make(map[string]int, nHi-nLo) - nFirst := make(map[string]int, nHi-nLo) - for j := nLo; j < nHi; j++ { - nl := a.newLines[j] - nCount[nl]++ - if _, ok := nFirst[nl]; !ok { - nFirst[nl] = j - } - } - var anchors []anchor - lastNi := nLo - 1 - for i := oLo; i < oHi; i++ { - line := a.oldLines[i] - if oCount[line] == 1 && nCount[line] == 1 { - if ni := nFirst[line]; ni > lastNi { - anchors = append(anchors, anchor{i, ni}) - lastNi = ni - } - } - } - return anchors -} - -// alignRange anchors on unchanged unique lines, then similarity-aligns the small gaps -// between them. Anchoring keeps each per-segment alignment tiny even for a huge, scattered -// diff, so the expensive DP never runs over the whole output and unchanged rows are never -// paired positionally with the wrong neighbour. -func (a *Alignment) alignRange(oLo, oHi, nLo, nHi int) []op { - anchors := a.findAnchors(oLo, oHi, nLo, nHi) - if len(anchors) == 0 { - return a.alignGap(oLo, oHi, nLo, nHi) - } - var ops []op - prevO, prevN := oLo, nLo - for _, an := range anchors { - ops = append(ops, a.alignGap(prevO, an.oi, prevN, an.ni)...) - ops = append(ops, op{opMatch, an.oi, an.ni}) - prevO, prevN = an.oi+1, an.ni+1 - } - ops = append(ops, a.alignGap(prevO, oHi, prevN, nHi)...) - return ops -} - -// alignGap aligns a gap between anchors by token similarity, maximizing total similarity (via -// lcs) so an exact match wins over a look-alike. Within a gap the deletes are emitted before -// the inserts, so a replace block lets Lines pair an old line with its new counterpart for a -// word-level diff. A pathologically large gap (no anchors at all, mostly-changed output) -// falls back to positional pairing, bounded by alignCellCap. -func (a *Alignment) alignGap(oLo, oHi, nLo, nHi int) []op { - r, c := oHi-oLo, nHi-nLo - if r == 0 && c == 0 { - return nil - } - if r*c > alignCellCap { - a.coarse = true - ops := make([]op, 0, r+c) - for i := oLo; i < oHi; i++ { - ops = append(ops, op{opDelete, i, -1}) - } - for j := nLo; j < nHi; j++ { - ops = append(ops, op{opInsert, -1, j}) - } - return ops - } - - oldSets := make([]map[string]struct{}, r) - for i := range oldSets { - oldSets[i] = tokenSet(a.oldLines[oLo+i]) - } - newSets := make([]map[string]struct{}, c) - for j := range newSets { - newSets[j] = tokenSet(a.newLines[nLo+j]) - } - pairs := lcs(r, c, func(i, j int) float64 { - if s := jaccard(oldSets[i], newSets[j]); s >= simThreshold { - return s // weight by similarity so an exact twin (1) beats a look-alike - } - return 0 - }) - - ops := make([]op, 0, r+c) - oi, nj := 0, 0 - emitGap := func(oEnd, nEnd int) { - for ; oi < oEnd; oi++ { - ops = append(ops, op{opDelete, oLo + oi, -1}) - } - for ; nj < nEnd; nj++ { - ops = append(ops, op{opInsert, -1, nLo + nj}) - } - } - for _, pr := range pairs { - emitGap(pr[0], pr[1]) - ops = append(ops, op{opMatch, oLo + pr[0], nLo + pr[1]}) - oi, nj = pr[0]+1, pr[1]+1 - } - emitGap(r, c) - return ops -} diff --git a/internal/diff/align_test.go b/internal/diff/align_test.go deleted file mode 100644 index 9037e87..0000000 --- a/internal/diff/align_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package diff - -import ( - "strings" - "testing" -) - -func text(lines ...string) string { return strings.Join(lines, "\n") } - -// changedSpans returns the texts of the Changed spans of a line, for concise assertions. -func changedSpans(spans []Span) []string { - var out []string - for _, s := range spans { - if s.Changed { - out = append(out, s.Text) - } - } - return out -} - -func TestSimilarity(t *testing.T) { - sim := func(a, b string) float64 { return jaccard(tokenSet(a), tokenSet(b)) } - exact := []struct { - name string - a, b string - want float64 - }{ - {"identical", "a b c", "a b c", 1}, - {"bothEmpty", "", "", 1}, - {"oneEmpty", "a b", "", 0}, - {"disjoint", "pod-1 Running", "svc-9 Pending", 0}, - } - for _, c := range exact { - if got := sim(c.a, c.b); got != c.want { - t.Errorf("%s: similarity=%v want %v", c.name, got, c.want) - } - } - - atLeast := []struct { - name string - a, b string - }{ - {"ageTick", "pod-1 1/1 Running 5m", "pod-1 1/1 Running 6m"}, - {"timestampTick", "2024-01-01 12:00:01 starting job", "2024-01-01 12:00:02 starting job"}, - } - for _, c := range atLeast { - if got := sim(c.a, c.b); got < simThreshold { - t.Errorf("%s: similarity=%v want >= %v", c.name, got, simThreshold) - } - } -} - -func TestMapLineIdentical(t *testing.T) { - s := text("a", "b", "c") - a := Align(s, s) - for i := 0; i < 3; i++ { - if got := a.MapLine(i); got != i { - t.Errorf("MapLine(%d)=%d want %d", i, got, i) - } - } -} - -func TestMapLinePrepend(t *testing.T) { - a := Align(text("b", "c", "d"), text("a", "b", "c", "d")) - for old, want := range map[int]int{0: 1, 1: 2, 2: 3} { - if got := a.MapLine(old); got != want { - t.Errorf("MapLine(%d)=%d want %d", old, got, want) - } - } -} - -func TestMapLineAppend(t *testing.T) { - a := Align(text("a", "b"), text("a", "b", "c")) - if got := a.MapLine(0); got != 0 { - t.Errorf("MapLine(0)=%d want 0", got) - } - if got := a.MapLine(1); got != 1 { - t.Errorf("MapLine(1)=%d want 1", got) - } -} - -func TestMapLineKubectlInsertWithAgeTick(t *testing.T) { - old := text( - "NAME READY STATUS AGE", - "pod-1 1/1 Running 5m", - "pod-2 1/1 Running 5m", - "pod-3 1/1 Running 5m", - ) - updated := text( - "NAME READY STATUS AGE", - "pod-0 1/1 Running 1s", - "pod-1 1/1 Running 6m", - "pod-2 1/1 Running 6m", - "pod-3 1/1 Running 6m", - ) - a := Align(old, updated) - // Anchored on pod-2 (old idx 2); despite the AGE tick and the inserted pod-0, - // it relocates to its new home at idx 3. - if got := a.MapLine(2); got != 3 { - t.Errorf("MapLine(2)=%d want 3", got) - } -} - -func TestMapLineAnchorDeleted(t *testing.T) { - a := Align(text("h", "p1", "p2", "p3"), text("h", "p1", "p3")) - // p2 (old idx 2) removed; anchor lands where it was (new idx 2, now p3) - no teleport. - if got := a.MapLine(2); got != 2 { - t.Errorf("MapLine(2)=%d want 2", got) - } -} - -func TestMapLinePastEnd(t *testing.T) { - a := Align(text("a", "b"), text("a", "b", "c")) - if got := a.MapLine(100); got != 3 { - t.Errorf("MapLine(100)=%d want 3", got) - } -} - -func TestMapLineCoarseScan(t *testing.T) { - a := Alignment{ - coarse: true, - oldLines: []string{"alpha 1", "bravo 2", "charlie 3"}, - newLines: []string{"zero 0", "alpha 1", "bravo 2", "charlie 3"}, - } - if got := a.MapLine(1); got != 2 { - t.Errorf("coarse MapLine(1)=%d want 2", got) - } -} - -func TestMapLineCoarseNotFound(t *testing.T) { - a := Alignment{ - coarse: true, - oldLines: []string{"unique-xyz abc"}, - newLines: []string{"totally different here"}, - } - if got := a.MapLine(0); got != 0 { - t.Errorf("coarse MapLine(0)=%d want 0 (keep offset)", got) - } -} - -func TestLinesPrependAddsOnlyNew(t *testing.T) { - lines := Align(text("a", "b", "c"), text("x", "a", "b", "c")).Lines() - want := []LineKind{LineAdded, LineEqual, LineEqual, LineEqual} - if len(lines) != len(want) { - t.Fatalf("got %d lines want %d", len(lines), len(want)) - } - for i, k := range want { - if lines[i].Kind != k { - t.Errorf("line %d kind=%d want %d", i, lines[i].Kind, k) - } - } - if lines[0].Text != "x" { - t.Errorf("added line=%q want %q", lines[0].Text, "x") - } -} - -func TestLinesInPlaceWordChange(t *testing.T) { - lines := Align("count 5", "count 6").Lines() - if len(lines) != 1 || lines[0].Kind != LineChanged { - t.Fatalf("got %+v want one LineChanged", lines) - } - if got := changedSpans(lines[0].Spans); len(got) != 1 || got[0] != "6" { - t.Errorf("changed spans=%v want [6]", got) - } -} - -func TestLinesDeletionDropped(t *testing.T) { - lines := Align(text("a", "b", "c"), text("a", "c")).Lines() - if len(lines) != 2 { - t.Fatalf("got %d lines want 2 (b dropped)", len(lines)) - } - if lines[0].Text != "a" || lines[1].Text != "c" { - t.Errorf("texts=%q,%q want a,c", lines[0].Text, lines[1].Text) - } - for _, ln := range lines { - if ln.Kind != LineEqual { - t.Errorf("line %q kind=%d want LineEqual", ln.Text, ln.Kind) - } - } -} - -func TestLinesCountEqualsNewLines(t *testing.T) { - updated := text("a", "X", "Y", "c", "d", "e") - lines := Align(text("a", "b", "c", "d"), updated).Lines() - if want := strings.Count(updated, "\n") + 1; len(lines) != want { - t.Errorf("Lines()=%d want %d", len(lines), want) - } -} diff --git a/internal/diff/diff.go b/internal/diff/diff.go deleted file mode 100644 index a61baf1..0000000 --- a/internal/diff/diff.go +++ /dev/null @@ -1,53 +0,0 @@ -package diff - -// LineKind classifies a new-output line in the diff. -type LineKind uint8 - -const ( - LineEqual LineKind = iota // unchanged from the old snapshot - LineChanged // same row, some tokens changed (see Spans) - LineAdded // no counterpart in the old snapshot -) - -// Line is the diff of one new-output line. For LineChanged, Spans is the word-level -// breakdown (concatenating back to Text); for LineEqual and LineAdded it is nil. Deleted old -// lines are not represented - the diff describes the new snapshot. -type Line struct { - Kind LineKind - Text string // the new-output line - OldIndex int // matched old-output line index, or -1 - Spans []Span -} - -// Lines returns the structured diff of the new output: one Line per new-output line, in -// order. Callers render it however they like (Equal plain, Added whole-line highlighted, -// Changed with its Changed spans highlighted). Output length equals the number of new lines. -func (a Alignment) Lines() []Line { - lines := make([]Line, 0, len(a.newLines)) - var pendDel []int // deletes awaiting an insert to pair with (an in-place replace) - di := 0 - for _, o := range a.ops { - switch o.kind { - case opDelete: - pendDel = append(pendDel, o.oldIdx) - case opMatch: - pendDel, di = pendDel[:0], 0 // unpaired deletes are dropped - nl := a.newLines[o.newIdx] - if a.oldLines[o.oldIdx] == nl { - lines = append(lines, Line{Kind: LineEqual, Text: nl, OldIndex: o.oldIdx}) - } else { - lines = append(lines, Line{Kind: LineChanged, Text: nl, OldIndex: o.oldIdx, Spans: WordDiff(a.oldLines[o.oldIdx], nl)}) - } - case opInsert: - nl := a.newLines[o.newIdx] - if di < len(pendDel) { - oldIdx := pendDel[di] - di++ - lines = append(lines, Line{Kind: LineChanged, Text: nl, OldIndex: oldIdx, Spans: WordDiff(a.oldLines[oldIdx], nl)}) - } else { - lines = append(lines, Line{Kind: LineAdded, Text: nl, OldIndex: -1}) - } - } - } - return lines -} diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go deleted file mode 100644 index 8890d25..0000000 --- a/internal/diff/diff_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package diff - -import ( - "fmt" - "reflect" - "strings" - "testing" -) - -func TestLCS(t *testing.T) { - eq := func(a, b []string) func(i, j int) float64 { - return func(i, j int) float64 { - if a[i] == b[j] { - return 1 - } - return 0 - } - } - cases := []struct { - name string - a, b []string - want [][2]int - }{ - {"bothEmpty", nil, nil, [][2]int{}}, - {"oldEmpty", nil, []string{"a", "b"}, [][2]int{}}, - {"newEmpty", []string{"a", "b"}, nil, [][2]int{}}, - {"identity", []string{"x", "y", "z"}, []string{"x", "y", "z"}, [][2]int{{0, 0}, {1, 1}, {2, 2}}}, - {"disjoint", []string{"a", "b"}, []string{"c", "d"}, [][2]int{}}, - {"interior", []string{"x", "M", "y"}, []string{"p", "M", "q"}, [][2]int{{1, 1}}}, - {"prepend", []string{"b", "c"}, []string{"a", "b", "c"}, [][2]int{{0, 1}, {1, 2}}}, - } - for _, c := range cases { - got := lcs(len(c.a), len(c.b), eq(c.a, c.b)) - if !reflect.DeepEqual(got, c.want) { - t.Errorf("%s: lcs=%v want %v", c.name, got, c.want) - } - } -} - -func TestTokenize(t *testing.T) { - cases := []struct { - in string - want []string - }{ - {"1/1", []string{"1", "/", "1"}}, - {"a b", []string{"a", " ", "b"}}, - {"pod-1 1/1 Running 5m", []string{"pod", "-", "1", " ", "1", "/", "1", " ", "Running", " ", "5m"}}, - {"café", []string{"café"}}, - {"", nil}, - } - for _, c := range cases { - if got := tokenize(c.in); !reflect.DeepEqual(got, c.want) { - t.Errorf("tokenize(%q)=%v want %v", c.in, got, c.want) - } - } -} - -func TestWordDiff(t *testing.T) { - cases := []struct { - name string - old, new string - want []string // texts of the Changed spans - }{ - {"wholeWordReplace", "icecream", "beer", []string{"beer"}}, - {"wholeWordReplaceLong", "Running", "Pending", []string{"Pending"}}, - {"subTokenPunct", "1/1", "1/2", []string{"2"}}, - {"ageTick", "pod-1 1/1 Running 5m", "pod-1 1/1 Running 6m", []string{"6m"}}, - {"whitespaceOnly", "a b", "a b", nil}, // shifted padding is not a highlightable change - {"identical", "abc", "abc", nil}, - } - for _, c := range cases { - spans := WordDiff(c.old, c.new) - if got := changedSpans(spans); !reflect.DeepEqual(got, c.want) { - t.Errorf("%s: changed spans=%v want %v", c.name, got, c.want) - } - // Spans must reconstruct the new line exactly (incl. spacing). - var b strings.Builder - for _, s := range spans { - b.WriteString(s.Text) - } - if b.String() != c.new { - t.Errorf("%s: spans concat=%q want %q", c.name, b.String(), c.new) - } - } -} - -// The headline bug: character LCS used to keep the shared 'ee' plain. Word-level must mark -// the whole replaced word as one changed span - no plain fragment left behind. -func TestWordDiffNoOrphanChars(t *testing.T) { - spans := WordDiff("icecream", "beer") - if len(spans) != 1 || !spans[0].Changed || spans[0].Text != "beer" { - t.Errorf("got %+v want one changed span \"beer\"", spans) - } -} - -// An inserted duplicate token must mark the inserted (second) instance, not the unchanged -// earlier one. "a b" -> "a a b" tokenizes to [a, " ", a, " ", b]; index 2 is the inserted "a". -func TestTokenDiffDuplicateInsertion(t *testing.T) { - spans := tokenDiff(tokenize("a b"), tokenize("a a b")) - if spans[0].Changed { - t.Errorf("first 'a' (unchanged) must not be Changed") - } - if !spans[2].Changed { - t.Errorf("inserted second 'a' must be Changed") - } - if spans[4].Changed { - t.Errorf("trailing 'b' (unchanged) must not be Changed") - } -} - -// Churn among similar rows: a pod is removed and another added (same age), with one pod -// unchanged. The rows share "1/1 Running 3d" so they are >0.5 similar; the alignment must -// pair the unchanged pod with its exact twin (not a weakly-similar neighbour), so it stays -// plain rather than being word-diffed against an unrelated line. -func TestRenderChurnKeepsUnchangedRowsPlain(t *testing.T) { - old := text( - "pod-a 1/1 Running 5m", - "pod-b 1/1 Running 3d", - ) - updated := text( - "pod-b 1/1 Running 3d", // unchanged, shifted up (pod-a removed) - "pod-c 1/1 Running 3d", // new pod, same age - ) - lines := Align(old, updated).Lines() - if lines[0].Kind != LineEqual || lines[0].Text != "pod-b 1/1 Running 3d" { - t.Errorf("unchanged pod-b must be LineEqual, got kind=%d text=%q", lines[0].Kind, lines[0].Text) - } -} - -// Regression for the coarse false-diff bug: with enough scattered changes that a -// whole-middle DP would exceed alignCellCap, anchoring on the unchanged unique rows must -// keep them plain instead of falling back to positional pairing (which mis-highlighted ~800 -// unchanged rows on a real 1900-line `kubectl get pods -A`). -func TestRenderLargeScatteredNoFalseDiffs(t *testing.T) { - const n = 1600 // n*n > alignCellCap, so a single-segment DP would go coarse - oldRows := make([]string, n) - newRows := make([]string, n) - for i := range oldRows { - row := fmt.Sprintf("pod-%04d 1/1 Running 9d", i) - oldRows[i] = row - newRows[i] = row - } - // Scatter changes at top and bottom (forcing a wide middle) plus a mid insertion. - newRows[0] = "pod-0000 1/1 Running 10d" - newRows[n-1] = fmt.Sprintf("pod-%04d 1/1 Running 10d", n-1) - newRows = append(newRows[:800:800], append([]string{"pod-NEWX 0/1 Pending 1s"}, newRows[800:]...)...) - - lines := Align(strings.Join(oldRows, "\n"), strings.Join(newRows, "\n")).Lines() - kind := make(map[string]LineKind, len(lines)) - for _, ln := range lines { - kind[ln.Text] = ln.Kind - } - for i := 1; i < n-1; i++ { // rows 1..n-2 are unchanged and must stay LineEqual - row := fmt.Sprintf("pod-%04d 1/1 Running 9d", i) - if kind[row] != LineEqual { - t.Fatalf("unchanged row %q kind=%d want LineEqual", row, kind[row]) - } - } -} - -// The primary case: a new row inserts (sorted into place) while neighbouring rows' volatile -// AGE field ticks. The similarity alignment must still pair each shifted row with its old -// self so only the age token highlights, and the new row is fully highlighted. -func TestRenderInsertWithVolatileFields(t *testing.T) { - old := text( - "NAME READY STATUS AGE", - "pod-1 1/1 Running 5m", - "pod-2 1/1 Running 5m", - ) - updated := text( - "NAME READY STATUS AGE", - "pod-0 1/1 Running 1s", // new pod, sorts to top - "pod-1 1/1 Running 6m", // shifted down, AGE ticked - "pod-2 1/1 Running 6m", - ) - lines := Align(old, updated).Lines() - if len(lines) != 4 { - t.Fatalf("got %d lines want 4", len(lines)) - } - if lines[0].Kind != LineEqual { - t.Errorf("header kind=%d want LineEqual", lines[0].Kind) - } - if lines[1].Kind != LineAdded || lines[1].Text != "pod-0 1/1 Running 1s" { - t.Errorf("pod-0 must be LineAdded, got kind=%d text=%q", lines[1].Kind, lines[1].Text) - } - for _, i := range []int{2, 3} { // pod-1, pod-2: only the age token changed - if lines[i].Kind != LineChanged { - t.Errorf("line %d kind=%d want LineChanged", i, lines[i].Kind) - continue - } - if got := changedSpans(lines[i].Spans); len(got) != 1 || got[0] != "6m" { - t.Errorf("line %d changed spans=%v want [6m]", i, got) - } - } -} diff --git a/internal/diff/lcs.go b/internal/diff/lcs.go deleted file mode 100644 index 27eb478..0000000 --- a/internal/diff/lcs.go +++ /dev/null @@ -1,45 +0,0 @@ -package diff - -import "slices" - -// lcs returns matched index pairs {i, j} in increasing order: an order-preserving set of -// correspondences that maximizes the total weight, where weight(i, j) > 0 means i and j may -// be matched (and how strongly) and weight <= 0 means they may not. The caller decides how -// to treat the gaps between matched pairs (deletes on the old side, inserts on the new). -// -// Maximizing weight rather than match count is what makes an exact counterpart (weight 1) -// win over a merely-similar one (weight < 1): an unchanged row is always paired with its -// identical twin instead of a look-alike neighbour, so it is never spuriously highlighted. -func lcs(r, c int, weight func(i, j int) float64) [][2]int { - dp := make([][]float64, r+1) - for i := range dp { - dp[i] = make([]float64, c+1) - } - for i := 1; i <= r; i++ { - for j := 1; j <= c; j++ { - best := max(dp[i-1][j], dp[i][j-1]) - if w := weight(i-1, j-1); w > 0 { - if diag := dp[i-1][j-1] + w; diag > best { - best = diag - } - } - dp[i][j] = best - } - } - - pairs := make([][2]int, 0, min(r, c)) - for i, j := r, c; i > 0 && j > 0; { - switch w := weight(i-1, j-1); { - case w > 0 && dp[i][j] == dp[i-1][j-1]+w: - pairs = append(pairs, [2]int{i - 1, j - 1}) - i-- - j-- - case dp[i-1][j] >= dp[i][j-1]: - i-- - default: - j-- - } - } - slices.Reverse(pairs) - return pairs -} diff --git a/internal/diff/mapline.go b/internal/diff/mapline.go deleted file mode 100644 index 74f7bad..0000000 --- a/internal/diff/mapline.go +++ /dev/null @@ -1,56 +0,0 @@ -package diff - -// MapLine returns the new-output line index corresponding to old-output line idx, so a -// caller can keep a scroll or cursor position anchored to the same content across a refresh. -// A matched line maps to its counterpart; a deleted line maps to the position where it was; -// an unrelocatable line (coarse fallback, nothing similar) returns idx unchanged. -func (a Alignment) MapLine(idx int) int { - if idx < 0 { - return idx - } - if a.coarse { - return a.mapLineByScan(idx) - } - curNew := 0 - for _, o := range a.ops { - switch o.kind { - case opMatch: - if o.oldIdx == idx { - return o.newIdx - } - curNew++ - case opInsert: - curNew++ - case opDelete: - if o.oldIdx == idx { - return curNew - } - } - } - return curNew -} - -// mapLineByScan is MapLine's coarse fallback: relocate a single old line by best token -// similarity, biased toward its old index, returning idx unchanged when nothing clears the -// threshold. -func (a Alignment) mapLineByScan(idx int) int { - if idx >= len(a.oldLines) { - return min(idx, len(a.newLines)) - } - target := tokenSet(a.oldLines[idx]) - best := -1 - bestScore := 0.0 - for j, nl := range a.newLines { - score := jaccard(target, tokenSet(nl)) - if score < simThreshold { - continue - } - if best == -1 || score > bestScore || (score == bestScore && max(j-idx, idx-j) < max(best-idx, idx-best)) { - best, bestScore = j, score - } - } - if best == -1 { - return idx - } - return best -} diff --git a/internal/diff/similarity.go b/internal/diff/similarity.go deleted file mode 100644 index 188b1e8..0000000 --- a/internal/diff/similarity.go +++ /dev/null @@ -1,35 +0,0 @@ -package diff - -import "strings" - -// tokenSet returns the set of whitespace-separated tokens in line. This coarse, set-based -// tokenization is the basis for row identity: two rows are "the same" when their token sets -// overlap enough (jaccard), so an identifier token (e.g. a pod NAME) keeps a row matched to -// its old self even as volatile fields change. It is deliberately coarser than the word -// tokenizer used for highlighting (token.go). -func tokenSet(line string) map[string]struct{} { - fields := strings.Fields(line) - set := make(map[string]struct{}, len(fields)) - for _, f := range fields { - set[f] = struct{}{} - } - return set -} - -// jaccard is the overlap of two token sets, |A∩B| / |A∪B|. -func jaccard(a, b map[string]struct{}) float64 { - if len(a) == 0 && len(b) == 0 { - return 1 // two empty lines are identical - } - if len(a) == 0 || len(b) == 0 { - return 0 // one empty, one not: no overlap - } - inter := 0 - for t := range a { - if _, ok := b[t]; ok { - inter++ - } - } - union := len(a) + len(b) - inter - return float64(inter) / float64(union) -} diff --git a/internal/diff/token.go b/internal/diff/token.go deleted file mode 100644 index 9471b18..0000000 --- a/internal/diff/token.go +++ /dev/null @@ -1,39 +0,0 @@ -package diff - -import "unicode" - -// tokenize splits s into maximal word-runs, maximal whitespace-runs, and each remaining -// rune as its own token (GitHub's \w+|\s+|[^\w\s], unicode-aware). This fine, ordered -// tokenization drives word-level highlighting (worddiff.go); it is deliberately finer than -// the whitespace token sets used for row identity (similarity.go), which must stay coarse so -// shared punctuation does not blur unrelated rows together. -func tokenize(s string) []string { - var tokens []string - runes := []rune(s) - for i := 0; i < len(runes); { - switch r := runes[i]; { - case isWord(r): - j := i + 1 - for j < len(runes) && isWord(runes[j]) { - j++ - } - tokens = append(tokens, string(runes[i:j])) - i = j - case unicode.IsSpace(r): - j := i + 1 - for j < len(runes) && unicode.IsSpace(runes[j]) { - j++ - } - tokens = append(tokens, string(runes[i:j])) - i = j - default: - tokens = append(tokens, string(r)) - i++ - } - } - return tokens -} - -func isWord(r rune) bool { - return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r) -} diff --git a/internal/diff/worddiff.go b/internal/diff/worddiff.go deleted file mode 100644 index 6fe1daa..0000000 --- a/internal/diff/worddiff.go +++ /dev/null @@ -1,56 +0,0 @@ -package diff - -import "strings" - -// Span is a run of text within a line together with whether it is a meaningful change worth -// highlighting. Whitespace-only spans are never marked Changed (so shifting column padding -// does not light up), even when they differ. -type Span struct { - Text string - Changed bool -} - -// WordDiff breaks newLine into spans against oldLine: a token with no counterpart in oldLine -// (and that is not whitespace-only) is marked Changed. newLine's exact text is preserved, so -// the spans concatenate back to it. A pathologically long line falls back to a single -// changed span. -func WordDiff(oldLine, newLine string) []Span { - o, n := tokenize(oldLine), tokenize(newLine) - if len(o)*len(n) > alignCellCap { - return []Span{{Text: newLine, Changed: true}} - } - return tokenDiff(o, n) -} - -// tokenDiff returns the new tokens as spans. Byte-identical prefix and suffix tokens are -// peeled before the LCS so that an inserted duplicate marks the inserted instance rather -// than an unchanged earlier one (e.g. "a b" -> "a a b" marks the second "a"). -func tokenDiff(old, new []string) []Span { - n, m := len(old), len(new) - p := 0 - for p < n && p < m && old[p] == new[p] { - p++ - } - s := 0 - for s < n-p && s < m-p && old[n-1-s] == new[m-1-s] { - s++ - } - - pairs := lcs(n-s-p, m-s-p, func(i, j int) float64 { - if old[p+i] == new[p+j] { - return 1 - } - return 0 - }) - matchedMid := make([]bool, m-s-p) - for _, pr := range pairs { - matchedMid[pr[1]] = true - } - - spans := make([]Span, m) - for j, t := range new { - changed := j >= p && j < m-s && !matchedMid[j-p] && strings.TrimSpace(t) != "" - spans[j] = Span{Text: t, Changed: changed} - } - return spans -} diff --git a/internal/pathutil/pathutil.go b/internal/pathutil/pathutil.go deleted file mode 100644 index 12fc132..0000000 --- a/internal/pathutil/pathutil.go +++ /dev/null @@ -1,26 +0,0 @@ -// Package pathutil holds small filesystem-path helpers shared between the CLI and the TUI. -package pathutil - -import ( - "os" - "path/filepath" - "strings" -) - -// ExpandTilde replaces a leading "~" or "~/" with the user's home directory. Other forms -// (including "~user/...") pass through unchanged. The original input is returned alongside -// the error on home-lookup failure so callers can choose between aborting and proceeding -// with the literal path. -func ExpandTilde(p string) (string, error) { - if p != "~" && !strings.HasPrefix(p, "~/") { - return p, nil - } - home, err := os.UserHomeDir() - if err != nil { - return p, err - } - if p == "~" { - return home, nil - } - return filepath.Join(home, p[2:]), nil -} diff --git a/internal/pathutil/pathutil_test.go b/internal/pathutil/pathutil_test.go deleted file mode 100644 index 1379c09..0000000 --- a/internal/pathutil/pathutil_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package pathutil - -import ( - "os" - "path/filepath" - "testing" -) - -func TestExpandTilde(t *testing.T) { - home, err := os.UserHomeDir() - if err != nil { - t.Skipf("os.UserHomeDir not available: %v", err) - } - - cases := []struct { - in, want string - }{ - {"~", home}, - {"~/foo/bar", filepath.Join(home, "foo/bar")}, - {"~user/foo", "~user/foo"}, - {"/abs/path", "/abs/path"}, - {"relative/path", "relative/path"}, - {"$VAR/foo", "$VAR/foo"}, - {"", ""}, - } - for _, c := range cases { - got, err := ExpandTilde(c.in) - if err != nil { - t.Errorf("ExpandTilde(%q) returned error: %v", c.in, err) - continue - } - if got != c.want { - t.Errorf("ExpandTilde(%q) = %q, want %q", c.in, got, c.want) - } - } -} diff --git a/internal/recording/flow.go b/internal/recording/flow.go deleted file mode 100644 index 18662bd..0000000 --- a/internal/recording/flow.go +++ /dev/null @@ -1,141 +0,0 @@ -package recording - -import ( - "errors" - "fmt" - "os" - "strings" - "time" - - "github.com/ivoronin/wch/internal/pathutil" - "github.com/ivoronin/wch/internal/session" -) - -// ErrPathExists is returned by PreflightCheck (and by Flow.Start, wrapping the underlying -// os.ErrExist from the recorder factory) when a recording target path already exists. -// Detect with errors.Is. -var ErrPathExists = errors.New("recording: path exists") - -// AutoStartRequest signals a desire to begin recording immediately on TUI startup -- the -// typed alternative to the empty-string-means-no convention. nil means no request. -// Built by the CLI from the validated `-w` flag value; passed through tui.Config; the TUI -// fires a deferred message at Init time that calls Flow.Start(Path). -type AutoStartRequest struct { - Path string -} - -// maxSanitizedCommandLen caps the command-derived portion of a default recording filename -// so the full name (command + timestamp + extension) stays well under typical FS limits. -const maxSanitizedCommandLen = 80 - -// NormalizePath trims input whitespace, expands a leading "~" or "~/" via the user's -// home directory, and rejects empty or whitespace-only input. The only string-shape -// canonicalization recording does -- no Clean, no Abs. -func NormalizePath(input string) (string, error) { - trimmed := strings.TrimSpace(input) - expanded, err := pathutil.ExpandTilde(trimmed) - if err != nil { - return "", err - } - if expanded == "" { - return "", errors.New("recording: empty path") - } - return expanded, nil -} - -// PreflightCheck reports whether a recording can be created at path. Today the only -// failure mode is ErrPathExists -- JSONLRecorder's O_EXCL open is the atomic guard, -// but the CLI uses PreflightCheck to refuse early (before launching the TUI) with a -// clear stderr message. Returns nil when the path is available. -func PreflightCheck(path string) error { - if _, err := os.Stat(path); err == nil { - return fmt.Errorf("%w: %s", ErrPathExists, path) - } - return nil -} - -// DefaultFilename builds a CWD-relative filename from the watched command and a moment -// in time, e.g. "kubectl_get_pods_A_20260530-153045.wch.jsonl". -func DefaultFilename(command string, now time.Time) string { - base := sanitizeCommand(command) - if base == "" { - base = "wch" - } - return base + "_" + now.Format("20060102-150405") + ".wch.jsonl" -} - -// sanitizeCommand collapses runs of non-alphanumerics into a single underscore, trims -// leading/trailing underscores, and caps the result at maxSanitizedCommandLen. -func sanitizeCommand(s string) string { - var b strings.Builder - prevUnderscore := false - for _, r := range s { - if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - b.WriteRune(r) - prevUnderscore = false - continue - } - if !prevUnderscore { - b.WriteByte('_') - prevUnderscore = true - } - } - out := strings.Trim(b.String(), "_") - if len(out) > maxSanitizedCommandLen { - out = strings.TrimRight(out[:maxSanitizedCommandLen], "_") - } - return out -} - -// Flow is the single entry point CLI and TUI use to drive a recording. It owns adapter -// construction and error classification; the underlying Session keeps the active Recorder -// and writes frames through RecordIfChanged. -// -// The factory field is unexported and accessible only to in-package tests via newFlowWith -// -- production callers use New, which wires the JSONLRecorder factory. -type Flow struct { - session *session.Session - factory func(path string) (session.Recorder, error) -} - -// New constructs a Flow that opens JSONL recordings on disk. -func New(s *session.Session) *Flow { - return &Flow{ - session: s, - factory: func(path string) (session.Recorder, error) { return NewJSONLRecorder(path) }, - } -} - -// Start opens a recorder at path and arms the session. Returns an error classified via -// errors.Is(err, ErrPathExists) for the "file exists" case. The IsActive guard precedes -// the factory call so an already-active flow never creates an orphan file that would -// then block a future Start with os.ErrExist. -func (f *Flow) Start(path string) error { - if f.IsActive() { - return errors.New("recording: already in progress") - } - rec, err := f.factory(path) - if err != nil { - if errors.Is(err, os.ErrExist) { - return fmt.Errorf("%w: %s", ErrPathExists, path) - } - return err - } - if err := f.session.StartRecording(rec); err != nil { - _ = rec.Close() - return err - } - return nil -} - -// Stop closes the active recording, if any. Idempotent -- safe to call when nothing is -// recording. -func (f *Flow) Stop() error { - return f.session.StopRecording() -} - -// IsActive reports whether a recording is currently in progress. Reads through to the -// session so it stays correct after RecordIfChanged auto-finalizes on a write error. -func (f *Flow) IsActive() bool { - return f.session.IsRecording() -} diff --git a/internal/recording/flow_test.go b/internal/recording/flow_test.go deleted file mode 100644 index 692eeda..0000000 --- a/internal/recording/flow_test.go +++ /dev/null @@ -1,205 +0,0 @@ -package recording - -import ( - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/ivoronin/wch/internal/session" -) - -func TestNormalizePathRejectsEmpty(t *testing.T) { - if _, err := NormalizePath(""); err == nil { - t.Errorf("empty input should error") - } - if _, err := NormalizePath(" "); err == nil { - t.Errorf("whitespace-only input should error") - } -} - -func TestNormalizePathTrimsAndPassesThrough(t *testing.T) { - got, err := NormalizePath(" /tmp/foo.jsonl ") - if err != nil { - t.Fatalf("unexpected err: %v", err) - } - if got != "/tmp/foo.jsonl" { - t.Errorf("got %q want %q", got, "/tmp/foo.jsonl") - } -} - -func TestNormalizePathExpandsTilde(t *testing.T) { - home, err := os.UserHomeDir() - if err != nil { - t.Skip("no home directory") - } - got, err := NormalizePath("~/recordings/x.jsonl") - if err != nil { - t.Fatalf("unexpected err: %v", err) - } - want := filepath.Join(home, "recordings/x.jsonl") - if got != want { - t.Errorf("got %q want %q", got, want) - } -} - -func TestPreflightCheckMissingFileOK(t *testing.T) { - path := filepath.Join(t.TempDir(), "absent.jsonl") - if err := PreflightCheck(path); err != nil { - t.Errorf("missing file should pass: %v", err) - } -} - -func TestPreflightCheckExistingFileFails(t *testing.T) { - path := filepath.Join(t.TempDir(), "present.jsonl") - if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - err := PreflightCheck(path) - if err == nil { - t.Fatal("existing file should fail PreflightCheck") - } - if !errors.Is(err, ErrPathExists) { - t.Errorf("err = %v want ErrPathExists", err) - } - if !strings.Contains(err.Error(), path) { - t.Errorf("err message should include path; got %q", err.Error()) - } -} - -func TestSanitizeCommand(t *testing.T) { - cases := []struct { - in, want string - }{ - {"kubectl get pods -A", "kubectl_get_pods_A"}, - {"", ""}, - {" ", ""}, - {"---a---", "a"}, - {"cat /tmp/foo | grep bar", "cat_tmp_foo_grep_bar"}, - {"df -h | awk '{print $1}'", "df_h_awk_print_1"}, - {"top", "top"}, - // Runs of non-alphanumerics collapse to a single underscore. - {"a b\t\tc", "a_b_c"}, - } - for _, c := range cases { - got := sanitizeCommand(c.in) - if got != c.want { - t.Errorf("sanitizeCommand(%q) = %q, want %q", c.in, got, c.want) - } - } - - long := strings.Repeat("a", 200) - got := sanitizeCommand(long) - if len(got) != maxSanitizedCommandLen { - t.Errorf("sanitizeCommand(200×'a') length = %d, want %d", len(got), maxSanitizedCommandLen) - } - if strings.HasSuffix(got, "_") { - t.Errorf("sanitizeCommand of long alpha left a trailing underscore: %q", got) - } - - // Truncated tail that lands on a run of separators should also drop trailing underscores. - mix := strings.Repeat("a", maxSanitizedCommandLen-2) + "--tail" - got = sanitizeCommand(mix) - if strings.HasSuffix(got, "_") { - t.Errorf("sanitizeCommand(%q) left trailing underscore: %q", mix, got) - } -} - -func TestDefaultFilename(t *testing.T) { - when := time.Date(2026, 5, 30, 15, 30, 45, 0, time.Local) - - got := DefaultFilename("kubectl get pods -A", when) - want := "kubectl_get_pods_A_20260530-153045.wch.jsonl" - if got != want { - t.Errorf("DefaultFilename(kubectl, ...) = %q, want %q", got, want) - } - - got = DefaultFilename("", when) - want = "wch_20260530-153045.wch.jsonl" - if got != want { - t.Errorf("DefaultFilename(empty, ...) = %q, want %q", got, want) - } -} - -// newFlowWith injects a recorder factory so Flow.Start can be exercised against an -// InMemoryRecorder rather than the on-disk JSONL one. -func newFlowWith(s *session.Session, factory func(path string) (session.Recorder, error)) *Flow { - f := New(s) - f.factory = factory - return f -} - -func TestFlowStartSuccessOnInMemory(t *testing.T) { - s := session.NewSession("cmd", time.Second) - rec := NewInMemoryRecorder() - flow := newFlowWith(s, func(path string) (session.Recorder, error) { return rec, nil }) - - if err := flow.Start("/tmp/anything.jsonl"); err != nil { - t.Fatalf("Start err = %v", err) - } - if !flow.IsActive() { - t.Errorf("IsActive() should be true") - } - if rec.Header().Command != "cmd" { - t.Errorf("recorder Header.Command=%q want cmd", rec.Header().Command) - } -} - -func TestFlowStartFileExists(t *testing.T) { - s := session.NewSession("cmd", time.Second) - flow := newFlowWith(s, func(path string) (session.Recorder, error) { - return nil, os.ErrExist - }) - err := flow.Start("/tmp/existing.jsonl") - if !errors.Is(err, ErrPathExists) { - t.Errorf("err=%v want wraps ErrPathExists", err) - } - if flow.IsActive() { - t.Errorf("IsActive() should be false after ErrPathExists") - } -} - -func TestFlowStartIOError(t *testing.T) { - s := session.NewSession("cmd", time.Second) - boom := errors.New("disk explode") - flow := newFlowWith(s, func(path string) (session.Recorder, error) { return nil, boom }) - err := flow.Start("/tmp/err.jsonl") - if !errors.Is(err, boom) { - t.Errorf("err=%v want wraps boom", err) - } - if errors.Is(err, ErrPathExists) { - t.Errorf("err should not match ErrPathExists") - } -} - -func TestFlowStopIdempotent(t *testing.T) { - s := session.NewSession("cmd", time.Second) - rec := NewInMemoryRecorder() - flow := newFlowWith(s, func(path string) (session.Recorder, error) { return rec, nil }) - if err := flow.Start("/tmp/x.jsonl"); err != nil { - t.Fatalf("Start: %v", err) - } - if err := flow.Stop(); err != nil { - t.Errorf("first Stop: %v", err) - } - if err := flow.Stop(); err != nil { - t.Errorf("second Stop: %v", err) - } - if flow.IsActive() { - t.Errorf("IsActive() should be false after Stop") - } -} - -func TestFlowStartDefaultFactoryIsJSONL(t *testing.T) { - s := session.NewSession("cmd", time.Second) - flow := New(s) - path := filepath.Join(t.TempDir(), "y.jsonl") - if err := flow.Start(path); err != nil { - t.Fatalf("Start err = %v", err) - } - if err := flow.Stop(); err != nil { - t.Fatal(err) - } -} diff --git a/internal/recording/inmem.go b/internal/recording/inmem.go deleted file mode 100644 index 596201e..0000000 --- a/internal/recording/inmem.go +++ /dev/null @@ -1,96 +0,0 @@ -package recording - -import ( - "errors" - "slices" - "time" - - "github.com/ivoronin/wch/internal/session" -) - -// ErrInjectedWriteFailure is the error InMemoryRecorder returns from WriteFrame after -// the count configured via FailWriteAfter has been exhausted. Use errors.Is to detect. -var ErrInjectedWriteFailure = errors.New("recording: injected write failure") - -// InMemoryRecorder is a session.Recorder that retains every header + frame in memory. -// Useful for tests (in this package and in session tests) and any in-process caller -// that wants to inspect recording behavior without touching the filesystem. -type InMemoryRecorder struct { - header Header - frames []Frame - closed bool - writesBefore int // FailWriteAfter target; -1 = never fail - writesDone int -} - -// NewInMemoryRecorder returns an empty in-memory recorder. Initialize must be called -// before WriteFrame; the zero state is intentionally invalid to surface misuse. -func NewInMemoryRecorder() *InMemoryRecorder { - return &InMemoryRecorder{writesBefore: -1} -} - -// FailWriteAfter arms the recorder to return ErrInjectedWriteFailure from the -// (n+1)-th WriteFrame call onward. Used by session.RecordIfChanged auto-finalize -// tests. Call before Initialize. -func (r *InMemoryRecorder) FailWriteAfter(n int) { - r.writesBefore = n -} - -// Initialize records the header (Command, Interval), resets the in-memory frame slice, -// and seeds it with the supplied backlog. The wch JSONL format is described by -// Header.Format/Version, so those are filled in from the package-level constants. -func (r *InMemoryRecorder) Initialize(command string, interval time.Duration, backlog []session.Execution) error { - r.header = Header{ - Format: FormatTag, - Version: SupportedVersion, - Command: command, - Interval: interval.String(), - } - r.frames = r.frames[:0] - for _, e := range backlog { - r.frames = append(r.frames, frameFrom(e)) - } - return nil -} - -// WriteFrame appends a frame to the in-memory log, honoring FailWriteAfter if set. -func (r *InMemoryRecorder) WriteFrame(exec session.Execution) error { - if r.writesBefore >= 0 && r.writesDone >= r.writesBefore { - return ErrInjectedWriteFailure - } - r.writesDone++ - r.frames = append(r.frames, frameFrom(exec)) - return nil -} - -// Close marks the recorder closed. Idempotent. -func (r *InMemoryRecorder) Close() error { - r.closed = true - return nil -} - -// InMemoryHeader is the inspected view returned by Header(). Only the parsed Interval -// and Command are exposed; Format and Version are wch-internal constants. -type InMemoryHeader struct { - Command string - Interval time.Duration -} - -// Header returns the header recorded at Initialize time. -func (r *InMemoryRecorder) Header() InMemoryHeader { - d, _ := time.ParseDuration(r.header.Interval) - return InMemoryHeader{Command: r.header.Command, Interval: d} -} - -// Frames returns a defensive copy of every frame captured (backlog + WriteFrame). -func (r *InMemoryRecorder) Frames() []Frame { - return slices.Clone(r.frames) -} - -// Closed reports whether Close has been called. -func (r *InMemoryRecorder) Closed() bool { - return r.closed -} - -// Compile-time assertion: InMemoryRecorder must satisfy session.Recorder. -var _ session.Recorder = (*InMemoryRecorder)(nil) diff --git a/internal/recording/inmem_test.go b/internal/recording/inmem_test.go deleted file mode 100644 index a20d98f..0000000 --- a/internal/recording/inmem_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package recording - -import ( - "errors" - "testing" - "time" - - "github.com/ivoronin/wch/internal/session" -) - -func TestInMemoryRecorderInitializeStoresBacklog(t *testing.T) { - r := NewInMemoryRecorder() - backlog := []session.Execution{ - {Stdout: "a\n"}, - {Stdout: "b\n"}, - } - if err := r.Initialize("cmd", time.Second, backlog); err != nil { - t.Fatalf("Initialize: %v", err) - } - if got := r.Frames(); len(got) != 2 || got[0].Stdout != "a\n" || got[1].Stdout != "b\n" { - t.Errorf("Frames after Initialize: %+v", got) - } - h := r.Header() - if h.Command != "cmd" || h.Interval != time.Second { - t.Errorf("Header after Initialize: %+v", h) - } -} - -func TestInMemoryRecorderWriteFrameAppends(t *testing.T) { - r := NewInMemoryRecorder() - _ = r.Initialize("x", time.Second, nil) - if err := r.WriteFrame(session.Execution{Stdout: "c\n"}); err != nil { - t.Fatalf("WriteFrame: %v", err) - } - if got := r.Frames(); len(got) != 1 || got[0].Stdout != "c\n" { - t.Errorf("Frames after WriteFrame: %+v", got) - } -} - -func TestInMemoryRecorderCloseIdempotent(t *testing.T) { - r := NewInMemoryRecorder() - _ = r.Initialize("x", time.Second, nil) - if err := r.Close(); err != nil { - t.Errorf("first Close: %v", err) - } - if err := r.Close(); err != nil { - t.Errorf("second Close: %v", err) - } - if !r.Closed() { - t.Errorf("Closed() should be true after Close") - } -} - -func TestInMemoryRecorderFailWriteAfter(t *testing.T) { - r := NewInMemoryRecorder() - r.FailWriteAfter(1) - _ = r.Initialize("x", time.Second, nil) - if err := r.WriteFrame(session.Execution{Stdout: "ok\n"}); err != nil { - t.Fatalf("first WriteFrame should succeed: %v", err) - } - err := r.WriteFrame(session.Execution{Stdout: "boom\n"}) - if err == nil { - t.Fatal("second WriteFrame should fail") - } - if !errors.Is(err, ErrInjectedWriteFailure) { - t.Errorf("err = %v want ErrInjectedWriteFailure", err) - } -} diff --git a/internal/recording/jsonl.go b/internal/recording/jsonl.go deleted file mode 100644 index 3fe2c49..0000000 --- a/internal/recording/jsonl.go +++ /dev/null @@ -1,82 +0,0 @@ -package recording - -import ( - "encoding/json" - "os" - "time" - - "github.com/ivoronin/wch/internal/session" -) - -// JSONLRecorder is the on-disk session.Recorder: one JSON value per line, header first. -// The file is opened with O_EXCL — refusing to clobber an existing recording. -type JSONLRecorder struct { - path string - f *os.File - enc *json.Encoder -} - -// NewJSONLRecorder opens path with O_EXCL, ready for Initialize to be called next. -// On os.ErrExist the caller (Flow.Start) is responsible for the user-facing message. -func NewJSONLRecorder(path string) (*JSONLRecorder, error) { - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) - if err != nil { - return nil, err - } - enc := json.NewEncoder(f) - enc.SetEscapeHTML(false) - return &JSONLRecorder{path: path, f: f, enc: enc}, nil -} - -// Initialize writes the header followed by every backlog frame. On any write error -// during initialization the file is closed AND removed — otherwise the orphan would -// block a same-path retry under O_EXCL until the user manually deletes it. -func (r *JSONLRecorder) Initialize(command string, interval time.Duration, backlog []session.Execution) error { - if err := r.enc.Encode(Header{ - Format: FormatTag, - Version: SupportedVersion, - Command: command, - Interval: interval.String(), - }); err != nil { - r.abortAndRemove() - return err - } - for _, e := range backlog { - if err := r.enc.Encode(frameFrom(e)); err != nil { - r.abortAndRemove() - return err - } - } - return nil -} - -// WriteFrame persists one novel execution. -func (r *JSONLRecorder) WriteFrame(exec session.Execution) error { - return r.enc.Encode(frameFrom(exec)) -} - -// Close releases the file handle. Idempotent. -func (r *JSONLRecorder) Close() error { - if r.f == nil { - return nil - } - err := r.f.Close() - r.f = nil - return err -} - -// abortAndRemove closes the file, zeroes the handle (so Close is a true no-op after this), -// and unlinks the path. Used by Initialize on any write failure so the O_EXCL guard doesn't -// stay stuck on an orphan and so a subsequent Close call from the caller's error path is a -// clean no-op. -func (r *JSONLRecorder) abortAndRemove() { - if r.f == nil { - return - } - _ = r.f.Close() - r.f = nil - _ = os.Remove(r.path) -} - -// Compile-time guarantee that JSONLRecorder satisfies session.Recorder. -var _ session.Recorder = (*JSONLRecorder)(nil) diff --git a/internal/recording/jsonl_test.go b/internal/recording/jsonl_test.go deleted file mode 100644 index b69183f..0000000 --- a/internal/recording/jsonl_test.go +++ /dev/null @@ -1,241 +0,0 @@ -package recording - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/ivoronin/wch/internal/session" -) - -// helpers - -func mustStartJSONL(t *testing.T, s *session.Session, path string) *JSONLRecorder { - t.Helper() - rec, err := NewJSONLRecorder(path) - if err != nil { - t.Fatalf("NewJSONLRecorder: %v", err) - } - if err := s.StartRecording(rec); err != nil { - t.Fatalf("StartRecording: %v", err) - } - return rec -} - -func mustRecord(t *testing.T, s *session.Session, exec session.Execution) { - t.Helper() - if _, _, err := s.RecordIfChanged(exec); err != nil { - t.Fatalf("RecordIfChanged: %v", err) - } -} - -// A full round-trip preserves ANSI in stdout, separate stderr, non-zero exit, and the error -// message string. The recording is closed before Load, replay never re-arms. -func TestRecordingRoundTrip(t *testing.T) { - path := filepath.Join(t.TempDir(), "wch.jsonl") - - s := session.NewSession("kubectl get pods", 5*time.Second) - mustStartJSONL(t, s, path) - - mustRecord(t, s, session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, 1, 0, time.UTC), - Stdout: "\x1b[1mNAME\x1b[0m\npod-1 \x1b[32mRunning\x1b[0m\n", - }) - mustRecord(t, s, session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, 2, 0, time.UTC), - Stdout: "out\n", - Stderr: "warn\n", - ExitCode: 2, - Error: errors.New("exit status 2"), - }) - - if err := s.StopRecording(); err != nil { - t.Fatalf("StopRecording: %v", err) - } - if s.IsRecording() { - t.Errorf("IsRecording() should be false after Stop") - } - - got, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - if got.Command != "kubectl get pods" { - t.Errorf("Command=%q want %q", got.Command, "kubectl get pods") - } - if got.Interval != 5*time.Second { - t.Errorf("Interval=%v want %v", got.Interval, 5*time.Second) - } - if len(got.History) != 2 { - t.Fatalf("History len=%d want 2", len(got.History)) - } - if got.History[0].Stdout != s.History[0].Stdout { - t.Errorf("ANSI stdout not preserved: %q vs %q", got.History[0].Stdout, s.History[0].Stdout) - } - if got.History[1].Stderr != "warn\n" { - t.Errorf("Stderr=%q want %q", got.History[1].Stderr, "warn\n") - } - if got.History[1].ExitCode != 2 { - t.Errorf("ExitCode=%d want 2", got.History[1].ExitCode) - } - if got.History[1].Error == nil || got.History[1].Error.Error() != "exit status 2" { - t.Errorf("Error not preserved: %v", got.History[1].Error) - } - if got.IsRecording() { - t.Errorf("Loaded session must not be armed for recording") - } -} - -// StartRecording dumps every frame already in History. -func TestRecordingStartDumpsBacklog(t *testing.T) { - path := filepath.Join(t.TempDir(), "wch.jsonl") - - s := session.NewSession("x", time.Second) - for i := 0; i < 4; i++ { - mustRecord(t, s, session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, i, 0, time.UTC), - Stdout: fmt.Sprintf("frame %d\n", i), - }) - } - mustStartJSONL(t, s, path) - if err := s.StopRecording(); err != nil { - t.Fatal(err) - } - - got, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - if len(got.History) != 4 { - t.Fatalf("backlog dump frames=%d want 4", len(got.History)) - } - for i, f := range got.History { - wantStdout := fmt.Sprintf("frame %d\n", i) - if f.Stdout != wantStdout { - t.Errorf("frame %d stdout=%q want %q", i, f.Stdout, wantStdout) - } - } -} - -// A frame larger than bufio.Scanner's 64KB token cap round-trips because Load calls -// scanner.Buffer with maxLineSize (256 MB). -func TestRecordingLargeFrameRoundTrip(t *testing.T) { - path := filepath.Join(t.TempDir(), "wch.jsonl") - big := strings.Repeat("kubernetes is large\n", 5000) // > 64KB - - s := session.NewSession("x", time.Second) - mustStartJSONL(t, s, path) - mustRecord(t, s, session.Execution{Timestamp: time.Now().UTC(), Stdout: big}) - if err := s.StopRecording(); err != nil { - t.Fatal(err) - } - - got, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - if len(got.History) != 1 { - t.Fatalf("History len=%d want 1", len(got.History)) - } - if got.History[0].Stdout != big { - t.Errorf("large stdout did not round-trip (lens %d vs %d)", len(got.History[0].Stdout), len(big)) - } -} - -// A crash-truncated final line is tolerated: Load returns every fully-decoded frame and no -// error. -func TestRecordingTruncatedTail(t *testing.T) { - path := filepath.Join(t.TempDir(), "wch.jsonl") - - s := session.NewSession("x", time.Second) - mustStartJSONL(t, s, path) - mustRecord(t, s, session.Execution{Timestamp: time.Now().UTC(), Stdout: "first\n"}) - mustRecord(t, s, session.Execution{Timestamp: time.Now().UTC(), Stdout: "second\n"}) - if err := s.StopRecording(); err != nil { - t.Fatal(err) - } - - // Append a partial JSON object (no closing brace, no newline). - f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) - if err != nil { - t.Fatal(err) - } - if _, err := f.WriteString(`{"ts":"2026-`); err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - - got, err := Load(path) - if err == nil { - t.Fatal("Load should report the skipped corrupt tail as a non-fatal warning") - } - if got == nil { - t.Fatalf("Load returned nil session despite recoverable error: %v", err) - } - if len(got.History) != 2 { - t.Errorf("History len=%d want 2 (truncated tail should be dropped)", len(got.History)) - } -} - -// NewJSONLRecorder opens the file with O_EXCL, so a second call at the same path refuses -// with os.ErrExist rather than clobbering the existing recording. -func TestRecordingRefusesExistingFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "wch.jsonl") - - first := session.NewSession("first", time.Second) - mustStartJSONL(t, first, path) - mustRecord(t, first, session.Execution{Timestamp: time.Now().UTC(), Stdout: "FIRST\n"}) - if err := first.StopRecording(); err != nil { - t.Fatal(err) - } - - _, err := NewJSONLRecorder(path) - if !errors.Is(err, os.ErrExist) { - t.Fatalf("second NewJSONLRecorder err = %v, want os.ErrExist", err) - } - - // First recording should still be readable, untouched. - got, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - if got.Command != "first" { - t.Errorf("Command=%q want %q (first recording must survive)", got.Command, "first") - } - if len(got.History) != 1 || got.History[0].Stdout != "FIRST\n" { - t.Errorf("History=%+v want one FIRST frame", got.History) - } -} - -// Sanity: the encoded file is JSONL (one well-formed JSON value per line, no extra escapes). -func TestRecordingIsJSONL(t *testing.T) { - path := filepath.Join(t.TempDir(), "wch.jsonl") - s := session.NewSession("kubectl", time.Second) - mustStartJSONL(t, s, path) - mustRecord(t, s, session.Execution{Timestamp: time.Now().UTC(), Stdout: "hi\n"}) - if err := s.StopRecording(); err != nil { - t.Fatal(err) - } - - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines (header + 1 frame), got %d:\n%s", len(lines), raw) - } - for i, line := range lines { - var v map[string]any - if err := json.Unmarshal([]byte(line), &v); err != nil { - t.Errorf("line %d is not valid JSON: %v\nraw=%q", i, err, line) - } - } -} diff --git a/internal/recording/load.go b/internal/recording/load.go deleted file mode 100644 index 6ffeb1f..0000000 --- a/internal/recording/load.go +++ /dev/null @@ -1,92 +0,0 @@ -package recording - -import ( - "bufio" - "encoding/json" - "errors" - "fmt" - "os" - "time" - - "github.com/ivoronin/wch/internal/session" -) - -// maxLineSize caps a single JSONL line. 256 MB is generous enough to load recordings made -// before the runner's per-stream cap landed, while bounding the buffer's worst-case growth. -const maxLineSize = 256 * 1024 * 1024 - -// Load reads a wch-history JSONL file into a Session. The header is validated for both -// format tag and version; mismatch returns a typed error so older binaries fail loud rather -// than silently mis-parsing a future format. -// -// Frame decoding is line-based and recoverable: a single malformed line (mid-stream corruption -// or a crash-truncated trailing line) is skipped so any fully-decoded frames after it still -// load. The skipped-line count and any I/O error are surfaced via the returned error so the -// caller can warn — Load still returns a non-nil Session in that case, signalling "partial -// load, here's what survived" rather than "load failed". -// -// The returned Session is not armed for recording — replay never persists. -func Load(path string) (*session.Session, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer func() { _ = f.Close() }() - - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) - - if !scanner.Scan() { - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("recording: read header: %w", err) - } - return nil, errors.New("recording: empty file") - } - var header Header - if err := json.Unmarshal(scanner.Bytes(), &header); err != nil { - return nil, fmt.Errorf("recording: invalid header: %w", err) - } - if header.Format != FormatTag { - return nil, fmt.Errorf("recording: unknown format %q (expected %q)", header.Format, FormatTag) - } - if header.Version != SupportedVersion { - return nil, fmt.Errorf("recording: unsupported version %d (this build supports %d)", header.Version, SupportedVersion) - } - interval, err := time.ParseDuration(header.Interval) - if err != nil { - return nil, fmt.Errorf("recording: invalid interval %q: %w", header.Interval, err) - } - s := session.NewSession(header.Command, interval) - var skipped int - for scanner.Scan() { - var frame Frame - if err := json.Unmarshal(scanner.Bytes(), &frame); err != nil { - skipped++ - continue - } - s.History = append(s.History, executionFrom(frame)) - } - if err := scanner.Err(); err != nil { - return s, fmt.Errorf("recording: read: %w", err) - } - if skipped > 0 { - return s, fmt.Errorf("recording: skipped %d corrupt frame(s)", skipped) - } - return s, nil -} - -// executionFrom converts a Frame back to a session.Execution. The inverse of frameFrom -// (in schema.go). -func executionFrom(f Frame) session.Execution { - var err error - if f.Error != "" { - err = errors.New(f.Error) - } - return session.Execution{ - Timestamp: f.Ts, - Stdout: f.Stdout, - Stderr: f.Stderr, - ExitCode: f.Exit, - Error: err, - } -} diff --git a/internal/recording/load_test.go b/internal/recording/load_test.go deleted file mode 100644 index f979d46..0000000 --- a/internal/recording/load_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package recording - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadRejectsBadFormat(t *testing.T) { - path := filepath.Join(t.TempDir(), "bad.jsonl") - if err := os.WriteFile(path, []byte(`{"format":"other","version":1,"command":"x","interval":"1s"}`+"\n"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := Load(path); err == nil { - t.Errorf("expected Load to reject unknown format") - } -} - -func TestLoadRejectsBadVersion(t *testing.T) { - path := filepath.Join(t.TempDir(), "future.jsonl") - if err := os.WriteFile(path, []byte(`{"format":"wch-history","version":99,"command":"x","interval":"1s"}`+"\n"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := Load(path); err == nil { - t.Errorf("expected Load to reject unsupported version") - } -} - -func TestLoadRejectsBadInterval(t *testing.T) { - path := filepath.Join(t.TempDir(), "bad-interval.jsonl") - if err := os.WriteFile(path, []byte(`{"format":"wch-history","version":1,"command":"x","interval":"not-a-duration"}`+"\n"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := Load(path); err == nil { - t.Errorf("expected Load to reject bad interval") - } -} diff --git a/internal/recording/schema.go b/internal/recording/schema.go deleted file mode 100644 index fb75aa4..0000000 --- a/internal/recording/schema.go +++ /dev/null @@ -1,52 +0,0 @@ -// Package recording owns the on-disk recording format and the recording lifecycle. -// The session.Recorder port lives in internal/session; the adapters (JSONL on disk and -// in-memory) live here, alongside Flow — the single entry point CLI and TUI use to -// start, stop, and inspect a recording. -package recording - -import ( - "time" - - "github.com/ivoronin/wch/internal/session" -) - -// FormatTag identifies a wch-history file. Every recording begins with a header line -// containing this tag. -const FormatTag = "wch-history" - -// SupportedVersion is the only file-format version this build accepts. -const SupportedVersion = 1 - -// Header is the first JSONL line of every wch-history recording. -type Header struct { - Format string `json:"format"` - Version int `json:"version"` - Command string `json:"command"` - Interval string `json:"interval"` -} - -// Frame is one captured execution as it sits on disk. -type Frame struct { - Ts time.Time `json:"ts"` - Exit int `json:"exit"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr,omitempty"` - Error string `json:"error,omitempty"` -} - -// frameFrom converts a session.Execution to a Frame. The conversion lives in the -// recording package because session does not know about the on-disk schema; both -// JSONLRecorder and InMemoryRecorder use it. -func frameFrom(e session.Execution) Frame { - errMsg := "" - if e.Error != nil { - errMsg = e.Error.Error() - } - return Frame{ - Ts: e.Timestamp, - Exit: e.ExitCode, - Stdout: e.Stdout, - Stderr: e.Stderr, - Error: errMsg, - } -} diff --git a/internal/runner/runner.go b/internal/runner/runner.go deleted file mode 100644 index 6a1a800..0000000 --- a/internal/runner/runner.go +++ /dev/null @@ -1,74 +0,0 @@ -package runner - -import ( - "bytes" - "context" - "os/exec" - "time" - - "github.com/ivoronin/wch/internal/session" -) - -const errorExitCode = -1 // Used when error is not an ExitError - -// Runner executes commands -type Runner struct { - command string -} - -// New creates a new runner -func New(command string) *Runner { - return &Runner{ - command: command, - } -} - -// maxOutputBytes caps the captured stdout/stderr per stream so a runaway streaming command -// (e.g. `journalctl -f`, `cat /dev/urandom`) cannot exhaust memory through the history. -// 4 MB comfortably absorbs typical kubectl/ps/etc. output while bounding worst-case usage. -// Excess bytes are silently dropped — no marker is inserted into the captured stream -// because the same buffer ends up in the recording file, where a synthetic suffix would be -// indistinguishable from real output to a later replay or downstream consumer. -const maxOutputBytes = 4 * 1024 * 1024 - -// limitedBuffer is a bytes.Buffer that stops growing past maxOutputBytes, dropping the -// excess. exec.Cmd treats a short write as a failure that aborts the command, so Write must -// always report n == len(p) to keep the process running even after we've stopped recording. -// String is promoted from the embedded buffer. -type limitedBuffer struct { - bytes.Buffer -} - -func (l *limitedBuffer) Write(p []byte) (int, error) { - if space := maxOutputBytes - l.Len(); space > 0 { - l.Buffer.Write(p[:min(len(p), space)]) - } - return len(p), nil -} - -// Execute runs the command and returns the result. Timestamp is the start time of the -// invocation (when wch decided to run the command), not the finish time — finish-time -// stamps drift further from "what wch did" the slower the command is. -func (r *Runner) Execute(ctx context.Context) session.Execution { - cmd := exec.CommandContext(ctx, "sh", "-c", r.command) - - var stdout, stderr limitedBuffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - result := session.Execution{Timestamp: time.Now()} - err := cmd.Run() - result.Stdout = stdout.String() - result.Stderr = stderr.String() - - if err != nil { - result.Error = err - if exitErr, ok := err.(*exec.ExitError); ok { - result.ExitCode = exitErr.ExitCode() - } else { - result.ExitCode = errorExitCode - } - } - - return result -} diff --git a/internal/session/port.go b/internal/session/port.go deleted file mode 100644 index b1b2bdf..0000000 --- a/internal/session/port.go +++ /dev/null @@ -1,21 +0,0 @@ -package session - -import "time" - -// Recorder is the persistence port a Session writes to when armed for recording. -// Adapters live in internal/recording: JSONLRecorder for the on-disk format, and -// InMemoryRecorder for tests and general in-process callers. Outside this package, -// callers should drive recording through recording.Flow rather than this port directly. -type Recorder interface { - // Initialize is called exactly once when the Session arms for recording. The adapter - // is responsible for writing any header it needs and for persisting the supplied - // backlog (every execution already in History at the moment recording begins). - Initialize(command string, interval time.Duration, backlog []Execution) error - - // WriteFrame persists one novel execution. Called for every execution - // RecordIfChanged accepted into History after Initialize has run. - WriteFrame(exec Execution) error - - // Close releases any resources the adapter holds. Idempotent. - Close() error -} diff --git a/internal/session/session.go b/internal/session/session.go deleted file mode 100644 index 15d084b..0000000 --- a/internal/session/session.go +++ /dev/null @@ -1,124 +0,0 @@ -package session - -import ( - "errors" - "slices" - "time" -) - -// Execution represents a single command execution result -type Execution struct { - Timestamp time.Time - Stdout string - Stderr string - ExitCode int - Error error -} - -// Output returns combined stdout and stderr -func (e *Execution) Output() string { - if e.Stderr == "" { - return e.Stdout - } - if e.Stdout == "" { - return e.Stderr - } - return e.Stdout + "\n" + e.Stderr -} - -// Session holds execution history and the active recorder, if any. The Recorder port is -// defined here (port.go); concrete adapters and the persistence format live in -// internal/recording. The session owns the recorder's lifetime (same lifetime as the -// session itself) but knows nothing about JSONL, file I/O, or path normalization — -// recording.Flow is the entry point external callers should use. -type Session struct { - Command string - Interval time.Duration - History []Execution - MaxHistory int - recorder Recorder // nil ⇔ not recording -} - -// NewSession creates a new session -func NewSession(command string, interval time.Duration) *Session { - return &Session{ - Command: command, - Interval: interval, - } -} - -// RecordIfChanged adds an execution to history only if it differs materially from the -// previous one — output, exit code, OR error string. A frame that prints the same text but -// changes exit code or error must not be dropped, otherwise downstream UI (exit-code -// annotation, OSC9 bell, replay) loses the transition. When a recording is active and the -// frame is added, the frame is also written to the file; a write error auto-finalizes the -// recording (close + clear) and is returned. added reports whether the execution was novel; -// evicted reports whether MaxHistory just rotated the oldest entry out (callers tracking a -// history cursor need to decrement it). -func (s *Session) RecordIfChanged(exec Execution) (added bool, evicted bool, err error) { - if len(s.History) > 0 { - last := s.History[len(s.History)-1] - if exec.Output() == last.Output() && - exec.ExitCode == last.ExitCode && - errString(exec.Error) == errString(last.Error) { - return false, false, nil - } - } - s.History = append(s.History, exec) - if s.MaxHistory > 0 && len(s.History) > s.MaxHistory { - // slices.Delete (rather than History[1:]) copy-shifts and zeros the freed tail slot, - // so the evicted Execution's stdout/stderr strings are released for GC instead of - // staying pinned in the backing array until the next slice growth. - s.History = slices.Delete(s.History, 0, 1) - evicted = true - } - if s.recorder != nil { - if writeErr := s.recorder.WriteFrame(exec); writeErr != nil { - _ = s.recorder.Close() - s.recorder = nil - return true, evicted, writeErr - } - } - return true, evicted, nil -} - -func errString(e error) string { - if e == nil { - return "" - } - return e.Error() -} - -// StartRecording arms the session to persist subsequent additions through rec. -// rec.Initialize is called with the current Command, Interval, and the History backlog. -// External callers should drive recording through recording.Flow rather than constructing -// the Recorder + calling StartRecording directly; this signature exists for Flow's use -// and for in-package tests. -func (s *Session) StartRecording(rec Recorder) error { - if s.recorder != nil { - return errors.New("session: already recording") - } - if err := rec.Initialize(s.Command, s.Interval, s.History); err != nil { - return err - } - s.recorder = rec - return nil -} - -// StopRecording closes the recording and clears the handle. Idempotent. -func (s *Session) StopRecording() error { - if s.recorder == nil { - return nil - } - err := s.recorder.Close() - s.recorder = nil - return err -} - -// IsRecording reports whether a recording is currently active. External callers -// (cmd/wch, internal/tui) should query recording.Flow.IsActive() instead — this method -// exists so the recording package's Flow can read through to the session's single -// source of truth. Used directly only by Flow and by in-package tests. -func (s *Session) IsRecording() bool { - return s.recorder != nil -} diff --git a/internal/session/session_test.go b/internal/session/session_test.go deleted file mode 100644 index 7c3d1a5..0000000 --- a/internal/session/session_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package session_test - -import ( - "testing" - "time" - - "github.com/ivoronin/wch/internal/recording" - "github.com/ivoronin/wch/internal/session" -) - -// RecordIfChanged appends the first execution unconditionally. -func TestRecordIfChangedFirstExecution(t *testing.T) { - s := session.NewSession("x", time.Second) - added, _, err := s.RecordIfChanged(session.Execution{Stdout: "hello"}) - if err != nil { - t.Fatalf("unexpected err: %v", err) - } - if !added { - t.Errorf("first execution should be added") - } - if len(s.History) != 1 { - t.Errorf("History len=%d want 1", len(s.History)) - } -} - -// Duplicate output (same Output()) is dropped, History stays the same length. -func TestRecordIfChangedDeduplicates(t *testing.T) { - s := session.NewSession("x", time.Second) - _, _, _ = s.RecordIfChanged(session.Execution{Stdout: "a\n"}) - added, _, err := s.RecordIfChanged(session.Execution{Stdout: "a\n"}) - if err != nil { - t.Fatalf("unexpected err: %v", err) - } - if added { - t.Errorf("duplicate output should not be added") - } - if len(s.History) != 1 { - t.Errorf("History len=%d want 1", len(s.History)) - } -} - -// A changed output is added. -func TestRecordIfChangedAddsChanged(t *testing.T) { - s := session.NewSession("x", time.Second) - _, _, _ = s.RecordIfChanged(session.Execution{Stdout: "a\n"}) - added, _, err := s.RecordIfChanged(session.Execution{Stdout: "b\n"}) - if err != nil { - t.Fatalf("unexpected err: %v", err) - } - if !added { - t.Errorf("changed output should be added") - } - if len(s.History) != 2 { - t.Errorf("History len=%d want 2", len(s.History)) - } -} - -// Once MaxHistory is set and exceeded, the oldest frame is trimmed. -func TestRecordIfChangedTrimsAtMaxHistory(t *testing.T) { - s := session.NewSession("x", time.Second) - s.MaxHistory = 3 - for i := 0; i < 5; i++ { - _, _, _ = s.RecordIfChanged(session.Execution{Stdout: string(rune('a' + i))}) - } - if got, want := len(s.History), 3; got != want { - t.Fatalf("History len=%d want %d", got, want) - } - // We should have kept the *last* three. - if s.History[0].Stdout != "c" || s.History[2].Stdout != "e" { - t.Errorf("trimmed wrong end; got %q..%q", s.History[0].Stdout, s.History[2].Stdout) - } -} - -// StopRecording is idempotent — safe to call on a session that isn't recording. -func TestRecordingStopIdempotent(t *testing.T) { - s := session.NewSession("x", time.Second) - if err := s.StopRecording(); err != nil { - t.Errorf("Stop on non-recording session: %v", err) - } - - rec := recording.NewInMemoryRecorder() - if err := s.StartRecording(rec); err != nil { - t.Fatalf("StartRecording: %v", err) - } - if err := s.StopRecording(); err != nil { - t.Errorf("first Stop: %v", err) - } - if err := s.StopRecording(); err != nil { - t.Errorf("second Stop: %v", err) - } - if s.IsRecording() { - t.Errorf("IsRecording() should be false") - } -} - -// StartRecording while a recording is already active returns an error and leaves the existing -// recording untouched. -func TestRecordingStartWhileActiveErrors(t *testing.T) { - s := session.NewSession("x", time.Second) - rec1 := recording.NewInMemoryRecorder() - if err := s.StartRecording(rec1); err != nil { - t.Fatalf("first Start: %v", err) - } - rec2 := recording.NewInMemoryRecorder() - if err := s.StartRecording(rec2); err == nil { - t.Errorf("expected Start while already recording to return an error") - } - if !s.IsRecording() { - t.Errorf("IsRecording() should still be true (existing recording untouched)") - } - if rec2.Closed() { - t.Errorf("second recorder must not have been touched") - } - _ = s.StopRecording() -} - -// When a write fails mid-recording, RecordIfChanged returns the error, the session auto- -// finalizes (recorder closed, handle cleared), IsRecording() flips to false. -func TestRecordingAutoFinalizeOnWriteError(t *testing.T) { - s := session.NewSession("x", time.Second) - rec := recording.NewInMemoryRecorder() - rec.FailWriteAfter(0) // any WriteFrame fails immediately - if err := s.StartRecording(rec); err != nil { - t.Fatalf("StartRecording: %v", err) - } - added, _, err := s.RecordIfChanged(session.Execution{Stdout: "boom\n"}) - if err == nil { - t.Fatal("expected write error, got nil") - } - if !added { - t.Errorf("frame should still count as added to in-memory History before the write attempt") - } - if s.IsRecording() { - t.Errorf("IsRecording() should be false after auto-finalize") - } - // And the frame *is* in History — persistence failed but in-memory dedupe succeeded. - if len(s.History) != 1 { - t.Errorf("History len=%d want 1", len(s.History)) - } -} diff --git a/internal/tui/bar.go b/internal/tui/bar.go deleted file mode 100644 index 7aa6d6e..0000000 --- a/internal/tui/bar.go +++ /dev/null @@ -1,101 +0,0 @@ -package tui - -import ( - "strings" - - "charm.land/bubbles/v2/key" - "charm.land/lipgloss/v2" -) - -// barShown reports whether the bottom bar takes up a screen row right now — -// either because the user enabled it (m.prefs.StatusBar) or because the current -// state needs unconditional bar feedback (signalled by state.ShowsBar). Each -// state decides for itself: inputState always wants the bar regardless of -// host; searchState's ShowsBar is false so search hides under -t by design, -// consistent with the user's "I asked for it off" intent. -func (m Model) barShown() bool { - return m.prefs.StatusBar || m.state.ShowsBar() -} - -// renderBarLayout composes the standard three-column bar from the given slot contents. Left -// is right-aligned within its zone (truncated/padded), the centered timestamp + activity -// indicator stay centered (with the optional REC dot charged to the left so the clock does -// not drift), and the help text fills the right zone. -func (m Model) renderBarLayout(leftContent, indicator, helpText string) string { - layout := calcThreeColumnLayout(m.width, centerBlockWidth) - left := barInnerStyle.Render(renderLeft(leftContent, layout.leftWidth)) - center := m.renderCenterBlock(indicator) - right := barInnerStyle.Render(renderRight(helpText, layout.rightWidth)) - content := lipgloss.JoinHorizontal(lipgloss.Top, left, center, right) - return statusBarStyle.Width(m.width).Render(content) -} - -// centerBlockWidth is the fixed cell width of the centered clock-group: -// -// [2 left-pad][rec slot 1][1 gap][timestamp][1 gap][indicator 1][2 right-pad] -// -// Each adjacent element on the bar has a 1-cell base gap; REC adds an extra cell on its -// left, indicator adds an extra cell on its right — so the clock group is bracketed by -// 2-cell gutters and stays visually distinct from the help and content zones. REC and -// indicator slots are always-on (blank-padded when REC is idle) so toggling recording -// mid-session never re-flows the left zone. Derived from timestampLen so a change to -// timestampFmt propagates without re-deriving constants. -var centerBlockWidth = 2 + 1 + 1 + timestampLen + 1 + 1 + 2 - -// renderCenterBlock composes the always-on clock-group at exactly centerBlockWidth visible -// cells: REC slot stays 1 cell whether recording or not. Gaps and pads are rendered inline -// per call so an adaptive-theme switch is picked up on the next frame. -func (m Model) renderCenterBlock(indicator string) string { - oneSpace := barInnerStyle.Width(1).Render("") - twoSpaces := barInnerStyle.Width(2).Render("") - rec := oneSpace - if m.flow.IsActive() { - rec = recStyle.Render("●") - } - timestamp := barInnerStyle.Width(timestampLen).Render(m.renderTimestamp()) - return lipgloss.JoinHorizontal(lipgloss.Top, - twoSpaces, rec, oneSpace, timestamp, oneSpace, indicator, twoSpaces, - ) -} - -// renderTimestamp formats the timestamp the active state asks for in the bar center. -// view/picker return the frame at the cursor; search returns the captured snapshot; -// input delegates to its prev. -func (m Model) renderTimestamp() string { - t, ok := m.state.Timestamp(m) - if !ok { - return "" - } - return t.Format(timestampFmt) -} - -// renderIndicator renders the activity indicator shown in the bar center (right of the -// clock). searchState replaces it with ❄ via its own RenderBar. -func (m Model) renderIndicator() string { - var indicator string - switch { - case !m.isLive(): - indicator = "▶" - case !m.isFollowing(): - indicator = "⎌" - case m.prefs.Paused: - indicator = "⏸" - case m.executing: - indicator = "*" - default: - indicator = "·" - } - return indicatorStyle.Render(indicator) -} - -// renderHelp builds the bar's right-side hint as " desc" segments joined with -// " • " and wrapped in helpStyle. Keys are bolded via boldKeep so the outer fg/bg survive -// to the end of the line. -func renderHelp(bindings []key.Binding) string { - parts := make([]string, len(bindings)) - for i, b := range bindings { - h := b.Help() - parts[i] = boldKeep(h.Key) + " " + h.Desc - } - return helpStyle.Render(strings.Join(parts, " • ")) -} diff --git a/internal/tui/bar_test.go b/internal/tui/bar_test.go deleted file mode 100644 index cf1a081..0000000 --- a/internal/tui/bar_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package tui - -import ( - "strings" - "testing" - "time" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "github.com/ivoronin/wch/internal/session" -) - -// The status bar must render on a single row. If the left/center/right zones together exceed -// the terminal's contentWidth, the outer statusBarStyle.Width(...).Render wraps the overflow -// onto a second row — the help tail (' quit') visibly disappears off-screen. The center block -// in particular is fragile: its visible width must equal centerBlockWidth exactly, regardless -// of recording state, or the bar tips over the contentWidth budget. -func TestBarSingleLineAtNormalWidths(t *testing.T) { - for _, w := range []int{60, 80, 100, 120} { - m := New(Config{Command: "x", Interval: time.Second}) - out, _ := m.Update(tea.WindowSizeMsg{Width: w, Height: 24}) - m = out.(Model) - bar := m.state.RenderBar(m) - if got := strings.Count(bar, "\n"); got != 0 { - t.Errorf("width=%d: bar has %d newlines (want 0); bar wrapped onto a second row", w, got) - } - } -} - -// renderCenterBlock's visible width must equal centerBlockWidth — the layout math in -// renderBarLayout depends on this. Test both idle and recording states so a future style -// tweak (padding added to recStyle or indicatorStyle) trips the test instead of silently -// stealing cells from the help zone. -func TestCenterBlockWidthMatchesConstant(t *testing.T) { - m := New(Config{Command: "x", Interval: time.Second}) - out, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) - m = out.(Model) - if got := lipgloss.Width(m.renderCenterBlock(m.renderIndicator())); got != centerBlockWidth { - t.Errorf("idle: center block visible width = %d, want %d", got, centerBlockWidth) - } -} - -// TestBarShown documents the single source of truth for whether the bottom bar -// is taking a row of the screen right now. Either the user-configured -// preference (m.prefs.StatusBar) is on, or the active state needs unconditional bar -// feedback (input prompt, picker timeline). searchState is intentionally NOT -// included — under -t we keep its query and counter hidden, consistent with -// the user's "I asked for it off" intent. -func TestBarShown(t *testing.T) { - tests := []struct { - name string - statusBar bool - state state - want bool - }{ - {"view + statusBar on", true, viewState{}, true}, - {"view + statusBar off", false, viewState{}, false}, - {"picker + statusBar off", false, pickerState{}, true}, - {"picker + statusBar on", true, pickerState{}, true}, - {"input on view + statusBar off", false, inputState{prev: viewState{}}, true}, - {"input on view + statusBar on", true, inputState{prev: viewState{}}, true}, - {"input on picker + statusBar off", false, inputState{prev: pickerState{}}, true}, - {"search + statusBar off", false, searchState{prev: viewState{}}, false}, - {"search + statusBar on", true, searchState{prev: viewState{}}, true}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - m := New(Config{Command: "x", Interval: time.Second}) - m.prefs.StatusBar = tc.statusBar - m.state = tc.state - if got := m.barShown(); got != tc.want { - t.Errorf("barShown() = %v, want %v", got, tc.want) - } - }) - } -} - -// TestSearchInputBarVisibleWithStatusBarOff: pressing '/' with the status bar -// disabled must reveal the input prompt in place of the bottom viewport row. -// Without the barShown wiring, the bar render gate is m.prefs.StatusBar=false and -// the prompt is hidden — leaving typed characters with no visual feedback. -func TestSearchInputBarVisibleWithStatusBarOff(t *testing.T) { - m := New(Config{Command: "x", Interval: time.Second}) - m.prefs.StatusBar = false - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("5m", podNames(20))}}) - - // Sanity: with statusBar off and viewState, no bar at the bottom. - pre := m.View().Content - if got := lipgloss.Height(pre); got != 10 { - t.Fatalf("pre-press rendered height = %d, want 10", got) - } - if got := trailingBarRow(pre); got == m.state.RenderBar(m) { - t.Fatalf("pre-press: last row already matches m.state.RenderBar(m); test setup is wrong") - } - - m = pressKey(t, m, '/') - - if _, ok := m.state.(inputState); !ok { - t.Fatalf("after '/': m.state = %T, want inputState", m.state) - } - post := m.View().Content - if got := lipgloss.Height(post); got != 10 { - t.Fatalf("post-press rendered height = %d, want 10 (bar must steal a viewport row)", got) - } - if got := trailingBarRow(post); got != m.state.RenderBar(m) { - t.Fatalf("post-press: trailing row does not match m.state.RenderBar(m)\n got: %q\nwant: %q", got, m.state.RenderBar(m)) - } -} - -// TestPickerBarVisibleWithStatusBarOff: pressing 'b' (enter picker) with the -// status bar disabled must reveal the picker timeline. On Esc the bar must -// disappear and the viewport must reclaim its row. -func TestPickerBarVisibleWithStatusBarOff(t *testing.T) { - m := New(Config{Command: "x", Interval: time.Second}) - m.prefs.StatusBar = false - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("5m", podNames(20))}}) - - pre := m.View().Content - if got := trailingBarRow(pre); got == m.state.RenderBar(m) { - t.Fatalf("pre-press: last row already matches m.state.RenderBar(m); test setup is wrong") - } - - m = pressKey(t, m, 'b') - if _, ok := m.state.(pickerState); !ok { - t.Fatalf("after 'b': m.state = %T, want pickerState", m.state) - } - post := m.View().Content - if got := lipgloss.Height(post); got != 10 { - t.Fatalf("after 'b': rendered height = %d, want 10", got) - } - if got := trailingBarRow(post); got != m.state.RenderBar(m) { - t.Fatalf("after 'b': trailing row does not match m.state.RenderBar(m)\n got: %q\nwant: %q", got, m.state.RenderBar(m)) - } - - m = feed(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) - if _, ok := m.state.(viewState); !ok { - t.Fatalf("after Esc: m.state = %T, want viewState", m.state) - } - back := m.View().Content - if got := trailingBarRow(back); got == m.state.RenderBar(m) { - t.Fatalf("after Esc: bar still present in trailing row\n got: %q", got) - } -} - -// trailingBarRow returns the last visible row of the rendered View content. -// We compare it directly against m.state.RenderBar(m) to assert that the bar is in fact -// laid out at the bottom of the screen, not just somewhere in the buffer. -func trailingBarRow(content string) string { - lines := strings.Split(content, "\n") - return lines[len(lines)-1] -} - -// In replay mode, the activity indicator is the ▶ play triangle. -func TestRenderIndicatorReplay(t *testing.T) { - m := NewReplay(Config{}, preloadedReplaySession(1)) - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - got := m.renderIndicator() - if !strings.Contains(got, "▶") { - t.Errorf("replay indicator should contain ▶; got %q", got) - } -} - -// In live mode, the indicator is one of the live glyphs (no ▶). -func TestRenderIndicatorLiveNotReplay(t *testing.T) { - m := New(Config{Command: "x", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - got := m.renderIndicator() - if strings.Contains(got, "▶") { - t.Errorf("live indicator must not contain ▶; got %q", got) - } -} diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go deleted file mode 100644 index 1404c1a..0000000 --- a/internal/tui/cursor.go +++ /dev/null @@ -1,68 +0,0 @@ -package tui - -// Cursor is the position into Session.History that the viewport renders from. The -1 -// idx means "no frame yet" -- live mode before the first execution, or any time the -// session has zero entries. All transitions go through methods so the evict-shift, -// follow-tail, clamp, and sentinel rules live next to each other instead of scattered -// across the bar, the state files, and dispatchExec. -// -// Constructors must be used: the zero value Cursor{idx: 0} would read as "at frame 0", -// which is invalid before any execution has landed. Use noCursor or cursorAtTail. -type Cursor struct{ idx int } - -// noCursor is the initial cursor for a live model: no frame to display yet. -func noCursor() Cursor { return Cursor{idx: -1} } - -// cursorAtTail returns a cursor positioned at the last frame of a history of length n. -// Returns noCursor when n == 0. -func cursorAtTail(historyLen int) Cursor { - if historyLen == 0 { - return noCursor() - } - return Cursor{idx: historyLen - 1} -} - -// cursorAt returns a cursor at the given index without clamping. Used by tests and by -// preloaded-session initialization where the caller already knows the index is valid -// for the corresponding history. -func cursorAt(idx int) Cursor { return Cursor{idx: idx} } - -// At returns the current index and a "valid" flag. ok == false when the cursor sits at -// the no-frame-yet sentinel. -func (c Cursor) At() (int, bool) { return c.idx, c.Valid() } - -// Index returns the raw cursor index. Useful for callers that have a downstream guard -// (e.g. FrameViewModel.Frame handles i < 0 by returning ""), or that arithmetically -// transform the index before passing it back through Move. -func (c Cursor) Index() int { return c.idx } - -// Valid reports whether the cursor points at a real frame. -func (c Cursor) Valid() bool { return c.idx >= 0 } - -// Following reports whether the cursor sits at the tail of a history of length n. An -// empty history (n == 0) is considered "following" so the first execution becomes the -// tail under view/picker's FollowsTail policy. -func (c Cursor) Following(historyLen int) bool { - return historyLen == 0 || c.idx == historyLen-1 -} - -// AfterEvict shifts the cursor down by 1, clamping at 0, after a MaxHistory eviction -// removed slot 0. Keeps a non-tail viewer reading the same frame; clamps to 0 when the -// frame they were on was the one evicted. -func (c Cursor) AfterEvict() Cursor { - return Cursor{idx: max(0, c.idx-1)} -} - -// ToTail returns a cursor at the last frame of a history of length n. Equivalent to -// cursorAtTail; provided as a method so transition sites (dispatchExec's follow-tail -// branch) read as "advance to tail". -func (c Cursor) ToTail(historyLen int) Cursor { return cursorAtTail(historyLen) } - -// Move returns a cursor clamped to [0, historyLen-1]. Used by picker navigation. -// Returns noCursor when historyLen == 0. -func (c Cursor) Move(to, historyLen int) Cursor { - if historyLen == 0 { - return noCursor() - } - return Cursor{idx: max(0, min(historyLen-1, to))} -} diff --git a/internal/tui/cursor_test.go b/internal/tui/cursor_test.go deleted file mode 100644 index d5433b6..0000000 --- a/internal/tui/cursor_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package tui - -import "testing" - -func TestCursorConstructors(t *testing.T) { - if i, ok := noCursor().At(); ok || i != -1 { - t.Errorf("noCursor().At() = (%d, %v), want (-1, false)", i, ok) - } - if i, ok := cursorAtTail(0).At(); ok || i != -1 { - t.Errorf("cursorAtTail(0).At() = (%d, %v), want (-1, false)", i, ok) - } - if i, ok := cursorAtTail(5).At(); !ok || i != 4 { - t.Errorf("cursorAtTail(5).At() = (%d, %v), want (4, true)", i, ok) - } - if i, ok := cursorAt(2).At(); !ok || i != 2 { - t.Errorf("cursorAt(2).At() = (%d, %v), want (2, true)", i, ok) - } -} - -func TestCursorFollowing(t *testing.T) { - cases := []struct { - name string - c Cursor - n int - want bool - }{ - {"empty history with noCursor", noCursor(), 0, true}, - {"empty history with stale cursor", cursorAt(3), 0, true}, - {"at tail of n=5", cursorAt(4), 5, true}, - {"middle of n=5", cursorAt(2), 5, false}, - {"head of n=5", cursorAt(0), 5, false}, - {"noCursor with non-empty", noCursor(), 5, false}, - } - for _, c := range cases { - if got := c.c.Following(c.n); got != c.want { - t.Errorf("%s: Following(%d) = %v, want %v", c.name, c.n, got, c.want) - } - } -} - -func TestCursorAfterEvict(t *testing.T) { - cases := []struct { - in, want int - }{ - {-1, 0}, // noCursor clamps to 0 after eviction (rare; usually only fires when valid) - {0, 0}, // the frame they were on was evicted - {1, 0}, - {4, 3}, - } - for _, c := range cases { - got := cursorAt(c.in).AfterEvict() - if got.Index() != c.want { - t.Errorf("cursorAt(%d).AfterEvict().Index() = %d, want %d", c.in, got.Index(), c.want) - } - } -} - -func TestCursorToTail(t *testing.T) { - if c := noCursor().ToTail(0); c.Valid() { - t.Errorf("ToTail(0) should be invalid (no frames), got %+v", c) - } - if c := cursorAt(0).ToTail(5); c.Index() != 4 { - t.Errorf("ToTail(5).Index() = %d, want 4", c.Index()) - } -} - -func TestCursorMove(t *testing.T) { - cases := []struct { - name string - from Cursor - to, n int - wantIdx int - wantValid bool - }{ - {"clamp below", cursorAt(2), -3, 5, 0, true}, - {"clamp above", cursorAt(2), 99, 5, 4, true}, - {"middle", cursorAt(2), 3, 5, 3, true}, - {"empty history", cursorAt(2), 1, 0, -1, false}, - {"from noCursor to valid", noCursor(), 2, 5, 2, true}, - } - for _, c := range cases { - got := c.from.Move(c.to, c.n) - if got.Index() != c.wantIdx || got.Valid() != c.wantValid { - t.Errorf("%s: Move(%d, %d) = {idx=%d valid=%v}, want {idx=%d valid=%v}", - c.name, c.to, c.n, got.Index(), got.Valid(), c.wantIdx, c.wantValid) - } - } -} diff --git a/internal/tui/diffrender/diffrender.go b/internal/tui/diffrender/diffrender.go deleted file mode 100644 index 73d5772..0000000 --- a/internal/tui/diffrender/diffrender.go +++ /dev/null @@ -1,79 +0,0 @@ -// Package diffrender renders a diff (from internal/diff) into styled terminal output: it -// overlays a highlight foreground on the changed cells while preserving the command's own -// ANSI background and attributes, including state carried across line boundaries. It is the -// optional terminal renderer companion to the presentation-free diff package - kept separate -// so diff stays dependency-free. -package diffrender - -import ( - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/cellbuf" - - "github.com/ivoronin/wch/internal/diff" - "github.com/ivoronin/wch/internal/tui/overlay" -) - -// Render turns lines (the diff of the ANSI-stripped new output, one Line per new line in -// order) into a styled string by overlaying onto styledOutput (the raw, styled new output): -// changed cells get fg as their foreground, the command's background/attributes are -// preserved, and SGR state carried across lines is honoured. styledOutput's rows align with -// lines by index. -func Render(lines []diff.Line, styledOutput string, fg ansi.Color) string { - if len(lines) == 0 { - return "" - } - return overlay.Walk(styledOutput, overlay.MaxDisplayWidth(styledOutput), len(lines), func(buf *cellbuf.Buffer) { - for y, ln := range lines { - highlightRow(buf, y, ln, fg) - } - }) -} - -// highlightRow sets fg on the cells of row y that correspond to changed visible runes of ln. -// Cells are walked left-to-right, each consuming its grapheme's runes (base + combining), to -// stay aligned with the diff's rune-indexed spans. -func highlightRow(buf *cellbuf.Buffer, y int, ln diff.Line, fg ansi.Color) { - if ln.Kind == diff.LineEqual { - return // unchanged: keep the command's styling, no highlight - } - - runes := []rune(ln.Text) - changed := make([]bool, len(runes)) - switch ln.Kind { - case diff.LineAdded: - for i := range changed { - changed[i] = true - } - case diff.LineChanged: - off := 0 - for _, s := range ln.Spans { - n := len([]rune(s.Text)) - if s.Changed { - for i := off; i < off+n && i < len(changed); i++ { - changed[i] = true - } - } - off += n - } - } - - ri := 0 - for x := 0; x < buf.Width() && ri < len(changed); x++ { - c := buf.Cell(x, y) - if c == nil || c.Width == 0 { - continue // padding or the continuation column of a wide rune: not a visible rune - } - cnt := 1 + len(c.Comb) - hot := false - for i := ri; i < ri+cnt && i < len(changed); i++ { - if changed[i] { - hot = true - break - } - } - if hot { - c.Style.Fg = fg - } - ri += cnt - } -} diff --git a/internal/tui/diffrender/diffrender_test.go b/internal/tui/diffrender/diffrender_test.go deleted file mode 100644 index 14c0994..0000000 --- a/internal/tui/diffrender/diffrender_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package diffrender - -import ( - "strings" - "testing" - - "github.com/charmbracelet/x/ansi" - - "github.com/ivoronin/wch/internal/diff" -) - -var testFg = ansi.RGBColor{R: 0x2E, G: 0x7D, B: 0x32} - -const greenFg = "38;2;46;125;50" // truecolor SGR params for testFg (foreground) - -// render runs the real path: diff on stripped text, overlay onto the styled new output. -func render(old, neu string) string { - a := diff.Align(ansi.Strip(old), ansi.Strip(neu)) - return Render(a.Lines(), neu, testFg) -} - -func TestRenderPlainChange(t *testing.T) { - body := render("a b", "a c") - if got := ansi.Strip(body); got != "a c" { - t.Fatalf("visible=%q want %q\nraw=%q", got, "a c", body) - } - if !strings.Contains(body, greenFg) { - t.Errorf("expected highlight, raw=%q", body) - } -} - -func TestRenderPreservesUnchangedFg(t *testing.T) { - // whole line yellow; only "6" changes -> unchanged "load " keeps yellow fg, "6" turns green. - body := render("\x1b[33mload 5\x1b[0m", "\x1b[33mload 6\x1b[0m") - if got := ansi.Strip(body); got != "load 6" { - t.Fatalf("visible=%q want %q\nraw=%q", got, "load 6", body) - } - if !strings.Contains(body, "33") { - t.Errorf("yellow fg lost on unchanged text, raw=%q", body) - } - if !strings.Contains(body, greenFg) { - t.Errorf("no highlight, raw=%q", body) - } -} - -func TestRenderPreservesBackground(t *testing.T) { - // red background across the line; middle token changes -> bg untouched, "X" gets green fg. - body := render("\x1b[41mA B C\x1b[0m", "\x1b[41mA X C\x1b[0m") - if got := ansi.Strip(body); got != "A X C" { - t.Fatalf("visible=%q want %q\nraw=%q", got, "A X C", body) - } - if !strings.Contains(body, "41") { - t.Errorf("red background not preserved, raw=%q", body) - } - if !strings.Contains(body, greenFg) { - t.Errorf("no highlight, raw=%q", body) - } -} - -func TestRenderCarriesColorAcrossLines(t *testing.T) { - // red opened on line 1, carried to line 2 (no reset until the end); only line 2 changes. - body := render("\x1b[31mfoo\nbar 5\x1b[0m", "\x1b[31mfoo\nbar 6\x1b[0m") - if got := ansi.Strip(body); got != "foo\nbar 6" { - t.Fatalf("visible=%q want %q\nraw=%q", got, "foo\nbar 6", body) - } - rows := strings.Split(body, "\n") - if len(rows) != 2 || !strings.Contains(rows[1], "31") { - t.Errorf("red not carried onto line 2, raw=%q", body) - } - if !strings.Contains(body, greenFg) { - t.Errorf("no highlight, raw=%q", body) - } -} - -func TestRenderAddedLineFullyHighlighted(t *testing.T) { - body := render("a", "a\nNEW") - if got := ansi.Strip(body); got != "a\nNEW" { - t.Fatalf("visible=%q want %q\nraw=%q", got, "a\nNEW", body) - } - rows := strings.Split(body, "\n") - if len(rows) != 2 || !strings.Contains(rows[1], greenFg) { - t.Errorf("added line not highlighted, raw=%q", body) - } -} - -func TestRenderColorOnlyChangeIsNotADiff(t *testing.T) { - // same visible text, different color -> not a change; shown with the new color, no highlight. - body := render("\x1b[31mERR\x1b[0m", "\x1b[32mERR\x1b[0m") - if got := ansi.Strip(body); got != "ERR" { - t.Fatalf("visible=%q want %q\nraw=%q", got, "ERR", body) - } - if strings.Contains(body, greenFg) { - t.Errorf("color-only change must not be highlighted, raw=%q", body) - } - if !strings.Contains(body, "32") { - t.Errorf("new color not shown, raw=%q", body) - } -} diff --git a/internal/tui/framemodel.go b/internal/tui/framemodel.go deleted file mode 100644 index e5f84b8..0000000 --- a/internal/tui/framemodel.go +++ /dev/null @@ -1,95 +0,0 @@ -package tui - -import ( - "fmt" - - "github.com/charmbracelet/x/ansi" - - "github.com/ivoronin/wch/internal/diff" - "github.com/ivoronin/wch/internal/session" - "github.com/ivoronin/wch/internal/tui/diffrender" - "github.com/ivoronin/wch/internal/tui/scrollview" -) - -// FrameViewModel owns the rendered viewport: it turns a history Execution into a styled -// body (Frame) and commits a body to the viewport with optional anchor preservation -// (ShowAnchored). The embedded scrollview provides navigation, geometry queries, and -// scrollbar predicates directly on the type, so callers reach for the viewport through -// one seam. The state interface returns "what to display" (state.Body); FrameViewModel -// decides "how to display it". -type FrameViewModel struct { - scrollview.Scrollview - session *session.Session -} - -// newFrameViewModel constructs the type with a zero-sized viewport; geometry comes from -// the first WindowSizeMsg via SetSize (promoted from the embedded scrollview). -func newFrameViewModel(s *session.Session) FrameViewModel { - return FrameViewModel{ - Scrollview: scrollview.NewScrollview(0, 0), - session: s, - } -} - -// Frame renders the styled body for history index i: command output, optional diff -// highlights against the previous recorded frame (when diffEnabled), and an exit-code -// annotation for non-zero exits. Out-of-range i returns "" so callers can treat it as -// "nothing to display" without a separate predicate. -func (f *FrameViewModel) Frame(i int, diffEnabled bool) string { - if i < 0 || i >= len(f.session.History) { - return "" - } - exec := f.session.History[i] - output := exec.Output() - body := output - if diffEnabled && i > 0 { - align := diff.Align(ansi.Strip(f.session.History[i-1].Output()), ansi.Strip(output)) - body = diffrender.Render(align.Lines(), output, insertFg) - } - if exec.Error != nil && exec.ExitCode != 0 { - annot := errorStyle.Render(fmt.Sprintf("Exit code: %d", exec.ExitCode)) - if body == "" { - body = annot - } else { - body += "\n" + annot - } - } - return body -} - -// ShowAnchored commits newBody to the viewport while preserving the user's -// eye-on-line invariant relative to prevBody: at the top/bottom edges the -// sticky-edge rule wins (YOffset=0 / GotoBottom); in the middle, diff.Align + -// MapLine translates the old YOffset to its new line after inserts/deletes -// shift content above. Used on every site where the displayed frame changes -// while the cursor (historyIndex) advances or moves: per-exec dispatch and -// per-cursor-move. -// -// Trade-off note: when called as part of a per-exec repaint, this re-derives -// the diff alignment that Frame's diff highlight already computed once for the -// same pair of frames. The dedup gate in Model.dispatchExec keeps duplicate -// ticks from reaching here, so the duplicate only runs on real frame changes -// -- the same frequency the body would have re-aligned at anyway. Resist -// threading a precomputed alignment back through: it poisons the signature -// with a parameter only one caller can supply. -func (f *FrameViewModel) ShowAnchored(newBody, prevBody string) { - atTop := f.YOffset() == 0 - atBottom := f.AtBottom() - - var newOffset int - if !atTop && !atBottom { - anchor := diff.Align(ansi.Strip(prevBody), ansi.Strip(newBody)) - newOffset = anchor.MapLine(f.YOffset()) - } - - f.SetContent(newBody) - - switch { - case atTop: - f.SetYOffset(0) - case atBottom: - f.GotoBottom() - default: - f.SetYOffset(newOffset) - } -} diff --git a/internal/tui/framemodel_test.go b/internal/tui/framemodel_test.go deleted file mode 100644 index d0b21f7..0000000 --- a/internal/tui/framemodel_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package tui - -import ( - "testing" - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/ivoronin/wch/internal/session" -) - -// A new row inserted at the top while every row's age ticks must keep the mid-screen row -// pinned under the top edge (the core fix). -func TestAnchorKeepsRowOnPrepend(t *testing.T) { - old := podNames(20) - updated := append([]string{"pod-00"}, old...) - - m := newSizedModel(t, podTable("5m", old)) - m.frames.SetYOffset(5) - if m.frames.YOffset() != 5 { - t.Fatalf("setup offset=%d want 5", m.frames.YOffset()) - } - - m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("6m", updated)}}) - if got := m.frames.YOffset(); got != 6 { - t.Errorf("anchored offset=%d want 6 (pod-05 shifted down by inserted pod-00)", got) - } -} - -// At the very top, newly prepended rows stay visible (sticky top wins over the anchor). -func TestStickyTop(t *testing.T) { - old := podNames(20) - updated := append([]string{"pod-00"}, old...) - - m := newSizedModel(t, podTable("5m", old)) - if m.frames.YOffset() != 0 { - t.Fatalf("setup offset=%d want 0", m.frames.YOffset()) - } - - m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("6m", updated)}}) - if got := m.frames.YOffset(); got != 0 { - t.Errorf("sticky top offset=%d want 0", got) - } -} - -// While viewing a past frame, its highlights are intrinsic (diff vs the previous recorded -// frame) and must survive a command run. -func TestHistoryHighlightsSurviveRefresh(t *testing.T) { - names := podNames(3) - m := New(Config{Command: "x", Interval: time.Second, DiffEnabled: true}) - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("5m", names)}}) - m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("6m", names)}}) - m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("7m", names)}}) - - m = m.withCursor(1) - if m.isFollowing() { - t.Fatal("setup: expected to be viewing a past frame, not following") - } - - plain := m - plain.prefs.Diff = false - before := m.frames.Frame(1, m.prefs.Diff) - if before == plain.frames.Frame(1, plain.prefs.Diff) { - t.Fatalf("frame 1 should be highlighted (diff vs frame 0); body=%q", before) - } - - idx, off := m.cursor.Index(), m.frames.YOffset() - m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("8m", names)}}) - - if m.cursor.Index() != idx { - t.Errorf("historyIndex moved to %d, want %d (frozen while viewing past)", m.cursor.Index(), idx) - } - if got := m.frames.YOffset(); got != off { - t.Errorf("YOffset moved to %d, want %d", got, off) - } - if after := m.frames.Frame(1, m.prefs.Diff); after != before { - t.Errorf("highlights reset after refresh:\n before=%q\n after =%q", before, after) - } -} - -// At the very bottom, the view follows the tail on refresh. -func TestStickyBottom(t *testing.T) { - old := podNames(20) - updated := append(podNames(20), "pod-21") - - m := newSizedModel(t, podTable("5m", old)) - m.frames.GotoBottom() - if !m.frames.AtBottom() { - t.Fatalf("setup: expected to start at bottom") - } - - m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("6m", updated)}}) - if !m.frames.AtBottom() { - t.Errorf("sticky bottom: expected to follow the tail, offset=%d", m.frames.YOffset()) - } -} diff --git a/internal/tui/help.go b/internal/tui/help.go deleted file mode 100644 index 7a076e3..0000000 --- a/internal/tui/help.go +++ /dev/null @@ -1,123 +0,0 @@ -package tui - -import ( - "fmt" - "strings" - - "charm.land/lipgloss/v2" -) - -// helpBinding is a single entry in the help overlay's keybinding table. keys is the -// human-readable key (or composite like "Enter b" / "Shift+←→"); desc is the description. -type helpBinding struct { - keys string - desc string -} - -// helpSection groups bindings under a heading. -type helpSection struct { - title string - bindings []helpBinding -} - -// helpSections returns the static keybinding reference shown in the help overlay. Not -// auto-derived from keys.go: composite key strings ("Enter b", "Home End", "↑↓") don't fit -// key.Binding cleanly, and the overlay's descriptions can be richer than the terse bar -// hints. Adding a binding here is the cost of having the overlay read as documentation. -func helpSections() []helpSection { - return []helpSection{ - {"Global", []helpBinding{ - {"q", "quit"}, - {"t", "toggle status bar"}, - {"h", "this help"}, - {"↑↓", "scroll up/down"}, - {"←→", "scroll left/right"}, - {"PgUp PgDn", "page up/down"}, - {"Home End", "top/bottom"}, - {"Shift+←→", "page horizontal"}, - }}, - {"View", []helpBinding{ - {"d", "toggle diff"}, - {"p", "pause"}, - {"r", "record"}, - {"/", "search"}, - {"b", "history"}, - {"Esc", "jump to live tail"}, - }}, - {"Picker", []helpBinding{ - {"←→", "frame ±1"}, - {"Home End", "first/last"}, - {"Enter b", "confirm"}, - {"Esc", "back to view"}, - }}, - {"Search", []helpBinding{ - {"n", "next match"}, - {"N", "prev match"}, - {"/", "new search"}, - {"Esc", "back"}, - }}, - {"Input", []helpBinding{ - {"Enter", "submit"}, - {"Esc", "cancel"}, - }}, - } -} - -// renderHelpPanel composes the centered help overlay: a two-column layout (Global+View on -// the left, Picker+Search+Input on the right) wrapped in a rounded border with no fill. A -// dismiss tip ("h to close") is embedded in the bottom border, centered, so it doesn't eat -// vertical space inside the panel. -func renderHelpPanel() string { - sections := helpSections() - body := lipgloss.JoinHorizontal(lipgloss.Top, - renderHelpColumn(sections[:2]), - helpColumnGap, - renderHelpColumn(sections[2:]), - ) - return embedBottomBorderTip(helpPanelStyle.Render(body), "h to close") -} - -// embedBottomBorderTip rewrites the panel's last (rounded-border) line, replacing its -// horizontal-dash run with `` centred between the corners. Falls back to the -// unmodified panel if the tip can't fit with at least one dash on each side. -func embedBottomBorderTip(panel, tip string) string { - lines := strings.Split(panel, "\n") - if len(lines) == 0 { - return panel - } - bottom := lines[len(lines)-1] - bottomW := lipgloss.Width(bottom) - // Need room for: 2 corners + 1-cell pad each side of the tip. - if bottomW < lipgloss.Width(tip)+4 { - return panel - } - paddedTip := lipgloss.NewStyle().Padding(0, 1).Render(tip) - inner := lipgloss.PlaceHorizontal(bottomW-2, lipgloss.Center, paddedTip, - lipgloss.WithWhitespaceChars("─")) - lines[len(lines)-1] = "╰" + inner + "╯" - return strings.Join(lines, "\n") -} - -// renderHelpColumn renders a vertical stack of sections as " " rows, -// keys padded to the widest key in the column so descriptions align. -func renderHelpColumn(sections []helpSection) string { - keyWidth := 0 - for _, s := range sections { - for _, b := range s.bindings { - keyWidth = max(keyWidth, lipgloss.Width(b.keys)) - } - } - keyCell := lipgloss.NewStyle().Bold(true).Width(keyWidth) - - var lines []string - for i, s := range sections { - if i > 0 { - lines = append(lines, "") - } - lines = append(lines, s.title) - for _, b := range s.bindings { - lines = append(lines, fmt.Sprintf(" %s %s", keyCell.Render(b.keys), b.desc)) - } - } - return strings.Join(lines, "\n") -} diff --git a/internal/tui/help_test.go b/internal/tui/help_test.go deleted file mode 100644 index dfff21d..0000000 --- a/internal/tui/help_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package tui - -import ( - "strings" - "testing" - "time" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - - "github.com/ivoronin/wch/internal/session" -) - -// helpSections must have no empty fields, no duplicate keys within a section, and a -// non-empty section list. Cheap structural invariants that catch typos at PR time. -func TestHelpSectionsValid(t *testing.T) { - sections := helpSections() - if len(sections) == 0 { - t.Fatalf("helpSections() returned no sections") - } - for _, s := range sections { - if s.title == "" { - t.Errorf("section with empty title: %+v", s) - } - if len(s.bindings) == 0 { - t.Errorf("section %q has no bindings", s.title) - } - seen := map[string]bool{} - for _, b := range s.bindings { - if b.keys == "" { - t.Errorf("section %q: empty keys in %+v", s.title, b) - } - if b.desc == "" { - t.Errorf("section %q: empty desc for keys %q", s.title, b.keys) - } - if seen[b.keys] { - t.Errorf("section %q: duplicate keys %q", s.title, b.keys) - } - seen[b.keys] = true - } - } -} - -// renderHelpPanel must fit a typical 80×24 terminal. Tightest reasonable bound: width ≤ 72 -// (leaves 4-cell margin per side), height ≤ 24 (fits exactly on a 24-row terminal). -func TestRenderHelpPanelFitsTypicalTerminal(t *testing.T) { - panel := renderHelpPanel() - w := lipgloss.Width(panel) - h := lipgloss.Height(panel) - if w > 72 { - t.Errorf("panel width = %d, want <= 72 (leaves margin in 80-col terminal)", w) - } - if h > 24 { - t.Errorf("panel height = %d, want <= 24 (fits 24-row terminal)", h) - } -} - -func TestRenderHelpPanelContainsAllSections(t *testing.T) { - plain := ansi.Strip(renderHelpPanel()) - for _, s := range helpSections() { - if !strings.Contains(plain, s.title) { - t.Errorf("rendered panel missing section %q", s.title) - } - } -} - -// Keys should carry SGR Bold (attribute 1). Lipgloss may compose it with fg/bg into a -// multi-attribute sequence like "\x1b[1;37;40m", so check for the bold attribute embedded -// in any of the standard SGR shapes. -func TestRenderHelpPanelBoldKeys(t *testing.T) { - out := renderHelpPanel() - if !strings.Contains(out, "\x1b[1m") && !strings.Contains(out, "\x1b[1;") { - t.Errorf("expected at least one SGR-Bold span in the panel; got: %q", out) - } -} - -// Pressing 'h' from viewState toggles the help overlay flag on Model. A second 'h' closes it. -func TestHelpKeyToggles(t *testing.T) { - m := New(Config{Command: "x", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 80, Height: 24}) - if m.prefs.HelpVisible { - t.Fatalf("setup: helpVisible should start false") - } - - m = pressKey(t, m, 'h') - if !m.prefs.HelpVisible { - t.Errorf("after first h, helpVisible = false; want true") - } - - m = pressKey(t, m, 'h') - if m.prefs.HelpVisible { - t.Errorf("after second h, helpVisible = true; want false (toggle)") - } -} - -// While helpVisible, other keys still reach the active state's handler — the overlay does -// not block dispatch. Pressing 'b' transitions to picker; the overlay stays open. -func TestHelpStickyDuringModeAction(t *testing.T) { - m := New(Config{Command: "x", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 80, Height: 24}) - m = feed(t, m, execResultMsg{exec: session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC), Stdout: "frame\n", - }}) - - m = pressKey(t, m, 'h') - m = pressKey(t, m, 'b') // open picker - - if _, ok := m.state.(pickerState); !ok { - t.Errorf("after b with help open, state = %T, want pickerState", m.state) - } - if !m.prefs.HelpVisible { - t.Errorf("help overlay should stay open while user transitions to picker") - } -} - -// Pressing 'q' while help is open quits the app — global key dispatch is unaffected by -// the overlay. -func TestHelpQuitPassthrough(t *testing.T) { - m := New(Config{Command: "x", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 80, Height: 24}) - m = pressKey(t, m, 'h') - - _, cmd := m.dispatchKey(tea.KeyPressMsg{Code: 'q', Text: "q"}) - if cmd == nil { - t.Fatalf("expected tea.Quit cmd from q with help visible") - } - if msg := cmd(); msg != (tea.QuitMsg{}) { - t.Errorf("q cmd produced %T, want tea.QuitMsg{}", msg) - } -} - -// In inputState, every key (including 'h') is consumed by the textinput. Help does NOT -// open, and the 'h' character lands in the input's value. -func TestHelpKeyInInputModeTypesLiteral(t *testing.T) { - m := New(Config{Command: "x", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 80, Height: 24}) - m = pressKey(t, m, 'r') // open record input - if _, ok := m.state.(inputState); !ok { - t.Fatalf("setup: expected inputState after r, got %T", m.state) - } - before := m.state.(inputState).input.Value() - - m = pressKey(t, m, 'h') - - if m.prefs.HelpVisible { - t.Errorf("helpVisible should stay false when h is pressed inside inputState") - } - if got := m.state.(inputState).input.Value(); !strings.HasSuffix(got, "h") || got == before { - t.Errorf("h keystroke should have appended to input value; before=%q after=%q", before, got) - } -} - -func TestHelpOverlayRendersInView(t *testing.T) { - m := New(Config{Command: "x", Interval: time.Second}) - m.ready = true - m = feed(t, m, tea.WindowSizeMsg{Width: 100, Height: 30}) - m = pressKey(t, m, 'h') - - plain := ansi.Strip(m.View().Content) - for _, section := range []string{"Global", "View", "Picker", "Search", "Input"} { - if !strings.Contains(plain, section) { - t.Errorf("rendered View should contain section %q with help visible; got:\n%s", section, plain) - } - } -} diff --git a/internal/tui/helprender/helprender.go b/internal/tui/helprender/helprender.go deleted file mode 100644 index 787a065..0000000 --- a/internal/tui/helprender/helprender.go +++ /dev/null @@ -1,25 +0,0 @@ -// Package helprender composes a centered overlay panel onto pre-rendered terminal content. -// It owns the centering math; the cellbuf composition is delegated to internal/tui/overlay's -// Sprite primitive. The panel itself is built elsewhere (in tui.renderHelpPanel) — this -// package only knows how to paint a string at the center of another string of given -// dimensions, with edge clipping when the panel is larger than the canvas. -package helprender - -import ( - "charm.land/lipgloss/v2" - - "github.com/ivoronin/wch/internal/tui/overlay" -) - -// Overlay returns content with panel painted on top, centered within (width, height). The -// panel's existing styling (borders, bold spans, etc.) is preserved via cellbuf's nested-SGR -// handling. When the panel exceeds either canvas dimension, its right/bottom is clipped — -// no scrolling, no relayout. Empty panel or non-positive dimensions return content as-is. -func Overlay(content, panel string, width, height int) string { - if panel == "" || width <= 0 || height <= 0 { - return content - } - x := max(0, (width-lipgloss.Width(panel))/2) - y := max(0, (height-lipgloss.Height(panel))/2) - return overlay.Sprite(content, width, height, panel, x, y) -} diff --git a/internal/tui/helprender/helprender_test.go b/internal/tui/helprender/helprender_test.go deleted file mode 100644 index 8fbca4a..0000000 --- a/internal/tui/helprender/helprender_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package helprender - -import ( - "strings" - "testing" - - "github.com/charmbracelet/x/ansi" -) - -// Empty panel or zero dimensions short-circuit and return content unchanged. -func TestOverlayShortCircuits(t *testing.T) { - content := "alpha\nbravo\ncharlie\n" - if got := Overlay(content, "", 80, 24); got != content { - t.Errorf("empty panel: got != content") - } - if got := Overlay(content, "panel", 0, 24); got != content { - t.Errorf("zero width: got != content") - } - if got := Overlay(content, "panel", 80, 0); got != content { - t.Errorf("zero height: got != content") - } -} - -// A small panel painted onto a larger canvas lands at the center; cells outside the panel -// footprint stay as the original content. -func TestOverlayCentersPanel(t *testing.T) { - rows := make([]string, 10) - for i := range rows { - rows[i] = strings.Repeat("x", 20) - } - content := strings.Join(rows, "\n") - panel := "ABC\nDEF" // 3×2 - - got := ansi.Strip(Overlay(content, panel, 20, 10)) - lines := strings.Split(got, "\n") - if len(lines) < 10 { - t.Fatalf("output has %d lines, want at least 10", len(lines)) - } - // Panel is 3×2, canvas 20×10 → top-left at ((20-3)/2, (10-2)/2) = (8, 4). - if !strings.HasPrefix(lines[4][8:], "ABC") { - t.Errorf("row 4 col 8: got %q, want prefix \"ABC\"; full row: %q", lines[4][8:], lines[4]) - } - if !strings.HasPrefix(lines[5][8:], "DEF") { - t.Errorf("row 5 col 8: got %q, want prefix \"DEF\"; full row: %q", lines[5][8:], lines[5]) - } - // Row outside the panel keeps the original x-fill. - if lines[0] != strings.Repeat("x", 20) { - t.Errorf("row 0: got %q, want unchanged x-fill", lines[0]) - } -} - -// A panel larger than the canvas gets clipped at the bottom/right; no panic. -func TestOverlayClipsOversizedPanel(t *testing.T) { - defer func() { - if r := recover(); r != nil { - t.Fatalf("Overlay panicked with oversized panel: %v", r) - } - }() - content := strings.Repeat("x", 20) + "\n" + strings.Repeat("x", 20) - // 30×5 panel, canvas 20×2 — way too big in both axes. - panel := strings.Repeat("A", 30) + "\n" + strings.Repeat("B", 30) + "\n" + strings.Repeat("C", 30) + "\n" + strings.Repeat("D", 30) + "\n" + strings.Repeat("E", 30) - out := Overlay(content, panel, 20, 2) - plain := ansi.Strip(out) - if !strings.Contains(plain, "A") { - t.Errorf("expected at least some panel content (e.g. 'A') in output; got %q", plain) - } -} diff --git a/internal/tui/keys.go b/internal/tui/keys.go deleted file mode 100644 index 43039b9..0000000 --- a/internal/tui/keys.go +++ /dev/null @@ -1,88 +0,0 @@ -package tui - -import ( - "slices" - - "charm.land/bubbles/v2/key" -) - -var globalKeys = struct { - Quit key.Binding - ToggleBar key.Binding - Help key.Binding -}{ - Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), - ToggleBar: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "status")), - Help: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "help")), -} - -// navKeys are the viewport-navigation defaults. handleGlobalKey scrolls for any of them -// not intercepted by the active state. pickerState intercepts Left/Right (frame cursor) -// and Home/End (first/last frame); inputState lets the textinput consume arrows for its -// own cursor. -var navKeys = struct { - Left, Right key.Binding - Up, Down key.Binding - PageUp, PageDown key.Binding - Home, End key.Binding - ScrollLeft, ScrollRight key.Binding -}{ - Left: key.NewBinding(key.WithKeys("left"), key.WithHelp("←", "left")), - Right: key.NewBinding(key.WithKeys("right"), key.WithHelp("→", "right")), - Up: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")), - Down: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")), - PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")), - PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "page down")), - Home: key.NewBinding(key.WithKeys("home"), key.WithHelp("home", "top")), - End: key.NewBinding(key.WithKeys("end"), key.WithHelp("end", "bottom")), - ScrollLeft: key.NewBinding(key.WithKeys("shift+left")), - ScrollRight: key.NewBinding(key.WithKeys("shift+right")), -} - -// commonKeys are intercepted with identical semantics in both viewState and pickerState: -// toggle diff/pause, start/stop recording, open search. Held once to avoid duplicating -// the bindings (and the matching switch arms) across both handlers. -var commonKeys = struct { - ToggleDiff key.Binding - Pause key.Binding - Record key.Binding - Search key.Binding - Escape key.Binding -}{ - ToggleDiff: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "diff")), - Pause: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "pause")), - Record: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "record")), - Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")), - Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), -} - -// viewKeys are viewState-specific bindings (entering the picker). -var viewKeys = struct { - Picker key.Binding -}{ - Picker: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "history")), -} - -// pickerKeys are pickerState-specific bindings (confirming a selection). Enter and the -// picker-entry key (b) are symmetric aliases. -var pickerKeys = struct { - Confirm key.Binding -}{ - Confirm: key.NewBinding(key.WithKeys("enter", "b"), key.WithHelp("enter", "confirm")), -} - -// searchKeys are searchState-specific bindings. n/p navigate matches. '/' and Esc reuse -// commonKeys.Search and commonKeys.Escape — same keys, same help, no point duplicating. -var searchKeys = struct { - NavNext key.Binding - NavPrev key.Binding -}{ - NavNext: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "next")), - NavPrev: key.NewBinding(key.WithKeys("N"), key.WithHelp("N", "prev")), -} - -// minimalBarBindings is the canonical bottom-bar trailer: state-specific extras followed -// by the always-visible {Help, Quit} pair. -func minimalBarBindings(extras ...key.Binding) []key.Binding { - return slices.Concat(extras, []key.Binding{globalKeys.Help, globalKeys.Quit}) -} diff --git a/internal/tui/layout.go b/internal/tui/layout.go deleted file mode 100644 index b0a0ad7..0000000 --- a/internal/tui/layout.go +++ /dev/null @@ -1,47 +0,0 @@ -package tui - -import ( - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" -) - -// threeColumnLayout holds computed widths for a three-section horizontal layout. -type threeColumnLayout struct { - leftWidth int // width for left section - rightWidth int // width for right section -} - -// calcThreeColumnLayout computes widths for a three-section layout where -// the center section is centered within the content area. -// totalWidth is the full available width, centerWidth is the width of the centered content. -func calcThreeColumnLayout(totalWidth, centerWidth int) threeColumnLayout { - contentWidth := totalWidth - statusBarStyle.GetHorizontalFrameSize() - leftWidth := (contentWidth - centerWidth) / 2 - rightWidth := contentWidth - leftWidth - centerWidth - return threeColumnLayout{ - leftWidth: leftWidth, - rightWidth: rightWidth, - } -} - -// barLeftZoneWidth returns the width of the left zone of the standard three-column -// status bar (using centerBlockWidth as the center). Callers that want to lay out -// their left content with internal flex (e.g. searchState's query+counter) can -// target the exact zone renderBarLayout uses. -func barLeftZoneWidth(width int) int { - return calcThreeColumnLayout(width, centerBlockWidth).leftWidth -} - -// renderLeft renders content left-aligned within the given width. -// Content is truncated with ellipsis if it exceeds the available width. -func renderLeft(content string, width int) string { - content = ansi.Truncate(content, width, "…") - return lipgloss.NewStyle().Width(width).Render(content) -} - -// renderRight renders content right-aligned within the given width. -// Content is truncated with ellipsis if it exceeds the available width. -func renderRight(content string, width int) string { - content = ansi.Truncate(content, width, "…") - return lipgloss.NewStyle().Width(width).Align(lipgloss.Right).Render(content) -} diff --git a/internal/tui/messages.go b/internal/tui/messages.go deleted file mode 100644 index df31b6e..0000000 --- a/internal/tui/messages.go +++ /dev/null @@ -1,15 +0,0 @@ -// Package tui's intra-update messages. Only true asynchronous events live here: tick -// (timer) and execResult (background runner result). Recording-related messages live with -// the rest of the recording lifecycle in recording.go. State transitions, key intents, and -// scroll commands are direct function calls in the dispatcher chain; not messages. -package tui - -import "github.com/ivoronin/wch/internal/session" - -type ( - // execResultMsg carries the runner's output back to Update after a tick. - execResultMsg struct{ exec session.Execution } - - // tickMsg fires every Config.Interval to schedule the next runner execution. - tickMsg struct{} -) diff --git a/internal/tui/model.go b/internal/tui/model.go deleted file mode 100644 index d040264..0000000 --- a/internal/tui/model.go +++ /dev/null @@ -1,444 +0,0 @@ -package tui - -import ( - "context" - "time" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2/compat" - - "github.com/ivoronin/wch/internal/recording" - "github.com/ivoronin/wch/internal/runner" - "github.com/ivoronin/wch/internal/session" - "github.com/ivoronin/wch/internal/tui/notify" -) - -// Config holds TUI configuration. -type Config struct { - Command string - Interval time.Duration - DiffEnabled bool - ShowStatus bool - NotifyOnChange bool - AutoStart *recording.AutoStartRequest // non-nil: start a recording to this path at launch - MaxHistory int // executions retained in memory; 0 = unlimited -} - -// Model is the Bubble Tea model. Domain (session, runner), infrastructure (viewport, -// notify), cross-state toggles, and the position cursor (historyIndex) live here. UI mode -// is held in m.state — a sealed sum type defined in state.go. -type Model struct { - // Core - session *session.Session - runner *runner.Runner - - // Infrastructure: frames owns the rendered viewport (Frame + ShowAnchored + embedded - // scrollview navigation). State.Body says "what to display"; frames decides "how". - frames FrameViewModel - notify notify.Model - - // Cross-state toggles - prefs Preferences - - // Runtime - executing bool - width int - height int - ready bool - - // Persistence wiring - flow *recording.Flow - autoStart *recording.AutoStartRequest - - // History cursor for view/picker. dispatchExec advances it per state.FollowsTail. - cursor Cursor - - // UI state. Exactly one of {viewState, pickerState, inputState, searchState} at all - // times. Overlay states (input, search) carry prev — the state to restore on Esc. - state state -} - -// notifyTTL is how long a notification bubble stays visible before its Tick expires. -const notifyTTL = time.Second - -// push enqueues a notification bubble and returns the resulting Model + Cmd. All recording- -// status notifications go through here so the wiring stays in one place. -func (m Model) push(level notify.Level, msg string) (Model, tea.Cmd) { - var cmd tea.Cmd - m.notify, cmd = m.notify.Push(msg, level, notifyTTL) - return m, cmd -} - -// New creates a live TUI model that watches cfg.Command. If cfg.AutoStart is non-nil, -// recording to that path starts during Init. -func New(cfg Config) Model { - sess := session.NewSession(cfg.Command, cfg.Interval) - sess.MaxHistory = cfg.MaxHistory - return Model{ - session: sess, - runner: runner.New(cfg.Command), - flow: recording.New(sess), - frames: newFrameViewModel(sess), - cursor: noCursor(), - state: viewState{}, - prefs: Preferences{ - Diff: cfg.DiffEnabled, - StatusBar: cfg.ShowStatus, - OSNotify: cfg.NotifyOnChange, - }, - autoStart: cfg.AutoStart, - notify: notify.New(), - } -} - -// NewReplay creates an offline TUI model replaying a loaded session. No runner, no recording, -// no ticking. Replay-ness is encoded by the nil runner. -func NewReplay(cfg Config, s *session.Session) Model { - return Model{ - session: s, - runner: nil, - flow: recording.New(s), - frames: newFrameViewModel(s), - cursor: cursorAtTail(len(s.History)), - state: viewState{}, - prefs: Preferences{ - Diff: cfg.DiffEnabled, - StatusBar: cfg.ShowStatus, - OSNotify: false, - }, - notify: notify.New(), - } -} - -func (m Model) isLive() bool { return m.runner != nil } - -// Cleanup finalises any resources held by the Model (today: an active recording). Bubble -// Tea v2 short-circuits Model.Update on QuitMsg — Update is never called for that message — -// so the Model has no chance to flush its own teardown. main.go calls Cleanup after p.Run -// returns. Idempotent: flow.Stop is a no-op when no recording is active. -func (m Model) Cleanup() error { - return m.flow.Stop() -} - -// Init starts the TUI. Replay returns nil — no tick is ever scheduled. Live mode kicks off -// the first execution tick and, if AutoStart was configured, begins recording. -func (m Model) Init() tea.Cmd { - bgQuery := tea.RequestBackgroundColor - if !m.isLive() { - return bgQuery - } - tick := func() tea.Msg { return tickMsg{} } - if m.autoStart == nil { - return tea.Batch(bgQuery, tick) - } - path := m.autoStart.Path - return tea.Batch( - bgQuery, - tick, - func() tea.Msg { return autoStartRecordingMsg{path: path} }, - ) -} - -// Update routes messages. Uniform events (resize, quit, tick, exec, autoStart) are handled -// at the top; key and exec dispatch consults the active state. -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var notifyCmd tea.Cmd - m.notify, notifyCmd = m.notify.Update(msg) - - switch msg := msg.(type) { - case tea.KeyPressMsg: - m2, cmd := m.dispatchKey(msg) - return m2, tea.Batch(cmd, notifyCmd) - case tea.WindowSizeMsg: - m2, cmd := m.handleResize(msg) - return m2, tea.Batch(cmd, notifyCmd) - case tickMsg: - m2, cmd := m.handleTick() - return m2, tea.Batch(cmd, notifyCmd) - case execResultMsg: - m2, cmd := m.dispatchExec(msg) - return m2, tea.Batch(cmd, notifyCmd) - case autoStartRecordingMsg: - m2, cmd, _ := m.startRecording(msg.path) - return m2, tea.Batch(cmd, notifyCmd) - case tea.BackgroundColorMsg: - compat.HasDarkBackground = msg.IsDark() - return m, notifyCmd - } - // Anything else (bracketed-paste PasteMsg/PasteStart/PasteEndMsg, cursor.BlinkMsg from - // textinput.Focus, tea.FocusMsg/BlurMsg, etc.) is forwarded to the active inputState's - // textinput when one exists, so its blink loop and focus tracking stay alive across the - // dispatcher. No-op in any other state. - m2, cmd := m.forwardToInputTextinput(msg) - return m2, tea.Batch(cmd, notifyCmd) -} - -// forwardToInputTextinput hands a non-key message to the textinput of the active -// inputState. Used for paste-mode messages (PasteMsg, PasteStart/End) and the -// general fallback (cursor.BlinkMsg from textinput.Focus, FocusMsg/BlurMsg) that -// the key dispatcher can't carry. No-op in any other state. -func (m Model) forwardToInputTextinput(msg tea.Msg) (Model, tea.Cmd) { - s, ok := m.state.(inputState) - if !ok { - return m, nil - } - in, cmd := s.input.Update(msg) - s.input = in - m.state = s - return m, cmd -} - -// dispatchKey is the central key router: the active state gets first dibs via its -// Handle method; if it reports handled=false the global key handler runs (q/t/ -// navigation defaults). When the new state's bar visibility differs from the -// pre-handler state's the viewport is resized; when the state TYPE changes (or bar -// flips) we repaint so any overlay leftover (e.g. searchState's reverse-video -// highlight) is replaced by the new state's content in the same step. -func (m Model) dispatchKey(msg tea.KeyPressMsg) (Model, tea.Cmd) { - barBefore := m.barShown() - stateKindBefore := stateKind(m.state) - nextM, s, cmd, handled := m.state.Handle(m, msg) - m = nextM - m.state = s - barChanged := m.barShown() != barBefore - stateChanged := stateKind(m.state) != stateKindBefore - if barChanged { - m = m.withResizedScrollview() - } - if barChanged || stateChanged { - m = m.repaint() - } - if handled { - return m, cmd - } - return m.handleGlobalKey(msg) -} - -// stateKind returns a small integer tag identifying the concrete state type. Used by -// dispatchKey to detect state transitions without depending on reflect. -func stateKind(s state) int { - switch s.(type) { - case viewState: - return 1 - case pickerState: - return 2 - case inputState: - return 3 - case searchState: - return 4 - } - return 0 -} - -// handleGlobalKey runs the global fall-through bindings: quit, status-bar toggle, and the -// viewport-navigation defaults (arrows/Home/End/PgUp/PgDn/Shift+arrows). Mutations happen -// directly on the viewport; no intra-update messages. -func (m Model) handleGlobalKey(msg tea.KeyPressMsg) (Model, tea.Cmd) { - switch { - case key.Matches(msg, globalKeys.Quit): - // Recording finalisation is owned by main.go's Cleanup() call after p.Run returns — - // Bubble Tea v2 short-circuits QuitMsg before Model.Update sees it, so there is no - // in-Update teardown seam. - return m, tea.Quit - case key.Matches(msg, globalKeys.ToggleBar): - m.prefs.StatusBar = !m.prefs.StatusBar - m = m.withResizedScrollview() - m = m.repaint() - return m, nil - case key.Matches(msg, globalKeys.Help): - m.prefs.HelpVisible = !m.prefs.HelpVisible - return m, nil - case key.Matches(msg, navKeys.Up): - m.frames.ScrollUp(1) - case key.Matches(msg, navKeys.Down): - m.frames.ScrollDown(1) - case key.Matches(msg, navKeys.Left): - m.frames.ScrollLeft() - case key.Matches(msg, navKeys.Right): - m.frames.ScrollRight() - case key.Matches(msg, navKeys.PageUp): - m.frames.PageUp() - case key.Matches(msg, navKeys.PageDown): - m.frames.PageDown() - case key.Matches(msg, navKeys.ScrollLeft): - m.frames.ScrollLeftPage() - case key.Matches(msg, navKeys.ScrollRight): - m.frames.ScrollRightPage() - case key.Matches(msg, navKeys.Home): - m.frames.GotoLeftEdge() - case key.Matches(msg, navKeys.End): - m.frames.GotoRightEdge() - } - return m, nil -} - -// dispatchExec appends the new execution, advances historyIndex if the active state's -// follow policy says so, and refreshes the viewport. view/picker anchor against the previous -// output; search owns its frozen body; input is transparent and delegates both freeze and -// follow policy to its prev. -func (m Model) dispatchExec(msg execResultMsg) (Model, tea.Cmd) { - m.executing = false - - prev, _ := m.state.Body(m) - prior := m.cursor - wasAtTail := m.isFollowing() - added, evicted, err := m.session.RecordIfChanged(msg.exec) - var cmds []tea.Cmd - if err != nil { - var c tea.Cmd - m, c = m.push(notify.LevelWarning, "Recording error: "+err.Error()) - cmds = append(cmds, c) - } - // MaxHistory eviction shifted every slot index down by 1. For a non-tail viewer the - // cursor must follow so the user keeps reading the same frame (clamped to 0 when the - // frame they were on was the one evicted). - if evicted { - m.cursor = m.cursor.AfterEvict() - } - if added && m.state.FollowsTail(wasAtTail) { - m.cursor = m.cursor.ToTail(len(m.session.History)) - } - // Skip the full diff/anchor recomputation when nothing the viewport derives from has - // changed (duplicate tick: !added && cursor didn't move). Important for the wch - // primary use case — watching slow-changing kubectl output, where most ticks are dedup'd. - if added || m.cursor != prior { - m = m.repaintAnchored(prev) - } - // prior.Valid() gates the very first frame (when there was no prior to compare against). - // This replaces the older len(History) > 1 gate, which was unreachable when MaxHistory==1. - if m.prefs.OSNotify && added && prior.Valid() { - cmds = append(cmds, sendNotification()) - } - cmds = append(cmds, m.scheduleNextTick()) - return m, tea.Batch(cmds...) -} - -// repaint asks the active state for its body and commits it to the viewport without -// anchor preservation. Used by handleResize, handleGlobalKey's bar toggle, and dispatchKey -// on a state transition — the cursor (historyIndex) hasn't moved, so the eye-on-line -// invariant doesn't apply. -func (m Model) repaint() Model { - body, ok := m.state.Body(m) - if !ok { - return m - } - m.frames.SetContent(body) - return m -} - -// snapTarget describes a viewport scroll-to-cell effect that follows a paint. Used by -// searchState for its event-driven snap-to-match (search entry and n/p navigation) -- -// kept outside the state interface because Body has no way to know whether the call -// context is event-triggered (snap wanted) or geometry-driven (snap unwanted). -type snapTarget struct { - line, col, length int -} - -// repaintWith commits s.Body(m) to the viewport and, when snap is non-nil, scrolls so -// the (line, col, length) region is visible. Used at sites where the freshly-built -// state isn't yet installed on Model (search entry constructing a searchState; n/p -// navigation mutating the searchState locally before returning it). When snap is nil, -// behaves like repaint applied to s. -func (m Model) repaintWith(s state, snap *snapTarget) Model { - body, ok := s.Body(m) - if !ok { - return m - } - m.frames.SetContent(body) - if snap != nil { - m.frames.EnsureLineVisible(snap.line) - m.frames.EnsureColumnVisible(snap.col, snap.length) - } - return m -} - -// repaintAnchored asks the active state for its body and commits it via ShowAnchored -// against prev. States that report IsFrozen (search; input over search) own their frozen -// body and are left untouched. -func (m Model) repaintAnchored(prev string) Model { - if !m.cursor.Valid() { - return m - } - if m.state.IsFrozen() { - return m - } - body, ok := m.state.Body(m) - if !ok { - return m - } - m.frames.ShowAnchored(body, prev) - return m -} - -// handleTick processes the periodic execution tick. -func (m Model) handleTick() (Model, tea.Cmd) { - if m.prefs.Paused { - return m, m.scheduleNextTick() - } - m.executing = true - return m, m.executeCmd() -} - -// handleResize responds to terminal size changes. The viewport geometry is updated, then -// the content is re-committed via the active state's Body (state-aware: searchState -// re-overlays its captured body with the highlight; view/picker re-derive from -// historyIndex; input delegates to its prev). Scroll position is preserved — no snap. -func (m Model) handleResize(msg tea.WindowSizeMsg) (Model, tea.Cmd) { - m.width = msg.Width - m.height = msg.Height - m.ready = true - m = m.withResizedScrollview() - m = m.repaint() - // ClearScreen forces a full terminal redraw, preventing visual artifacts in terminal - // multiplexers like Zellij that don't handle Bubble Tea's differential rendering - // correctly on resize. - return m, tea.ClearScreen -} - -// executeCmd runs the command and returns the result as a message. -func (m Model) executeCmd() tea.Cmd { - return func() tea.Msg { - return execResultMsg{exec: m.runner.Execute(context.Background())} - } -} - -// scheduleNextTick schedules the next execution tick. -func (m Model) scheduleNextTick() tea.Cmd { - return tea.Tick(m.session.Interval, func(time.Time) tea.Msg { - return tickMsg{} - }) -} - -// withResizedScrollview returns a model with updated viewport dimensions. -func (m Model) withResizedScrollview() Model { - w, h := m.width, m.height - if m.barShown() { - h-- - } - m.frames.SetSize(w, h) - return m -} - -// withCursor returns a model with the cursor moved and content updated. Clamped no-op moves -// (e.g. pressing Right at the tail or Left at the head) short-circuit so we don't run a -// full Strip+Align+SetContent for an identity re-render on every boundary keypress. -func (m Model) withCursor(newIdx int) Model { - moved := m.cursor.Move(newIdx, len(m.session.History)) - if moved == m.cursor { - return m - } - prev, _ := m.state.Body(m) - m.cursor = moved - return m.repaintAnchored(prev) -} - -// isFollowing returns true if viewing the latest history item. -func (m Model) isFollowing() bool { - return m.cursor.Following(len(m.session.History)) -} - -// sendNotification sends an OSC9 notification via raw terminal output. -func sendNotification() tea.Cmd { - return tea.Raw("\033]9;wch: output changed\a") -} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go deleted file mode 100644 index 1e63eb0..0000000 --- a/internal/tui/model_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package tui - -import ( - "strings" - "testing" - "time" - - tea "charm.land/bubbletea/v2" -) - -// NewReplay never schedules a tick and renders the latest frame after the first WindowSizeMsg. -// Init may return a non-nil cmd (e.g. background-color query) but must never schedule a tick. -func TestNewReplayInitNoTickAndRendersLatest(t *testing.T) { - s := preloadedReplaySession(3) - m := NewReplay(Config{}, s) - - if cmd := m.Init(); cmd != nil { - if msg := cmd(); msg != nil { - if _, isTick := msg.(tickMsg); isTick { - t.Errorf("replay Init() must not schedule a tick") - } - } - } - if m.isLive() { - t.Errorf("replay isLive() should be false") - } - if m.cursor.Index() != 2 { - t.Errorf("historyIndex=%d want 2 (last frame)", m.cursor.Index()) - } - - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - if !m.ready { - t.Errorf("Model.ready should be true after WindowSizeMsg") - } - body := m.frames.Frame(m.cursor.Index(), m.prefs.Diff) - if !strings.Contains(body, "frame 2") { - t.Errorf("expected latest frame rendered; got %q", body) - } -} - -// 'q' falls through from viewState's Handle to handleGlobalKey for the Quit cmd. -func TestHandleKeyPriorityFallsThroughToGlobal(t *testing.T) { - m := New(Config{Command: "x", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - - _, cmd := m.dispatchKey(tea.KeyPressMsg{Code: 'q', Text: "q"}) - if cmd == nil { - t.Fatalf("expected tea.Quit cmd from q-key fallthrough") - } - if msg := cmd(); msg != (tea.QuitMsg{}) { - t.Errorf("q-key cmd produced %T (%v), want tea.QuitMsg{}", msg, msg) - } -} diff --git a/internal/tui/notify/notify.go b/internal/tui/notify/notify.go deleted file mode 100644 index ba45814..0000000 --- a/internal/tui/notify/notify.go +++ /dev/null @@ -1,110 +0,0 @@ -// Package notify renders transient corner notifications ("bubbles") as overlays on top of -// existing Bubble Tea views. A new bubble pushed via Push appears in the configured corner; -// each carries its own timer Cmd that, threaded back through Update, removes the bubble -// after the requested TTL. Use Overlay to compose the bubble stack onto your own View -// output without disrupting underlying content. -package notify - -import ( - "slices" - "time" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -// Level categorizes a notification. Default styling tints the border accordingly. -type Level int - -const ( - LevelInfo Level = iota - LevelWarning -) - -// Position picks the corner the stack grows from. Newest bubble appears closest to the -// corner; older ones slide outward. -type Position int - -const ( - BottomRight Position = iota - BottomLeft - TopRight - TopLeft -) - -// defaultMaxVisible caps the visible stack so a flood of notifications doesn't cover the view. -const defaultMaxVisible = 5 - -// Model is a stack of transient notifications. -type Model struct { - bubbles []bubble - position Position - maxVisible int - nextID int - styles map[Level]lipgloss.Style -} - -// bubble is one notification in the stack. -type bubble struct { - id int - message string - level Level -} - -// expireMsg is the only message this package emits via tea.Tick; Update consumes it. -type expireMsg struct{ id int } - -// Option configures a Model at construction. -type Option func(*Model) - -// WithPosition picks the stack corner. -func WithPosition(p Position) Option { return func(m *Model) { m.position = p } } - -// WithMaxVisible caps the number of simultaneous bubbles. n ≤ 0 disables the cap. -func WithMaxVisible(n int) Option { return func(m *Model) { m.maxVisible = n } } - -// New constructs a Model. Defaults: BottomRight, maxVisible=5; per-level styling is set -// by defaultStyles (info untinted, warning yellow-bordered). -func New(opts ...Option) Model { - m := Model{ - position: BottomRight, - maxVisible: defaultMaxVisible, - styles: defaultStyles(), - } - for _, o := range opts { - o(&m) - } - return m -} - -// Push enqueues a notification. The returned Cmd fires after ttl; threading it through -// Update will remove the matching bubble. -func (m Model) Push(message string, level Level, ttl time.Duration) (Model, tea.Cmd) { - m.nextID++ - id := m.nextID - m.bubbles = append(m.bubbles, bubble{id: id, message: message, level: level}) - // Evict the oldest if over the visible cap. - if m.maxVisible > 0 && len(m.bubbles) > m.maxVisible { - m.bubbles = m.bubbles[len(m.bubbles)-m.maxVisible:] - } - return m, tea.Tick(ttl, func(time.Time) tea.Msg { - return expireMsg{id: id} - }) -} - -// Update consumes the internal expiry messages. Foreign messages return the model unchanged. -func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { - exp, ok := msg.(expireMsg) - if !ok { - return m, nil - } - if i := slices.IndexFunc(m.bubbles, func(b bubble) bool { return b.id == exp.id }); i >= 0 { - m.bubbles = slices.Delete(m.bubbles, i, i+1) - } - return m, nil -} - -// Active reports whether any bubble is currently being shown. -func (m Model) Active() bool { - return len(m.bubbles) > 0 -} diff --git a/internal/tui/notify/notify_test.go b/internal/tui/notify/notify_test.go deleted file mode 100644 index de01088..0000000 --- a/internal/tui/notify/notify_test.go +++ /dev/null @@ -1,248 +0,0 @@ -package notify - -import ( - "strings" - "testing" - "time" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" -) - -// Push schedules a Tick that, when fed back through Update, removes the bubble. -func TestPushExpireRoundTrip(t *testing.T) { - m := New() - var cmd tea.Cmd - m, cmd = m.Push("hi", LevelInfo, 50*time.Millisecond) - if !m.Active() { - t.Fatalf("expected Active() == true after Push") - } - if cmd == nil { - t.Fatalf("Push did not return a Cmd") - } - - msg := cmd() - exp, ok := msg.(expireMsg) - if !ok { - t.Fatalf("Tick produced %T, want expireMsg", msg) - } - - m, _ = m.Update(exp) - if m.Active() { - t.Errorf("expected Active() == false after matching expireMsg") - } -} - -// A stale expireMsg (id no longer in the stack) is a harmless no-op — it must not wipe a -// newer bubble. -func TestStaleExpireIgnored(t *testing.T) { - m := New() - var firstCmd tea.Cmd - m, firstCmd = m.Push("first", LevelInfo, time.Second) - staleExp := firstCmd().(expireMsg) - - // Remove the first bubble explicitly, then push a newer one. - m, _ = m.Update(staleExp) - m, _ = m.Push("second", LevelInfo, time.Second) - if !m.Active() { - t.Fatalf("setup: second bubble should be active") - } - - // Fire the stale expire again — must not affect the new bubble. - m, _ = m.Update(staleExp) - if !m.Active() { - t.Errorf("stale expire wiped a newer bubble") - } -} - -// WithMaxVisible caps the stack; oldest bubbles are evicted on overflow. Stale expires for -// evicted ones are no-ops. -func TestMaxVisibleEviction(t *testing.T) { - m := New(WithMaxVisible(2)) - var cmd1 tea.Cmd - m, cmd1 = m.Push("one", LevelInfo, time.Second) - m, _ = m.Push("two", LevelInfo, time.Second) - m, _ = m.Push("three", LevelInfo, time.Second) - - if got := len(m.bubbles); got != 2 { - t.Fatalf("len(bubbles)=%d, want 2 (cap)", got) - } - if m.bubbles[0].message != "two" || m.bubbles[1].message != "three" { - t.Errorf("retained bubbles=%q,%q; want two,three (oldest evicted)", - m.bubbles[0].message, m.bubbles[1].message) - } - // Fire the (now stale) expire of "one" — must not change the cap. - m, _ = m.Update(cmd1().(expireMsg)) - if got := len(m.bubbles); got != 2 { - t.Errorf("evicted-bubble expire mutated stack: len=%d, want 2", got) - } -} - -// Update returns the model untouched for foreign messages. -func TestUpdateForeignMessages(t *testing.T) { - m := New() - m, _ = m.Push("hi", LevelInfo, time.Second) - wantLen := len(m.bubbles) - for _, msg := range []tea.Msg{ - tea.WindowSizeMsg{Width: 80, Height: 24}, - tea.KeyPressMsg{Code: 'q', Text: "q"}, - tea.QuitMsg{}, - } { - m, _ = m.Update(msg) - if got := len(m.bubbles); got != wantLen { - t.Errorf("foreign %T mutated stack: len=%d want %d", msg, got, wantLen) - } - } -} - -// Overlay places the sprite in the configured corner. -func TestOverlayPlacement(t *testing.T) { - const baseW, baseH = 60, 12 - - base := buildBase(baseW, baseH, '.') - - cases := []struct { - pos Position - nameContains string - checkCorner func(t *testing.T, lines []string) - }{ - { - pos: BottomRight, - nameContains: "BottomRight", - checkCorner: func(t *testing.T, lines []string) { - // Bottom row should not start with the border in column 0. - if strings.HasPrefix(lines[baseH-1], "╰") { - t.Errorf("BottomRight: bubble found at left edge: %q", lines[baseH-1]) - } - // Top-left corner should be untouched. - if !strings.HasPrefix(lines[0], "....") { - t.Errorf("BottomRight: top-left was overwritten: %q", lines[0]) - } - }, - }, - { - pos: BottomLeft, - nameContains: "BottomLeft", - checkCorner: func(t *testing.T, lines []string) { - if !strings.HasPrefix(lines[baseH-1], "╰") { - t.Errorf("BottomLeft: bubble not at left edge of bottom row: %q", lines[baseH-1]) - } - }, - }, - { - pos: TopRight, - nameContains: "TopRight", - checkCorner: func(t *testing.T, lines []string) { - if strings.HasPrefix(lines[0], "╭") { - t.Errorf("TopRight: bubble found at left edge of top row: %q", lines[0]) - } - if !strings.HasSuffix(strings.TrimRight(lines[0], " "), "╮") { - t.Errorf("TopRight: bubble not at right edge of top row: %q", lines[0]) - } - }, - }, - { - pos: TopLeft, - nameContains: "TopLeft", - checkCorner: func(t *testing.T, lines []string) { - if !strings.HasPrefix(lines[0], "╭") { - t.Errorf("TopLeft: bubble not at left edge of top row: %q", lines[0]) - } - }, - }, - } - for _, c := range cases { - t.Run(c.nameContains, func(t *testing.T) { - m := New(WithPosition(c.pos)) - m, _ = m.Push("hello", LevelInfo, time.Second) - out := m.Overlay(base, baseW, baseH, Insets{}) - stripped := ansi.Strip(out) - lines := strings.Split(stripped, "\n") - if len(lines) != baseH { - t.Fatalf("output has %d lines, want %d", len(lines), baseH) - } - for i, l := range lines { - if visibleWidth(l) != baseW { - t.Errorf("line %d width=%d, want %d (%q)", i, visibleWidth(l), baseW, l) - } - } - c.checkCorner(t, lines) - // The message must be present somewhere. - if !strings.Contains(stripped, "hello") { - t.Errorf("rendered output missing message: %q", stripped) - } - }) - } -} - -// A bubble larger than the available area must clip rather than panic, and the result -// keeps the requested base dimensions. -func TestOverlayClipsAtEdge(t *testing.T) { - const baseW, baseH = 10, 3 - base := buildBase(baseW, baseH, '.') - - m := New() - m, _ = m.Push("a very long notification that exceeds the area dramatically", LevelInfo, time.Second) - out := m.Overlay(base, baseW, baseH, Insets{}) - stripped := ansi.Strip(out) - lines := strings.Split(stripped, "\n") - if len(lines) != baseH { - t.Errorf("expected %d lines, got %d", baseH, len(lines)) - } - for i, l := range lines { - if visibleWidth(l) != baseW { - t.Errorf("line %d width=%d, want %d", i, visibleWidth(l), baseW) - } - } -} - -// Insets push the bubble inward by the requested cells, leaving the reserved edges clear. -func TestOverlayHonoursInsets(t *testing.T) { - const baseW, baseH = 60, 12 - base := buildBase(baseW, baseH, '.') - - m := New(WithPosition(BottomRight)) - m, _ = m.Push("x", LevelInfo, time.Second) - out := m.Overlay(base, baseW, baseH, Insets{Right: 2, Bottom: 3}) - stripped := ansi.Strip(out) - lines := strings.Split(stripped, "\n") - - // Right inset = 2 → rightmost 2 columns of every line must remain "..". - for i, l := range lines { - if !strings.HasSuffix(l, "..") { - t.Errorf("line %d right inset violated: %q", i, l) - } - } - // Bottom inset = 3 → bottom 3 lines must be entirely dots. - for _, idx := range []int{baseH - 1, baseH - 2, baseH - 3} { - if got := lines[idx]; got != strings.Repeat(".", baseW) { - t.Errorf("line %d (bottom inset) was overwritten: %q", idx, got) - } - } -} - -// When no bubble is active, Overlay returns the input verbatim. -func TestOverlayInactiveIsPassthrough(t *testing.T) { - m := New() - base := buildBase(20, 5, '.') - if got := m.Overlay(base, 20, 5, Insets{}); got != base { - t.Errorf("Overlay (inactive) mutated content: %q vs %q", got, base) - } -} - -// --- helpers --------------------------------------------------------------- - -func buildBase(w, h int, ch rune) string { - row := strings.Repeat(string(ch), w) - lines := make([]string, h) - for i := range lines { - lines[i] = row - } - return strings.Join(lines, "\n") -} - -// visibleWidth measures the cell width of a line ignoring ANSI escape sequences. -func visibleWidth(s string) int { - return lipgloss.Width(s) -} diff --git a/internal/tui/notify/render.go b/internal/tui/notify/render.go deleted file mode 100644 index 5420999..0000000 --- a/internal/tui/notify/render.go +++ /dev/null @@ -1,73 +0,0 @@ -package notify - -import ( - "slices" - - "charm.land/lipgloss/v2" - - "github.com/ivoronin/wch/internal/tui/overlay" -) - -// Insets describe how much space to leave clear inside the overlay area between the -// bubbles and each edge. Useful to keep bubbles off fixed UI elements (e.g. a status bar at -// the bottom). -type Insets struct{ Top, Right, Bottom, Left int } - -// Overlay composes the current bubble stack onto content. content is interpreted as a -// styled string of dimensions width × height (terminal cells); the result is the same -// width × height with bubbles laid into the configured corner, respecting insets. Cells -// outside the bubble footprint show through unchanged. -func (m Model) Overlay(content string, width, height int, in Insets) string { - if !m.Active() || width <= 0 || height <= 0 { - return content - } - sprite := m.renderSprite() - sw := lipgloss.Width(sprite) - sh := lipgloss.Height(sprite) - x, y := m.corner(width, height, sw, sh, in) - return overlay.Sprite(content, width, height, sprite, x, y) -} - -// renderSprite produces the styled bubble stack as a single string. All bubbles share the -// same width (the widest in the stack) so the column reads cleanly when stacked. -func (m Model) renderSprite() string { - if len(m.bubbles) == 0 { - return "" - } - // Find the widest message+prefix; pad shorter ones up to that with spaces so each - // bubble renders at identical visible width. - contents := make([]string, len(m.bubbles)) - maxContent := 0 - for i, b := range m.bubbles { - contents[i] = prefixFor(b.level) + b.message - maxContent = max(maxContent, lipgloss.Width(contents[i])) - } - - parts := make([]string, len(m.bubbles)) - for i, b := range m.bubbles { - padded := lipgloss.NewStyle().Width(maxContent).Render(contents[i]) - parts[i] = m.styles[b.level].Render(padded) - } - - // Newest at the corner: that's the natural append order for Bottom* positions; - // reverse for Top* so the newest sits at the top. - if m.position == TopLeft || m.position == TopRight { - slices.Reverse(parts) - } - return lipgloss.JoinVertical(lipgloss.Left, parts...) -} - -// corner returns the (x, y) top-left of the sprite for the configured Position, accounting -// for the given insets so the bubble keeps clear of reserved edges. -func (m Model) corner(baseW, baseH, sw, sh int, in Insets) (int, int) { - switch m.position { - case BottomRight: - return baseW - sw - in.Right, baseH - sh - in.Bottom - case BottomLeft: - return in.Left, baseH - sh - in.Bottom - case TopRight: - return baseW - sw - in.Right, in.Top - default: // TopLeft - return in.Left, in.Top - } -} diff --git a/internal/tui/notify/style.go b/internal/tui/notify/style.go deleted file mode 100644 index 60fa59c..0000000 --- a/internal/tui/notify/style.go +++ /dev/null @@ -1,37 +0,0 @@ -package notify - -import ( - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" -) - -// defaultStyles returns the per-level styles. Info uses the terminal's default colors -// (no tint); Warning gets a yellow-tinted border. The level symbol itself (ℹ / ⚠) is the -// only accented piece — see prefixFor. -func defaultStyles() map[Level]lipgloss.Style { - base := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - Padding(0, 1) - return map[Level]lipgloss.Style{ - LevelInfo: base, - LevelWarning: base.BorderForeground(ansi.Yellow), - } -} - -// Level symbol accents: a small splash of color on the leading glyph so the level reads at -// a glance without tinting the rest of the bubble. -var ( - infoAccent = lipgloss.NewStyle().Foreground(ansi.Cyan) - warningAccent = lipgloss.NewStyle().Foreground(ansi.Yellow) -) - -// prefixFor returns the level-specific icon prefix shown before the message, with its -// accent color baked in. -func prefixFor(l Level) string { - switch l { - case LevelWarning: - return warningAccent.Render("⚠ ") - default: - return infoAccent.Render("ℹ ") - } -} diff --git a/internal/tui/overlay/overlay.go b/internal/tui/overlay/overlay.go deleted file mode 100644 index a4b9a95..0000000 --- a/internal/tui/overlay/overlay.go +++ /dev/null @@ -1,83 +0,0 @@ -// Package overlay owns the cellbuf machinery shared by tui's four renderers (diffrender, -// searchrender, helprender, notify): each one wants to paint styled cells onto a base -// string and read back a styled string. Walk covers the in-place pattern (mutate cells of -// the base buffer); Sprite covers the composition pattern (copy a sprite buffer onto a -// base). Together they collapse the NewBuffer → SetContent → Render → CRLF-strip dance -// to one place, so each renderer keeps only its domain logic (what to highlight, where -// to put the sprite). -package overlay - -import ( - "strings" - - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/cellbuf" -) - -// CellCap bounds the cell grid Walk will build per call. Beyond it, Walk returns the -// base unchanged rather than allocate a huge grid. Empirically large enough for any -// realistic terminal (4M cells ≈ 2000×2000). -const CellCap = 4_000_000 - -// Walk loads base into a cell buffer of (w, h) and calls mutate to paint cells in -// place. Returns the rendered string with cellbuf's CRLF row separators normalized to -// LF. Returns base unchanged when w == 0 or w*h > CellCap. -func Walk(base string, w, h int, mutate func(*cellbuf.Buffer)) string { - if w == 0 || w*h > CellCap { - return base - } - buf := cellbuf.NewBuffer(w, h) - cellbuf.SetContent(buf, base) - mutate(buf) - return render(buf) -} - -// Sprite composes sprite onto base at (x, y) and returns the result. The result has the -// same dimensions as base (baseW, baseH); cells outside the sprite footprint pass -// through unchanged. cellbuf carries SGR state across the merge so the sprite's styling -// survives intact. The base dimensions are caller-supplied so a renderer can declare -// the canvas size that matters to it (e.g. terminal viewport, not the base string's -// natural width). -func Sprite(base string, baseW, baseH int, sprite string, x, y int) string { - buf := cellbuf.NewBuffer(baseW, baseH) - cellbuf.SetContent(buf, base) - - sw, sh := spriteDims(sprite) - spriteBuf := cellbuf.NewBuffer(sw, sh) - cellbuf.SetContent(spriteBuf, sprite) - - for dy := range sh { - for dx := range sw { - c := spriteBuf.Cell(dx, dy) - if c == nil || c.Width == 0 { - continue - } - buf.SetCell(x+dx, y+dy, c) - } - } - return render(buf) -} - -// MaxDisplayWidth returns the widest display-width line in s, in terminal cells. Used -// by Walk callers that derive the cell-buffer width from the base string itself. -func MaxDisplayWidth(s string) int { - w := 0 - for _, line := range strings.Split(s, "\n") { - w = max(w, ansi.StringWidth(line)) - } - return w -} - -// spriteDims returns the natural cell dimensions of a styled sprite string. -func spriteDims(s string) (w, h int) { - for _, line := range strings.Split(s, "\n") { - w = max(w, ansi.StringWidth(line)) - h++ - } - return w, h -} - -// render finalises buf to a string with LF row separators (cellbuf emits CRLF). -func render(buf *cellbuf.Buffer) string { - return strings.ReplaceAll(cellbuf.Render(buf), "\r\n", "\n") -} diff --git a/internal/tui/overlay/overlay_test.go b/internal/tui/overlay/overlay_test.go deleted file mode 100644 index 2b16338..0000000 --- a/internal/tui/overlay/overlay_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package overlay - -import ( - "strings" - "testing" - - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/cellbuf" -) - -func TestMaxDisplayWidth(t *testing.T) { - cases := []struct { - in string - want int - }{ - {"", 0}, - {"foo", 3}, - {"foo\nbarbaz", 6}, - {"\x1b[31mred\x1b[0m", 3}, // ANSI sequence ignored - {"短\n longer line", 12}, // wide rune + longer plain line - {"short\n\x1b[1mlong with bold\x1b[0m\nmid", 14}, // mixed - } - for _, c := range cases { - if got := MaxDisplayWidth(c.in); got != c.want { - t.Errorf("MaxDisplayWidth(%q) = %d, want %d", c.in, got, c.want) - } - } -} - -func TestWalkRendersAndMutates(t *testing.T) { - base := "abc\ndef" - called := false - got := Walk(base, 3, 2, func(buf *cellbuf.Buffer) { - called = true - buf.Cell(0, 0).Style.Fg = ansi.Red - }) - if !called { - t.Errorf("Walk did not invoke mutate") - } - if stripped := ansi.Strip(got); stripped != "abc\ndef" { - t.Errorf("Walk output lost content: stripped=%q want %q", stripped, "abc\ndef") - } - if strings.Contains(got, "\r\n") { - t.Errorf("Walk output contained CRLF: %q", got) - } - if !strings.Contains(got, "\x1b[") { - t.Errorf("Walk did not produce styled output: %q", got) - } -} - -func TestWalkReturnsBaseWhenZeroWidth(t *testing.T) { - base := "ignored" - got := Walk(base, 0, 5, func(*cellbuf.Buffer) { - t.Errorf("mutate must not be called when w == 0") - }) - if got != base { - t.Errorf("Walk(w=0) = %q, want base unchanged", got) - } -} - -func TestWalkReturnsBaseWhenCapExceeded(t *testing.T) { - base := "ignored" - got := Walk(base, CellCap, 2, func(*cellbuf.Buffer) { - t.Errorf("mutate must not be called when w*h > CellCap") - }) - if got != base { - t.Errorf("Walk(cap exceeded) = %q, want base unchanged", got) - } -} - -func TestSpriteCopiesAtOffset(t *testing.T) { - base := "abcde\nfghij\nklmno" - sprite := "XY\nZW" - got := Sprite(base, 5, 3, sprite, 1, 1) - stripped := ansi.Strip(got) - - want := "abcde\nfXYij\nkZWno" - if stripped != want { - t.Errorf("Sprite composition wrong\n got: %q\nwant: %q", stripped, want) - } -} - -func TestSpritePreservesBaseOutsideFootprint(t *testing.T) { - base := "aaa\nbbb\nccc" - sprite := "X" - got := ansi.Strip(Sprite(base, 3, 3, sprite, 0, 0)) - want := "Xaa\nbbb\nccc" - if got != want { - t.Errorf("Sprite leaked outside footprint\n got: %q\nwant: %q", got, want) - } -} - -func TestSpriteUsesLFSeparators(t *testing.T) { - got := Sprite("ab\ncd", 2, 2, "X", 0, 0) - if strings.Contains(got, "\r\n") { - t.Errorf("Sprite output contained CRLF: %q", got) - } -} diff --git a/internal/tui/preferences.go b/internal/tui/preferences.go deleted file mode 100644 index 1c57485..0000000 --- a/internal/tui/preferences.go +++ /dev/null @@ -1,18 +0,0 @@ -package tui - -// Preferences are the runtime toggles the user flips during a session. Lives -// as a single nested field on Model (m.prefs) so all read/write sites mention -// the same prefix and the toggle set is visible at one declaration. -// -// CLI-derived preferences (Diff, StatusBar, OSNotify) are populated from -// Config in New / NewReplay. Runtime-only toggles (Paused, HelpVisible) -// default to false. -type Preferences struct { - Diff bool // toggled by 'd'; controls renderFrame's diff overlay - StatusBar bool // toggled by 't'; user side of barShown's OR with state.ShowsBar - // OSNotify gates the OSC9 ping on exec changes; set via -b at launch. Named for - // the OSC9 channel, not the trigger; cfg.NotifyOnChange maps here. - OSNotify bool - Paused bool // toggled by 'p'; suppresses tick-driven execution - HelpVisible bool // toggled by 'h'; gates the help overlay in View -} diff --git a/internal/tui/preferences_test.go b/internal/tui/preferences_test.go deleted file mode 100644 index 602be7a..0000000 --- a/internal/tui/preferences_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package tui - -import ( - "testing" - "time" -) - -// TestPreferencesDefaults pins that New(Config{...}) copies the cfg-derived -// preferences into m.prefs and that the runtime-only flags (Paused, -// HelpVisible) start false. A future Config field that forgets to populate -// prefs would break this test. -func TestPreferencesDefaults(t *testing.T) { - cfg := Config{ - Command: "x", - Interval: time.Second, - DiffEnabled: true, - ShowStatus: true, - NotifyOnChange: true, - } - m := New(cfg) - - want := Preferences{ - Diff: true, - StatusBar: true, - OSNotify: true, - Paused: false, - HelpVisible: false, - } - // NOTE: switch to reflect.DeepEqual or per-field asserts when adding non-comparable fields. - if m.prefs != want { - t.Errorf("New prefs = %+v, want %+v", m.prefs, want) - } -} diff --git a/internal/tui/recording.go b/internal/tui/recording.go deleted file mode 100644 index 6a432d5..0000000 --- a/internal/tui/recording.go +++ /dev/null @@ -1,86 +0,0 @@ -package tui - -import ( - "errors" - "time" - - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - - "github.com/ivoronin/wch/internal/recording" - "github.com/ivoronin/wch/internal/tui/notify" -) - -const ( - recordPromptLabel = "Record to: " - recordStartedMessage = "Recording started" - recordStoppedMessage = "Recording stopped" -) - -// autoStartRecordingMsg fires once during Init when AutoStart was non-nil: a deferred -// flow.Start so the failure path (rare; the CLI already verified the file doesn't exist) -// can surface a warning bubble instead of crashing the program. -type autoStartRecordingMsg struct{ path string } - -// newRecordInput builds the textinput for the record-filename prompt: bar-matched palette, -// branded prompt label, value pre-filled, cursor parked at the end so Enter accepts the -// default and the user can backspace into the command portion. -func newRecordInput(initial string) textinput.Model { - in := newBarInput(recordPromptLabel) - in.SetValue(initial) - in.SetCursor(len(initial)) - return in -} - -// startRecording asks Flow to begin a recording and translates the outcome into a -// notification bubble. ok reports whether the recording is now active. Shared by the -// auto-start launch path and the interactive record-filename submit. -func (m Model) startRecording(path string) (Model, tea.Cmd, bool) { - err := m.flow.Start(path) - switch { - case err == nil: - m2, cmd := m.push(notify.LevelInfo, recordStartedMessage) - return m2, cmd, true - case errors.Is(err, recording.ErrPathExists): - m2, cmd := m.push(notify.LevelWarning, "File exists: "+path) - return m2, cmd, false - default: - m2, cmd := m.push(notify.LevelWarning, "Recording error: "+err.Error()) - return m2, cmd, false - } -} - -// toggleRecord is the 'r'-key shared handler for view and picker. Recording in progress → -// stop + notification; idle → open the filename input. Replay no-ops defensively (the key -// is not advertised in replay help either). -func (m Model) toggleRecord(from state) (Model, state, tea.Cmd) { - if !m.isLive() { - return m, from, nil - } - if m.flow.IsActive() { - var cmd tea.Cmd - if err := m.flow.Stop(); err != nil { - m, cmd = m.push(notify.LevelWarning, "Recording stop error: "+err.Error()) - } else { - m, cmd = m.push(notify.LevelInfo, recordStoppedMessage) - } - return m, from, cmd - } - return m.openInput(from, newRecordInput(recording.DefaultFilename(m.session.Command, time.Now())), applyRecordSubmit) -} - -// applyRecordSubmit validates the typed path and either starts recording (popping back to -// the predecessor) or flashes a warning while keeping the input open with the user's value -// intact for them to correct. -func applyRecordSubmit(m Model, s inputState) (Model, state, tea.Cmd) { - path, err := recording.NormalizePath(s.input.Value()) - if err != nil { - m2, cmd := m.push(notify.LevelWarning, "Empty or invalid path") - return m2, s, cmd - } - m, cmd, ok := m.startRecording(path) - if !ok { - return m, s, cmd - } - return m, s.prev, cmd -} diff --git a/internal/tui/recording_test.go b/internal/tui/recording_test.go deleted file mode 100644 index 5b35f4e..0000000 --- a/internal/tui/recording_test.go +++ /dev/null @@ -1,259 +0,0 @@ -package tui - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/x/ansi" - - "github.com/ivoronin/wch/internal/recording" - "github.com/ivoronin/wch/internal/session" -) - -// --- Replay & recording integration --------------------------------------- - -func preloadedReplaySession(n int) *session.Session { - s := session.NewSession("kubectl", time.Second) - for i := 0; i < n; i++ { - _, _, _ = s.RecordIfChanged(session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, i, 0, time.UTC), - Stdout: fmt.Sprintf("frame %d\n", i), - }) - } - return s -} - -// Regression: opening an input prompt must leave the textinput focused. Without focus, -// textinput.Update drops every keypress and the user can't type. Previously openInput built -// the inputState with an unfocused textinput value and then called Focus() on a separate -// local copy, so the stored input remained focused=false. -func TestRecordInputFocusedAndAcceptsKeystrokes(t *testing.T) { - m := New(Config{Command: "kubectl", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 80, Height: 24}) - m = pressKey(t, m, 'r') - - in, ok := m.state.(inputState) - if !ok { - t.Fatalf("setup: m.state = %T, want inputState", m.state) - } - if !in.input.Focused() { - t.Fatalf("input must be focused immediately after openInput; was not") - } - - before := in.input.Value() - m = pressKey(t, m, 'X') // arbitrary printable - got := m.state.(inputState).input.Value() - if got == before { - t.Errorf("typing 'X' didn't change the input value (still %q) — keystroke was dropped", got) - } - if !strings.HasSuffix(got, "X") { - t.Errorf("input value = %q, want a trailing X", got) - } -} - -// Pressing 'r' on a live model switches into inputState with a pre-filled default filename. -func TestRecordKeyOpensInputMode(t *testing.T) { - m := New(Config{Command: "kubectl get pods -A", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 80, Height: 24}) - - m = pressKey(t, m, 'r') - - in, ok := m.state.(inputState) - if !ok { - t.Fatalf("after r-key, m.state = %T, want inputState", m.state) - } - v := in.input.Value() - if !strings.HasPrefix(v, "kubectl_get_pods_A_") || !strings.HasSuffix(v, ".wch.jsonl") { - t.Errorf("pre-filled value = %q, want kubectl_get_pods_A_.wch.jsonl", v) - } - if m.session.IsRecording() { - t.Errorf("session must not be recording yet — only the prompt is open") - } -} - -// Submitting a valid path starts recording, returns to viewState, and the backlog plus -// subsequent frames round-trip via session.Load. -func TestSubmitInputStartsRecording(t *testing.T) { - path := filepath.Join(t.TempDir(), "wch.jsonl") - m := New(Config{Command: "kubectl", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - - m = feed(t, m, execResultMsg{exec: session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC), Stdout: "alpha\n", - }}) - - m = pressKey(t, m, 'r') - if _, ok := m.state.(inputState); !ok { - t.Fatalf("setup: expected inputState after r, got %T", m.state) - } - m = submitInputValue(t, m, path) - - if !m.session.IsRecording() { - t.Fatalf("expected IsRecording() after submit") - } - if _, ok := m.state.(viewState); !ok { - t.Errorf("after successful submit, m.state = %T, want viewState", m.state) - } - if !m.notify.Active() { - t.Errorf("expected notification bubble on successful start") - } - - m = feed(t, m, execResultMsg{exec: session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, 1, 0, time.UTC), Stdout: "beta\n", - }}) - m = feed(t, m, execResultMsg{exec: session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, 2, 0, time.UTC), Stdout: "gamma\n", - }}) - - m = pressKey(t, m, 'r') - if m.session.IsRecording() { - t.Errorf("expected IsRecording() == false after second toggle") - } - - loaded, err := recording.Load(path) - if err != nil { - t.Fatalf("recording.Load: %v", err) - } - want := []string{"alpha\n", "beta\n", "gamma\n"} - if len(loaded.History) != len(want) { - t.Fatalf("loaded len=%d want %d", len(loaded.History), len(want)) - } - for i, w := range want { - if loaded.History[i].Stdout != w { - t.Errorf("frame %d stdout=%q want %q", i, loaded.History[i].Stdout, w) - } - } -} - -// Empty input is refused: warning bubble fires; the prompt stays open with the value. -func TestSubmitEmptyValueRefuses(t *testing.T) { - m := New(Config{Command: "kubectl", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - m = pressKey(t, m, 'r') - m = submitInputValue(t, m, " ") - - if m.session.IsRecording() { - t.Errorf("Recording must not start on empty submit") - } - if _, ok := m.state.(inputState); !ok { - t.Errorf("after refusal, m.state = %T, want inputState (prompt stays open)", m.state) - } - if !m.notify.Active() { - t.Errorf("expected warning bubble on empty submit") - } -} - -// Submitting a path that already exists is refused with a warning; prompt stays open. -func TestSubmitExistingFileRefuses(t *testing.T) { - path := filepath.Join(t.TempDir(), "exists.wch.jsonl") - if err := os.WriteFile(path, []byte("placeholder"), 0o644); err != nil { - t.Fatalf("seed file: %v", err) - } - - m := New(Config{Command: "kubectl", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - m = pressKey(t, m, 'r') - m = submitInputValue(t, m, path) - - if m.session.IsRecording() { - t.Errorf("Recording must not start on existing-file submit") - } - if _, ok := m.state.(inputState); !ok { - t.Errorf("after refusal, m.state = %T, want inputState", m.state) - } - if !m.notify.Active() { - t.Errorf("expected warning bubble on existing-file submit") - } -} - -// Esc out of inputState returns to viewState without starting a recording. -func TestCancelClosesInput(t *testing.T) { - m := New(Config{Command: "kubectl", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - m = pressKey(t, m, 'r') - m = feed(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) - - if m.session.IsRecording() { - t.Errorf("cancel must not start a recording") - } - if _, ok := m.state.(viewState); !ok { - t.Errorf("after cancel, m.state = %T, want viewState", m.state) - } -} - -// In replay, 'r' is a no-op and the Record binding is dropped from viewHelpBindings. -func TestRecordKeyInReplayMode(t *testing.T) { - m := NewReplay(Config{}, preloadedReplaySession(1)) - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - - for _, b := range viewHelpBindings(m) { - if b.Help().Key == commonKeys.Record.Help().Key { - t.Errorf("Record binding should not appear in replay help; got %v", b.Help()) - } - } - - m = pressKey(t, m, 'r') - if m.session.IsRecording() { - t.Errorf("replay must not start recording") - } - if _, ok := m.state.(inputState); ok { - t.Errorf("replay must not open input prompt") - } -} - -// Successful submit pushes a "Recording started" bubble onto the notify stack. -func TestSubmitRecordPushesBubble(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "wch.jsonl") - m := New(Config{Command: "x", Interval: time.Second}) - m.ready = true - m = feed(t, m, tea.WindowSizeMsg{Width: 120, Height: 30}) - - m = pressKey(t, m, 'r') - m = submitInputValue(t, m, path) - if !m.notify.Active() { - t.Fatalf("expected notify.Active() after successful start") - } - rendered := ansi.Strip(m.View().Content) - if !strings.Contains(rendered, "Recording started") { - t.Errorf("rendered View missing start message; got:\n%s", rendered) - } -} - -// Cleanup finalises the recording when called after p.Run returns. Bubble Tea v2 -// short-circuits Model.Update on QuitMsg, so the cleanup contract lives on Model and is -// invoked by main.go — not by the QuitMsg handler. -func TestCleanupFinalizesRecording(t *testing.T) { - path := filepath.Join(t.TempDir(), "wch.jsonl") - m := New(Config{Command: "x", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - m = feed(t, m, execResultMsg{exec: session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC), Stdout: "kept\n", - }}) - - m = pressKey(t, m, 'r') - m = submitInputValue(t, m, path) - if !m.session.IsRecording() { - t.Fatalf("setup: should be recording after submit") - } - - if err := m.Cleanup(); err != nil { - t.Fatalf("Cleanup: %v", err) - } - if m.session.IsRecording() { - t.Errorf("IsRecording() must be false after Cleanup") - } - - loaded, err := recording.Load(path) - if err != nil { - t.Fatalf("recording.Load: %v", err) - } - if len(loaded.History) == 0 { - t.Errorf("expected at least one frame in the finalized file") - } -} diff --git a/internal/tui/scrollview/scrollview.go b/internal/tui/scrollview/scrollview.go deleted file mode 100644 index f369dc7..0000000 --- a/internal/tui/scrollview/scrollview.go +++ /dev/null @@ -1,264 +0,0 @@ -package scrollview - -import ( - "strings" - - "charm.land/bubbles/v2/viewport" - "charm.land/lipgloss/v2" -) - -// Scrollbar symbols -const ( - vTrackChar = "│" - vThumbChar = "┃" - hTrackChar = "─" - hThumbChar = "━" -) - -// Scrollbar styles -var ( - trackStyle = lipgloss.NewStyle().Faint(true) - thumbStyle = lipgloss.NewStyle().Bold(true) -) - -// Scrollview wraps bubbles viewport with horizontal scrolling and scrollbar rendering. -type Scrollview struct { - viewport.Model // embedded - navigation and scroll methods auto-promoted - - content string // raw content - lines []string // cached split lines - maxWidth int // cached max line width - showBar bool // show scrollbar - totalWidth int // user-requested width (content + scrollbar space) - totalHeight int // user-requested height (content + scrollbar space) - needsVBar bool // vertical scrollbar needed (computed in updateLayout) - needsHBar bool // horizontal scrollbar needed (computed in updateLayout) -} - -// NewScrollview creates a new Scrollview with the given dimensions. -func NewScrollview(width, height int) Scrollview { - return Scrollview{ - Model: viewport.New(viewport.WithWidth(width), viewport.WithHeight(height)), - showBar: true, - totalWidth: width, - totalHeight: height, - } -} - -// SetContent sets the viewport content and preserves scroll position. -func (v *Scrollview) SetContent(content string) { - v.content = content - - // Cache split lines and max width - if content == "" { - v.lines = nil - v.maxWidth = 0 - } else { - v.lines = strings.Split(content, "\n") - v.maxWidth = 0 - for _, line := range v.lines { - v.maxWidth = max(v.maxWidth, lipgloss.Width(line)) - } - } - - // Adjust height for horizontal scrollbar BEFORE setting content on Model - v.updateLayout() - - yoff := v.YOffset() - xoff := v.XOffset() - v.Model.SetContent(content) - - // Preserve vertical scroll position (clamped) - ymax := max(0, v.TotalLineCount()-v.Height()) - v.SetYOffset(min(yoff, ymax)) - - // Preserve horizontal scroll (clamped) - v.SetXOffset(min(xoff, v.maxXOffset())) -} - -// updateLayout adjusts embedded viewport dimensions based on scrollbar needs. -func (v *Scrollview) updateLayout() { - // Compute scrollbar needs using cached values. The dimension guards (totalHeight/ - // totalWidth > 0) keep degenerate geometry from claiming a scrollbar: when wch's - // output is piped to a non-TTY the program receives a 0x0 WindowSizeMsg, and the - // bar reservation in the caller can drive a dimension negative. Without the guard, - // len(v.lines) > v.totalHeight is trivially true (e.g. 0 > -1) and View renders a - // vertical bar over zero-line content, dividing by zero in calcScrollbarThumb. - v.needsVBar = v.showBar && v.totalHeight > 0 && len(v.lines) > v.totalHeight - v.needsHBar = v.showBar && v.totalWidth > 0 && v.maxWidth > v.totalWidth - - // Reserve space for scrollbars - w := v.totalWidth - h := v.totalHeight - if v.needsVBar { - w-- // reserve 1 column for v-scrollbar - } - if v.needsHBar { - h-- // reserve 1 line for h-scrollbar - } - v.SetWidth(w) - v.SetHeight(h) -} - -// calcScrollbarThumb computes the start position and size of a scrollbar thumb. -// offset is the current scroll position, visible is the viewport size, total is the content size. -func calcScrollbarThumb(offset, visible, total int) (start, size int) { - // max(1, total) guards the division: callers only render a thumb when total > 0, but - // stay defensive so degenerate geometry can never divide by zero. - size = max(1, visible*visible/max(1, total)) - start = offset * (visible - size) / max(1, total-visible) - // Clamp so that start+size never exceeds visible (defensive against stale offset) - start = min(start, max(0, visible-size)) - return -} - -// View renders the viewport content with scrollbars. -func (v Scrollview) View() string { - content := v.Model.View() - - if !v.showBar { - return content - } - - lines := strings.Split(content, "\n") - totalLines := v.TotalLineCount() - visibleLines := len(lines) - - // Add vertical scrollbar to each line (must append char-by-char to existing lines) - if v.needsVBar { - vThumbStart, vThumbSize := calcScrollbarThumb(v.YOffset(), visibleLines, totalLines) - - vTrack := trackStyle.Render(vTrackChar) - vThumb := thumbStyle.Render(vThumbChar) - - for i := range lines { - if i >= vThumbStart && i < vThumbStart+vThumbSize { - lines[i] += vThumb - } else { - lines[i] += vTrack - } - } - } - - // Add horizontal scrollbar at bottom (can batch-style whole segments) - if v.needsHBar { - hThumbStart, hThumbSize := calcScrollbarThumb(v.XOffset(), v.Width(), v.maxWidth) - - hTrackBefore := strings.Repeat(hTrackChar, hThumbStart) - hThumb := strings.Repeat(hThumbChar, hThumbSize) - hTrackAfter := strings.Repeat(hTrackChar, v.Width()-hThumbStart-hThumbSize) - - hBar := lipgloss.JoinHorizontal(lipgloss.Top, - trackStyle.Render(hTrackBefore), - thumbStyle.Render(hThumb), - trackStyle.Render(hTrackAfter), - ) - - // When both scrollbars present, leave corner empty; otherwise hbar takes full width - if v.needsVBar { - hBar += " " - } - - lines = append(lines, hBar) - } - - return lipgloss.JoinVertical(lipgloss.Left, lines...) -} - -// maxXOffset returns the maximum horizontal scroll offset. -func (v *Scrollview) maxXOffset() int { - return max(0, v.maxWidth-v.Width()) -} - -// ScrollLeft scrolls the viewport left by one column. -func (v *Scrollview) ScrollLeft() { - v.SetXOffset(max(0, v.XOffset()-1)) -} - -// ScrollRight scrolls the viewport right by one column. -func (v *Scrollview) ScrollRight() { - v.SetXOffset(min(v.XOffset()+1, v.maxXOffset())) -} - -// ScrollLeftPage scrolls the viewport left by one page width. -func (v *Scrollview) ScrollLeftPage() { - v.SetXOffset(max(0, v.XOffset()-v.Width())) -} - -// ScrollRightPage scrolls the viewport right by one page width. -func (v *Scrollview) ScrollRightPage() { - v.SetXOffset(min(v.XOffset()+v.Width(), v.maxXOffset())) -} - -// GotoLeftEdge scrolls to the left edge of content. -func (v *Scrollview) GotoLeftEdge() { - v.SetXOffset(0) -} - -// GotoRightEdge scrolls to the right edge of content. -func (v *Scrollview) GotoRightEdge() { - v.SetXOffset(v.maxXOffset()) -} - -// SetSize updates the viewport dimensions. -func (v *Scrollview) SetSize(width, height int) { - v.totalWidth = width - v.totalHeight = height - v.updateLayout() - // Clamp horizontal scroll to new valid range - v.SetXOffset(min(v.XOffset(), v.maxXOffset())) -} - -// SetShowScrollbar enables or disables the scrollbar. -func (v *Scrollview) SetShowScrollbar(show bool) { - v.showBar = show - v.updateLayout() -} - -// NeedsVerticalScrollbar reports whether a vertical scrollbar is currently being rendered -// (the scrollbar is enabled and content overflows the visible height). -func (v Scrollview) NeedsVerticalScrollbar() bool { return v.needsVBar } - -// NeedsHorizontalScrollbar reports the equivalent for the horizontal scrollbar. -func (v Scrollview) NeedsHorizontalScrollbar() bool { return v.needsHBar } - -// EnsureLineVisible scrolls vertically by the minimum amount required to put line within the -// visible window: nothing if it's already on screen, snap to the top if it's above, snap -// to the bottom otherwise. Returns the resulting YOffset. -func (v *Scrollview) EnsureLineVisible(line int) int { - y := v.YOffset() - h := v.Height() - switch { - case line < y: - v.SetYOffset(line) - case h > 0 && line >= y+h: - v.SetYOffset(line - h + 1) - } - return v.YOffset() -} - -// EnsureColumnVisible scrolls horizontally so the range [col, col+length) is fully inside -// the viewport. If the range is already visible, no change. Otherwise snap the left or right -// edge of the range to the viewport boundary, preferring to keep the left edge in view when -// the range is wider than the viewport. -func (v *Scrollview) EnsureColumnVisible(col, length int) int { - length = max(length, 1) - x := v.XOffset() - w := v.Width() - if w <= 0 { - return x - } - switch { - case col < x: - v.SetXOffset(col) - case col+length > x+w: - // Range right edge is past the viewport. If the range fits, scroll just enough to - // fit it; otherwise prefer the left edge in view. - if length <= w { - v.SetXOffset(col + length - w) - } else { - v.SetXOffset(col) - } - } - return v.XOffset() -} diff --git a/internal/tui/scrollview/scrollview_test.go b/internal/tui/scrollview/scrollview_test.go deleted file mode 100644 index 53d6af0..0000000 --- a/internal/tui/scrollview/scrollview_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package scrollview - -import ( - "strings" - "testing" -) - -func TestViewNoPanicOnResizeAfterScroll(t *testing.T) { - // Simulate: wide content, scroll right, then shrink terminal width. - // This triggers a negative Repeat count in View() because the scrollbar - // thumb position calculated from the stale XOffset exceeds the new width. - sv := NewScrollview(80, 10) - - // Content wider than viewport to enable horizontal scrollbar - wide := strings.Repeat("x", 200) - lines := make([]string, 20) - for i := range lines { - lines[i] = wide - } - sv.SetContent(strings.Join(lines, "\n")) - - // Scroll far right - for range 100 { - sv.ScrollRight() - } - - // Shrink viewport (simulates narrowing terminal window) - sv.SetSize(20, 10) - - // Scroll far right while narrow (large maxXOffset when viewport is small) - for range 200 { - sv.ScrollRight() - } - - // Expand viewport back (simulates widening terminal window). - // Now XOffset from the narrow state exceeds the valid range for the wider viewport, - // causing calcScrollbarThumb to produce start+size > Width. - sv.SetSize(150, 10) - - // View() should not panic - sv.View() -} - -// TestViewNoPanicOnDegenerateSize reproduces issue #14: piping wch output to a non-TTY -// (e.g. `wch date | cat`) delivers a 0x0 WindowSizeMsg, and the caller's bar-space -// reservation can drive a dimension negative. A negative totalHeight made the -// len(lines) > totalHeight overflow check trivially true even for empty content, so -// View() asked calcScrollbarThumb to size a thumb over zero total lines and divided by -// zero. No scrollbar should be claimed and View() must not panic. -func TestViewNoPanicOnDegenerateSize(t *testing.T) { - sv := NewScrollview(0, 0) - sv.SetSize(0, -1) // mirrors withResizedScrollview's h-- on a 0-height window - - // Empty content: no lines to scroll, so neither bar should be needed. - sv.SetContent("") - if sv.NeedsVerticalScrollbar() || sv.NeedsHorizontalScrollbar() { - t.Errorf("degenerate size, empty content: NeedsV=%v NeedsH=%v, want false/false", - sv.NeedsVerticalScrollbar(), sv.NeedsHorizontalScrollbar()) - } - sv.View() // must not panic - - // Real content under degenerate geometry must also stay panic-free. - sv.SetContent(strings.Repeat("line\n", 50)) - sv.View() -} - -// NeedsVerticalScrollbar / NeedsHorizontalScrollbar reflect whether each bar is currently -// rendered: they require both showBar=true and the content overflowing the corresponding -// axis. Disabling the scrollbar drops both to false. -func TestNeedsScrollbars(t *testing.T) { - sv := NewScrollview(20, 5) - sv.SetShowScrollbar(true) - - // Empty / no overflow → neither bar. - sv.SetContent("hi") - if sv.NeedsVerticalScrollbar() || sv.NeedsHorizontalScrollbar() { - t.Errorf("empty content: NeedsV=%v NeedsH=%v, want false/false", - sv.NeedsVerticalScrollbar(), sv.NeedsHorizontalScrollbar()) - } - - // Tall content → vertical bar; one short line → no horizontal bar. - tall := strings.Repeat("hi\n", 50) - sv.SetContent(tall) - if !sv.NeedsVerticalScrollbar() { - t.Errorf("tall content: NeedsVerticalScrollbar() = false, want true") - } - if sv.NeedsHorizontalScrollbar() { - t.Errorf("tall (but narrow) content: NeedsHorizontalScrollbar() = true, want false") - } - - // Wide content → horizontal bar. - wide := strings.Repeat("x", 200) - sv.SetContent(wide) - if !sv.NeedsHorizontalScrollbar() { - t.Errorf("wide content: NeedsHorizontalScrollbar() = false, want true") - } - - // Both tall and wide → both bars. - bothLines := make([]string, 50) - for i := range bothLines { - bothLines[i] = wide - } - sv.SetContent(strings.Join(bothLines, "\n")) - if !sv.NeedsVerticalScrollbar() || !sv.NeedsHorizontalScrollbar() { - t.Errorf("tall+wide content: NeedsV=%v NeedsH=%v, want true/true", - sv.NeedsVerticalScrollbar(), sv.NeedsHorizontalScrollbar()) - } - - // Disable the scrollbar entirely → both report false even with overflow. - sv.SetShowScrollbar(false) - if sv.NeedsVerticalScrollbar() || sv.NeedsHorizontalScrollbar() { - t.Errorf("scrollbar disabled: NeedsV=%v NeedsH=%v, want false/false", - sv.NeedsVerticalScrollbar(), sv.NeedsHorizontalScrollbar()) - } -} - -// EnsureLineVisible scrolls only when the line is outside the viewport: above → snap to top, -// below → snap to bottom, already inside → no-op. -func TestEnsureLineVisible(t *testing.T) { - sv := NewScrollview(20, 5) - sv.SetContent(strings.Repeat("x\n", 50)) - - sv.SetYOffset(10) - - // Already visible (within [10, 14]). - if got := sv.EnsureLineVisible(12); got != 10 { - t.Errorf("already-visible: YOffset=%d want 10", got) - } - - // Above viewport → snap line to top. - if got := sv.EnsureLineVisible(3); got != 3 { - t.Errorf("above: YOffset=%d want 3", got) - } - - // Below viewport. - sv.SetYOffset(10) - if got := sv.EnsureLineVisible(40); got != 36 { // 40 - 5 + 1 - t.Errorf("below: YOffset=%d want 36", got) - } -} - -// EnsureColumnVisible mirrors EnsureLineVisible on the horizontal axis. When the range is -// wider than the viewport, prefer the left edge to keep the start of the match in view. -func TestEnsureColumnVisible(t *testing.T) { - sv := NewScrollview(10, 5) - sv.SetContent(strings.Repeat("x", 100)) - - sv.SetXOffset(20) - - // Already visible: col 25 with length 3 fits inside [20, 30). - if got := sv.EnsureColumnVisible(25, 3); got != 20 { - t.Errorf("already-visible: XOffset=%d want 20", got) - } - - // Left of viewport. - if got := sv.EnsureColumnVisible(5, 3); got != 5 { - t.Errorf("left: XOffset=%d want 5", got) - } - - // Right of viewport: range fits → just enough to show it. - sv.SetXOffset(20) - if got := sv.EnsureColumnVisible(50, 3); got != 43 { // 50 + 3 - 10 - t.Errorf("right (fits): XOffset=%d want 43", got) - } - - // Right of viewport, range wider than viewport → keep left edge in view. - sv.SetXOffset(20) - if got := sv.EnsureColumnVisible(50, 20); got != 50 { - t.Errorf("right (oversize): XOffset=%d want 50", got) - } -} diff --git a/internal/tui/search.go b/internal/tui/search.go deleted file mode 100644 index 1ad0da6..0000000 --- a/internal/tui/search.go +++ /dev/null @@ -1,62 +0,0 @@ -package tui - -import ( - "strings" - "unicode" - - "github.com/charmbracelet/x/ansi" -) - -// searchMatch is one occurrence of a query in the searched snapshot, expressed in display- -// width cell coordinates (not byte offsets and not rune indices) so it lines up with the -// cellbuf overlay grid used by searchrender. -type searchMatch struct { - line int // 0-based line index in the stripped body - col int // 0-based display-width column at which the match starts - length int // display width of the match -} - -// findMatches walks every line of stripped body and collects every occurrence of query, -// using smart-case semantics: case-insensitive when query is all-lowercase, exact when query -// has any uppercase rune. unicode.IsUpper covers non-ASCII alphabets (Ü, Ç, Ñ, …) — limiting -// the check to A-Z would silently fold queries like "Über" or "Lösung" to case-insensitive. -// Returns matches in document order; an empty result means "no match" and the caller must NOT -// enter searchMode (notification is shown instead). -func findMatches(stripped, query string) []searchMatch { - if query == "" { - return nil - } - haystack := stripped - needle := query - if !strings.ContainsFunc(query, unicode.IsUpper) { - haystack = strings.ToLower(stripped) - needle = strings.ToLower(query) - } - queryWidth := ansi.StringWidth(query) - var matches []searchMatch - for lineIdx, line := range strings.Split(haystack, "\n") { - off := 0 // byte cursor into line - offWidth := 0 // display width consumed up to off (incremental — saves O(N) per match) - for { - i := strings.Index(line[off:], needle) - if i < 0 { - break - } - byteStart := off + i - // Add the width of the gap between the previous off and byteStart, not the - // whole line[:byteStart]. Avoids the O(matches * lineLen) hot path. - offWidth += ansi.StringWidth(line[off:byteStart]) - matches = append(matches, searchMatch{ - line: lineIdx, - col: offWidth, - length: queryWidth, - }) - offWidth += queryWidth - off = byteStart + len(needle) - if off >= len(line) { - break - } - } - } - return matches -} diff --git a/internal/tui/search_test.go b/internal/tui/search_test.go deleted file mode 100644 index c3c518b..0000000 --- a/internal/tui/search_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package tui - -import ( - "reflect" - "strings" - "testing" -) - -func TestFindMatchesSmartCase(t *testing.T) { - body := "pod-01 Running\npod-02 Pending\npod-03 PENDING\npod-04 pending\n" - - // All-lowercase query → case-insensitive, picks up Pending/PENDING/pending. - got := findMatches(body, "pending") - want := []searchMatch{ - {line: 1, col: 7, length: 7}, - {line: 2, col: 7, length: 7}, - {line: 3, col: 7, length: 7}, - } - if !reflect.DeepEqual(got, want) { - t.Errorf("smart-case lowercase: got %v want %v", got, want) - } - - // Mixed-case query → exact match only. - got = findMatches(body, "Pending") - want = []searchMatch{{line: 1, col: 7, length: 7}} - if !reflect.DeepEqual(got, want) { - t.Errorf("smart-case exact: got %v want %v", got, want) - } -} - -func TestFindMatchesMultiplePerLine(t *testing.T) { - got := findMatches("foo bar foo bar foo", "foo") - want := []searchMatch{ - {line: 0, col: 0, length: 3}, - {line: 0, col: 8, length: 3}, - {line: 0, col: 16, length: 3}, - } - if !reflect.DeepEqual(got, want) { - t.Errorf("got %v want %v", got, want) - } -} - -func TestFindMatchesEmptyAndNoMatch(t *testing.T) { - if findMatches("anything", "") != nil { - t.Errorf("empty query must yield nil matches") - } - if findMatches("alpha\nbeta\n", "gamma") != nil { - t.Errorf("no-occurrence query must yield nil matches") - } - if findMatches("", "foo") != nil { - t.Errorf("empty body must yield nil matches") - } -} - -func TestFindMatchesWideRunes(t *testing.T) { - // Each CJK rune is 2 display cells. "緑Running" -> col of "Running" is 2 (after one wide rune). - body := "緑Running\nRunning" - got := findMatches(body, "Running") - want := []searchMatch{ - {line: 0, col: 2, length: 7}, - {line: 1, col: 0, length: 7}, - } - if !reflect.DeepEqual(got, want) { - t.Errorf("wide rune cols: got %v want %v", got, want) - } -} - -func TestFindMatchesAcrossLines(t *testing.T) { - body := strings.Repeat("alpha\n", 3) + "alphabeta" - got := findMatches(body, "alpha") - want := []searchMatch{ - {line: 0, col: 0, length: 5}, - {line: 1, col: 0, length: 5}, - {line: 2, col: 0, length: 5}, - {line: 3, col: 0, length: 5}, - } - if !reflect.DeepEqual(got, want) { - t.Errorf("got %v want %v", got, want) - } -} diff --git a/internal/tui/searchrender/searchrender.go b/internal/tui/searchrender/searchrender.go deleted file mode 100644 index bf30d5f..0000000 --- a/internal/tui/searchrender/searchrender.go +++ /dev/null @@ -1,37 +0,0 @@ -// Package searchrender draws the in-snapshot search overlay: a single reverse-video span on -// the currently selected match. It is the optional terminal renderer companion to the -// search-matching logic in internal/tui, kept separate so cellbuf-overlay knowledge stays -// local to one package. Mirrors the architecture of internal/tui/diffrender. -package searchrender - -import ( - "strings" - - "github.com/charmbracelet/x/cellbuf" - - "github.com/ivoronin/wch/internal/tui/overlay" -) - -// Render overlays a reverse-video highlight onto the cells corresponding to the selected -// match (line, col, length in display-width cells). selLine/selCol/selLength describe the -// match in display-width coordinates that line up with cellbuf's grid. The body's existing -// styling (kubectl colors + any diff highlights baked in upstream) is preserved; only the -// reverse attribute (SGR 7) is added on the matched cells, so the terminal swaps the cell's -// own fg/bg without us picking a theme-specific colour. If selLength is 0, body is returned -// unchanged. -func Render(body string, selLine, selCol, selLength int) string { - if selLength <= 0 { - return body - } - w := overlay.MaxDisplayWidth(body) - h := strings.Count(body, "\n") + 1 - return overlay.Walk(body, w, h, func(buf *cellbuf.Buffer) { - for x := selCol; x < selCol+selLength && x < buf.Width(); x++ { - c := buf.Cell(x, selLine) - if c == nil { - continue - } - c.Style.Reverse(true) - } - }) -} diff --git a/internal/tui/searchrender/searchrender_test.go b/internal/tui/searchrender/searchrender_test.go deleted file mode 100644 index c0b6c15..0000000 --- a/internal/tui/searchrender/searchrender_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package searchrender - -import ( - "strings" - "testing" - - "github.com/charmbracelet/x/ansi" -) - -// Render applies the SGR 7 reverse attribute to the selected span. After stripping ANSI we -// should see the same visible text; the ANSI sequences should include a SGR 7 enter and a -// SGR 27 leave (or a Reset that turns it off) around the match cells. -func TestRenderAppliesReverseToSelection(t *testing.T) { - body := "alpha bravo charlie\nfoo bar baz" - got := Render(body, 0, 6, 5) // "bravo" - - if ansi.Strip(got) != body { - t.Errorf("stripped output changed:\nbefore: %q\nafter: %q", body, ansi.Strip(got)) - } - if !strings.Contains(got, "\x1b[7m") { - t.Errorf("expected SGR 7 (reverse) in output; got %q", got) - } -} - -// A zero-length selection short-circuits and returns the body untouched. -func TestRenderEmptySelectionPasses(t *testing.T) { - body := "alpha bravo" - got := Render(body, 0, 0, 0) - if got != body { - t.Errorf("empty selection should pass through; got %q want %q", got, body) - } -} - -// Body wider × taller than overlayCellCap returns body unchanged (degrades gracefully). -func TestRenderTooLargeReturnsBody(t *testing.T) { - // 2001 cells × 2001 rows ≈ 4M, just over the cap. - wide := strings.Repeat("x", 2001) - rows := make([]string, 2001) - for i := range rows { - rows[i] = wide - } - body := strings.Join(rows, "\n") - got := Render(body, 0, 0, 5) - if got != body { - t.Errorf("oversized body should pass through unchanged") - } -} diff --git a/internal/tui/state.go b/internal/tui/state.go deleted file mode 100644 index b53ad6e..0000000 --- a/internal/tui/state.go +++ /dev/null @@ -1,79 +0,0 @@ -package tui - -import ( - "time" - - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" -) - -// viewPolicy is the display-side contract of a state: what to render in the viewport -// (Body), what timestamp to show in the bar's center clock (Timestamp), whether the bar -// shows unconditionally (ShowsBar), whether new executions should repaint (IsFrozen), -// how historyIndex advances (FollowsTail), and what to put in the bar (RenderBar). -// Display concerns never reach Handle. Body and Timestamp's (_, bool) shapes let a -// state opt out (e.g. viewState before the first frame, searchState with zero matches) -// without the orchestrator inventing a sentinel value. -type viewPolicy interface { - Body(Model) (string, bool) - Timestamp(Model) (time.Time, bool) - ShowsBar() bool - IsFrozen() bool - FollowsTail(wasAtTail bool) bool - RenderBar(Model) string -} - -// inputHandler is the input-side contract of a state: a key press becomes a new model, -// the next state, and a side-effect cmd. handled gates the global-key fallback so an -// active overlay (input prompt, search) can consume printable chars before the global -// q/t bindings see them. -type inputHandler interface { - Handle(Model, tea.KeyPressMsg) (Model, state, tea.Cmd, bool) -} - -// state is the sealed sum type of UI modes. Exactly one struct below inhabits Model.state -// at any time; the isState marker keeps unrelated types out of the union. Display and -// input live behind the viewPolicy and inputHandler interfaces so a future reader can -// see the two concerns separately without scanning a 6-method blob. -type state interface { - isState() - viewPolicy - inputHandler -} - -// viewState is the default: the viewport shows the frame at Model.historyIndex, with diff -// highlights when Model.prefs.Diff is set. All other display fields are read directly off Model. -type viewState struct{} - -// pickerState is the history-timeline picker. The cursor is Model.historyIndex; cursor -// movement reassigns it. -type pickerState struct{} - -// inputState is a single-line prompt overlaid on top of a host state (prev). Used by both -// the record-filename and search-query flows; submit decides what to do with the typed -// value. inputState is transparent: Body/FollowsTail/IsFrozen all delegate to prev. The -// input prompt itself lives in the bar. -type inputState struct { - input textinput.Model - prev state - submit func(Model, inputState) (Model, state, tea.Cmd) -} - -// searchState shows a frozen snapshot with the selected match highlighted. body is captured -// once on entry; matches are computed once. follow remembers whether the user was at the -// live tail on entry so historyIndex can advance in the background — Esc then drops into -// the underlying viewState already at the new tail without a second keystroke. -type searchState struct { - query string - body string - matches []searchMatch - selected int - follow bool - captured time.Time - prev state -} - -func (viewState) isState() {} -func (pickerState) isState() {} -func (inputState) isState() {} -func (searchState) isState() {} diff --git a/internal/tui/state_common.go b/internal/tui/state_common.go deleted file mode 100644 index a1a82fe..0000000 --- a/internal/tui/state_common.go +++ /dev/null @@ -1,32 +0,0 @@ -// Package tui — handlers shared across multiple state types live here so they -// don't get filed under any single state's _.go file. -package tui - -import ( - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" -) - -// handleCommonKey handles the diff/pause/record/search bindings shared by -// viewState and pickerState. Returns handled=false if msg matches none of -// them. Lives here (not in either state's file) because both states call it -// and neither owns the shape. -func (m Model) handleCommonKey(s state, msg tea.KeyPressMsg) (Model, state, tea.Cmd, bool) { - switch { - case key.Matches(msg, commonKeys.ToggleDiff): - // Same frame → anchor would be identity. Skip the diff.Align dance and re-render - // directly; scroll position stays put because viewport's offset is not touched. - m.prefs.Diff = !m.prefs.Diff - return m.repaint(), s, nil, true - case key.Matches(msg, commonKeys.Pause): - m.prefs.Paused = !m.prefs.Paused - return m, s, nil, true - case key.Matches(msg, commonKeys.Record): - m2, st, cmd := m.toggleRecord(s) - return m2, st, cmd, true - case key.Matches(msg, commonKeys.Search): - m2, st, cmd := m.openSearchInput(s) - return m2, st, cmd, true - } - return m, s, nil, false -} diff --git a/internal/tui/state_input.go b/internal/tui/state_input.go deleted file mode 100644 index 9cc80ad..0000000 --- a/internal/tui/state_input.go +++ /dev/null @@ -1,163 +0,0 @@ -package tui - -import ( - "strings" - "time" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - - "github.com/ivoronin/wch/internal/tui/notify" -) - -// inputBarStyles is the default textinput palette for any feature that wants its prompt to -// live inside the bottom status bar: every sub-element carries the bar's fg/bg so a -// long-value scroll-overflow doesn't bleed unstyled cells. Placeholder and suggestion inherit -// the bar plate and dim via Faint. The cursor is an explicit Cyan - visible against both -// light and dark bar plates. -var inputBarStyles = func() textinput.Styles { - on := barInnerStyle - dim := barInnerStyle.Faint(true) - state := textinput.StyleState{ - Text: on, - Prompt: on, - Placeholder: dim, - Suggestion: dim, - } - s := textinput.DefaultDarkStyles() - s.Focused = state - s.Blurred = state - s.Cursor.Color = ansi.Cyan - return s -}() - -// inputKeys gates the universal input bindings (submit/cancel) shown in every input flow. -var inputKeys = struct { - Submit key.Binding - Cancel key.Binding -}{ - Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "save")), - Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), -} - -// Body for inputState delegates to its host: the input prompt lives in the bar; the -// viewport content belongs to whatever state input is sitting on top of. -func (s inputState) Body(m Model) (string, bool) { - return s.prev.Body(m) -} - -// Timestamp delegates to the host: input is transparent for bar-clock concerns. -func (s inputState) Timestamp(m Model) (time.Time, bool) { - return s.prev.Timestamp(m) -} - -// ShowsBar returns true because the input prompt lives in the bar; hiding it -// would leave the user typing into nothing. The decision does not depend on -// prev — input always wants its own bar. -func (inputState) ShowsBar() bool { return true } - -// IsFrozen delegates to prev: inputState is transparent for viewport-derived -// concerns, so freezing semantics belong to the host. -func (s inputState) IsFrozen() bool { return s.prev.IsFrozen() } - -// FollowsTail delegates to prev for the same reason: tail-follow policy -// belongs to the host state, not the input prompt overlaid on top. -func (s inputState) FollowsTail(wasAtTail bool) bool { - return s.prev.FollowsTail(wasAtTail) -} - -// RenderBar renders the input widget across the full bar width, leaving room -// for the prompt and the bar's own horizontal padding so long values scroll -// inside the field instead of pushing the prompt off the left edge. -func (s inputState) RenderBar(m Model) string { - w := m.width - statusBarStyle.GetHorizontalFrameSize() - lipgloss.Width(s.input.Prompt) - if w > 0 { - s.input.SetWidth(w) - } - return statusBarStyle.Width(m.width).Render(s.input.View()) -} - -// Handle routes a key in inputState. Submit calls the configured submit -// function directly (no message hop). Cancel pops back to prev. Every other -// key is forwarded to the textinput; handled=true on every key keeps globals -// (q/t) from stealing printable chars. -func (s inputState) Handle(m Model, msg tea.KeyPressMsg) (Model, state, tea.Cmd, bool) { - switch { - case key.Matches(msg, inputKeys.Submit): - m2, st, cmd := s.submit(m, s) - return m2, st, cmd, true - case key.Matches(msg, inputKeys.Cancel): - return m, s.prev, nil, true - } - in, cmd := s.input.Update(msg) - s.input = in - return m, s, cmd, true -} - -// openInput is the shared constructor for the two input flows: builds an inputState wrapping -// the supplied textinput + submit callback on top of prev, and returns the textinput's focus -// Cmd. Order matters: textinput.Focus has a pointer receiver, so it must run BEFORE the -// inputState is built — otherwise the struct captures an unfocused snapshot of `in` and -// textinput.Update drops every keystroke. -func (m Model) openInput(prev state, in textinput.Model, submit func(Model, inputState) (Model, state, tea.Cmd)) (Model, state, tea.Cmd) { - focusCmd := in.Focus() - return m, inputState{input: in, prev: prev, submit: submit}, focusCmd -} - -// openSearchInput builds the inputState for a search prompt. If we are already inside an -// active search (the user pressed '/' to start over), input.prev skips the abandoned search -// so Esc from the replacement returns to the pre-search predecessor. -func (m Model) openSearchInput(from state) (Model, state, tea.Cmd) { - prev := from - if s, ok := from.(searchState); ok { - prev = s.prev - } - return m.openInput(prev, newBarInput(searchPromptLabel), applySearchSubmit) -} - -// applySearchSubmit decides what to do with the typed query: empty → silent pop; no matches -// → notification + pop; otherwise → enter a fresh searchState with the captured body and -// matches, applying the selection overlay + scroll-to-match. -func applySearchSubmit(m Model, s inputState) (Model, state, tea.Cmd) { - q := strings.TrimSpace(s.input.Value()) - if q == "" { - return m, s.prev, nil - } - // Capture the underlying state's body. After openSearchInput's prev-peel, - // s.prev is always view/picker — never searchState — so the body is just - // the current frame. Frame returns "" for an invalid index, so the no-cursor - // case (no history yet) flows into the "no matches" branch below without a - // separate guard. - i, ok := m.cursor.At() - body := m.frames.Frame(i, m.prefs.Diff) - matches := findMatches(ansi.Strip(body), q) - if len(matches) == 0 { - var cmd tea.Cmd - m, cmd = m.push(notify.LevelInfo, "no matches") - return m, s.prev, cmd - } - next := searchState{ - query: q, - body: body, - matches: matches, - selected: 0, - follow: m.isFollowing(), - prev: s.prev, - } - if ok { - next.captured = m.session.History[i].Timestamp - } - m = m.repaintWith(next, next.snap()) - return m, next, nil -} - -// newBarInput constructs a textinput pre-styled to live in the bottom bar. -func newBarInput(prompt string) textinput.Model { - in := textinput.New() - in.Prompt = prompt - in.SetStyles(inputBarStyles) - return in -} diff --git a/internal/tui/state_input_test.go b/internal/tui/state_input_test.go deleted file mode 100644 index 2949164..0000000 --- a/internal/tui/state_input_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package tui - -import ( - "strings" - "testing" - "time" - - tea "charm.land/bubbletea/v2" -) - -// In inputState, a bracketed-paste msg should land in the textinput's value. Update must -// forward tea.PasteMsg to the active state — otherwise terminal pastes silently vanish. -func TestInputStateReceivesPasteMsg(t *testing.T) { - m := New(Config{Command: "kubectl", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 80, Height: 24}) - m = pressKey(t, m, 'r') - in, ok := m.state.(inputState) - if !ok { - t.Fatalf("setup: state = %T, want inputState", m.state) - } - in.input.SetValue("") - m.state = in - - m = feed(t, m, tea.PasteMsg{Content: "hello"}) - - got := m.state.(inputState).input.Value() - if !strings.Contains(got, "hello") { - t.Errorf("input value after paste = %q, want it to contain \"hello\"", got) - } -} - -// TestInputStateBodyDelegatesToPrev: inputState is transparent; its Body returns -// exactly what prev.Body returns. -func TestInputStateBodyDelegatesToPrev(t *testing.T) { - m := makePaintModel(t) - prev := viewState{} - in := inputState{prev: prev} - - prevBody, prevOk := prev.Body(m) - gotBody, gotOk := in.Body(m) - if gotOk != prevOk { - t.Errorf("inputState.Body ok=%v, want %v", gotOk, prevOk) - } - if gotBody != prevBody { - t.Errorf("inputState.Body did not delegate to prev") - } -} - -func TestInputStateShowsBar(t *testing.T) { - cases := []struct { - name string - prev state - }{ - {"on view", viewState{}}, - {"on picker", pickerState{}}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - s := inputState{prev: c.prev} - if !s.ShowsBar() { - t.Errorf("inputState.ShowsBar() = false, want true") - } - }) - } -} - -func TestInputStateIsFrozenDelegatesToPrev(t *testing.T) { - cases := []struct { - name string - prev state - want bool - }{ - {"on view", viewState{}, false}, - {"on picker", pickerState{}, false}, - {"on search", searchState{prev: viewState{}}, true}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := (inputState{prev: c.prev}).IsFrozen(); got != c.want { - t.Errorf("IsFrozen() = %v, want %v", got, c.want) - } - }) - } -} - -func TestInputStateFollowsTailDelegatesToPrev(t *testing.T) { - cases := []struct { - name string - prev state - wasAtTail bool - want bool - }{ - {"on view at tail", viewState{}, true, true}, - {"on view not at tail", viewState{}, false, false}, - {"on search inherits search follow", searchState{prev: viewState{}, follow: true}, false, true}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := (inputState{prev: c.prev}).FollowsTail(c.wasAtTail); got != c.want { - t.Errorf("FollowsTail(%v) = %v, want %v", c.wasAtTail, got, c.want) - } - }) - } -} - -// TestInputStateHandleEscPopsToPrev pins that Esc returns to the wrapped prev state. -func TestInputStateHandleEscPopsToPrev(t *testing.T) { - m := makePaintModel(t) - in := inputState{prev: viewState{}, input: newBarInput("/")} - m.state = in - _, st, _, handled := in.Handle(m, tea.KeyPressMsg{Code: tea.KeyEsc}) - if !handled { - t.Fatalf("inputState.Handle(Esc) reported handled=false") - } - if _, ok := st.(viewState); !ok { - t.Errorf("inputState.Handle(Esc) returned %T, want viewState (the prev)", st) - } -} diff --git a/internal/tui/state_picker.go b/internal/tui/state_picker.go deleted file mode 100644 index ac48c60..0000000 --- a/internal/tui/state_picker.go +++ /dev/null @@ -1,154 +0,0 @@ -package tui - -import ( - "time" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "github.com/ivoronin/wch/internal/session" -) - -// timestampLen is the display width of a formatted timestamp. -var timestampLen = lipgloss.Width(timestampFmt) - -// pickerSpacerLeft / pickerSpacerRight are hoisted style values used per item in the picker -// timeline. Lipgloss styles are immutable so the per-render NewStyle().Padding...() calls -// were pure waste — these constants do the same work once at startup. -var ( - pickerSpacerLeft = lipgloss.NewStyle().PaddingRight(itemSpacing) - pickerSpacerRight = lipgloss.NewStyle().PaddingLeft(itemSpacing) -) - -// Body for pickerState is identical to viewState: the picker timeline lives in the bar, -// the viewport shows the frame at the cursor position. -func (pickerState) Body(m Model) (string, bool) { - i, ok := m.cursor.At() - return m.frames.Frame(i, m.prefs.Diff), ok -} - -// Timestamp returns the timestamp of the frame at the cursor position -- the picker's -// cursor and view's cursor are the same field on Model. -func (pickerState) Timestamp(m Model) (time.Time, bool) { - i, ok := m.cursor.At() - if !ok { - return time.Time{}, false - } - return m.session.History[i].Timestamp, true -} - -// ShowsBar returns true because the picker timeline IS the bar — without it -// the user has no visible cursor or surrounding context for the history -// position they are navigating. -func (pickerState) ShowsBar() bool { return true } - -// IsFrozen returns false: picker shows the same body as view, and that body -// must keep tracking live executions while the user browses history. -func (pickerState) IsFrozen() bool { return false } - -// FollowsTail mirrors viewState: the underlying historyIndex advances to the -// new tail iff the user was at the tail. The picker's own cursor sits on -// historyIndex by definition. -func (pickerState) FollowsTail(wasAtTail bool) bool { return wasAtTail } - -// RenderBar renders the timeline-style bar replacing the status bar: a left -// and right column of timestamps surrounding the centered selected timestamp. -func (pickerState) RenderBar(m Model) string { - return renderPickerTimeline(m.session.History, m.cursor.Index(), m.width) -} - -// Handle processes a key for pickerState. Common bindings (diff/pause/record/ -// search) are tried first via handleCommonKey; picker-specific bindings -// (Confirm, cursor movement) come after. ↑/↓ are unhandled here and fall -// through to global scroll. -func (s pickerState) Handle(m Model, msg tea.KeyPressMsg) (Model, state, tea.Cmd, bool) { - if newM, st, cmd, handled := m.handleCommonKey(s, msg); handled { - return newM, st, cmd, true - } - switch { - case key.Matches(msg, pickerKeys.Confirm), key.Matches(msg, commonKeys.Escape): - return m, viewState{}, nil, true - case key.Matches(msg, navKeys.Left): - return m.withCursor(m.cursor.Index() - 1), s, nil, true - case key.Matches(msg, navKeys.Right): - return m.withCursor(m.cursor.Index() + 1), s, nil, true - case key.Matches(msg, navKeys.Home), key.Matches(msg, navKeys.ScrollLeft): - return m.withCursor(0), s, nil, true - case key.Matches(msg, navKeys.End), key.Matches(msg, navKeys.ScrollRight): - return m.withCursor(len(m.session.History) - 1), s, nil, true - } - return m, s, nil, false -} - -// renderPickerTimeline is the pure-data picker bar renderer: given the history slice, the -// selected index, and the available width, it builds the horizontal timeline strip. -func renderPickerTimeline(history []session.Execution, selected, width int) string { - if len(history) == 0 { - return statusBarStyle.Width(width).Render("") - } - - itemWidth := timestampLen + itemSpacing - - timestamp := history[selected].Timestamp.Format(timestampFmt) - layout := calcThreeColumnLayout(width, timestampLen) - - leftItems, rightItems := pickerItems(history, selected, layout.leftWidth-arrowWidth, layout.rightWidth-arrowWidth, itemWidth) - - left := pickerSide(layout.leftWidth, leftItems, selected > len(leftItems), true) - right := pickerSide(layout.rightWidth, rightItems, selected+len(rightItems)+1 < len(history), false) - - center := pickerSelectedStyle.Render(timestamp) - content := lipgloss.JoinHorizontal(lipgloss.Top, left, center, right) - return statusBarStyle.Width(width).Render(content) -} - -// pickerItems returns the timestamps that fit in the left/right sections around the selection. -func pickerItems(history []session.Execution, selected, leftSpace, rightSpace, itemWidth int) (left, right []string) { - for i := selected - 1; i >= 0 && leftSpace >= itemWidth; i-- { - left = append(left, history[i].Timestamp.Format(timestampFmt)) - leftSpace -= itemWidth - } - for i := selected + 1; i < len(history) && rightSpace >= itemWidth; i++ { - right = append(right, history[i].Timestamp.Format(timestampFmt)) - rightSpace -= itemWidth - } - return left, right -} - -// pickerSide renders one side of the picker timeline (left or right of the selected -// timestamp). The two sides are mirror images: items run inward toward the centered -// selection, an arrow appears at the outer edge when more entries exist beyond the visible -// window. left=true renders the left side (items reversed, spacer pads on the right of each -// item, arrow at the left, items right-aligned); left=false renders the right side. -func pickerSide(width int, items []string, more, left bool) string { - arrow := lipgloss.NewStyle().Width(arrowWidth).Render("") - if more { - if left { - arrow = "◀ " - } else { - arrow = " ▶" - } - } - - spacer := pickerSpacerRight - if left { - spacer = pickerSpacerLeft - } - spacedItems := make([]string, len(items)) - for i, item := range items { - idx := i - if left { - idx = len(items) - 1 - i - } - spacedItems[idx] = spacer.Render(item) - } - itemsStr := lipgloss.JoinHorizontal(lipgloss.Top, spacedItems...) - - if left { - content := renderRight(itemsStr, width-arrowWidth) - return pickerItemStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, arrow, content)) - } - content := renderLeft(itemsStr, width-arrowWidth) - return pickerItemStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, content, arrow)) -} diff --git a/internal/tui/state_picker_test.go b/internal/tui/state_picker_test.go deleted file mode 100644 index ee5a366..0000000 --- a/internal/tui/state_picker_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package tui - -import ( - "fmt" - "reflect" - "strings" - "testing" - "time" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - - "github.com/ivoronin/wch/internal/session" -) - -// pickerState.Handle maps cursor keys to historyIndex movements and Confirm/Esc to a state -// return of viewState{}. Unhandled keys (e.g. arbitrary letters) report handled=false so -// the global fallback can fire. -func TestHandlePickerKey(t *testing.T) { - // Need a model with history so withCursor doesn't clamp to -1. - m := New(Config{Command: "x", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - for i := 0; i < 5; i++ { - m = feed(t, m, execResultMsg{exec: session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, i, 0, time.UTC), - Stdout: fmt.Sprintf("frame %d\n", i), - }}) - } - - cases := []struct { - name string - key tea.KeyPressMsg - wantState string // type name, checked via type switch - wantIndex int - }{ - {"left", tea.KeyPressMsg{Code: tea.KeyLeft}, "tui.pickerState", 3}, - {"right (clamped)", tea.KeyPressMsg{Code: tea.KeyRight}, "tui.pickerState", 4}, - {"confirm", tea.KeyPressMsg{Code: tea.KeyEnter}, "tui.viewState", 4}, - {"esc", tea.KeyPressMsg{Code: tea.KeyEsc}, "tui.viewState", 4}, - } - - for _, c := range cases { - // historyIndex starts at 4 (tail). - mm := m - mm.state = pickerState{} - mm.cursor = cursorAt(4) - newM, s, _, handled := pickerState{}.Handle(mm, c.key) - if !handled { - t.Errorf("%s: handled=false, want true", c.name) - } - got := reflect.TypeOf(s).String() - if got != c.wantState { - t.Errorf("%s: state = %s, want %s", c.name, got, c.wantState) - } - if newM.cursor.Index() != c.wantIndex { - t.Errorf("%s: historyIndex = %d, want %d", c.name, newM.cursor.Index(), c.wantIndex) - } - } - - // Unhandled key. - _, _, _, handled := pickerState{}.Handle(m, tea.KeyPressMsg{Code: 'x', Text: "x"}) - if handled { - t.Errorf("unhandled key reported handled=true") - } -} - -// An empty history yields a blank, width-sized bar (no panic, no out-of-range access). -func TestRenderPickerTimelineEmpty(t *testing.T) { - if got, want := renderPickerTimeline(nil, 0, 40), statusBarStyle.Width(40).Render(""); got != want { - t.Errorf("renderPickerTimeline(empty)=%q want %q", got, want) - } -} - -// The bar shows the selected timestamp plus its neighbours and fits the given width. -func TestRenderPickerTimelineShowsTimestamps(t *testing.T) { - const width = 80 - base := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC) - history := make([]session.Execution, 5) - for i := range history { - history[i] = session.Execution{Timestamp: base.Add(time.Duration(i) * time.Second)} - } - - out := renderPickerTimeline(history, 2, width) - - if w := lipgloss.Width(out); w != width { - t.Errorf("rendered width=%d want %d", w, width) - } - plain := ansi.Strip(out) - for i, exec := range history { - ts := exec.Timestamp.Format(timestampFmt) - if !strings.Contains(plain, ts) { - t.Errorf("bar missing timestamp %d (%q): %q", i, ts, plain) - } - } -} - -// TestPickerStateBodyReturnsFrame: picker renders the same body as view -- the -// timeline lives in the bar, not the viewport. -func TestPickerStateBodyReturnsFrame(t *testing.T) { - m := makePaintModel(t) - m.state = pickerState{} - - body, ok := (pickerState{}).Body(m) - if !ok { - t.Fatalf("pickerState.Body: ok=false with history present") - } - if want := m.frames.Frame(m.cursor.Index(), m.prefs.Diff); body != want { - t.Errorf("pickerState.Body returned different body than frames.Frame") - } -} - -func TestPickerStateShowsBar(t *testing.T) { - if got := (pickerState{}).ShowsBar(); got != true { - t.Errorf("pickerState.ShowsBar() = %v, want true", got) - } -} - -func TestPickerStateIsFrozen(t *testing.T) { - if got := (pickerState{}).IsFrozen(); got != false { - t.Errorf("pickerState.IsFrozen() = %v, want false", got) - } -} - -func TestPickerStateFollowsTail(t *testing.T) { - cases := []struct { - name string - wasAtTail bool - want bool - }{ - {"at tail", true, true}, - {"not at tail", false, false}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := (pickerState{}).FollowsTail(c.wasAtTail); got != c.want { - t.Errorf("FollowsTail(%v) = %v, want %v", c.wasAtTail, got, c.want) - } - }) - } -} - -// TestPickerStateHandleEscReturnsToView pins Esc in pickerState returns to viewState. -func TestPickerStateHandleEscReturnsToView(t *testing.T) { - m := makePaintModel(t) - m.state = pickerState{} - _, st, _, handled := pickerState{}.Handle(m, tea.KeyPressMsg{Code: tea.KeyEsc}) - if !handled { - t.Fatalf("pickerState.Handle(Esc) reported handled=false") - } - if _, ok := st.(viewState); !ok { - t.Errorf("pickerState.Handle(Esc) returned %T, want viewState", st) - } -} diff --git a/internal/tui/state_search.go b/internal/tui/state_search.go deleted file mode 100644 index 92dddbe..0000000 --- a/internal/tui/state_search.go +++ /dev/null @@ -1,131 +0,0 @@ -package tui - -import ( - "fmt" - "time" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - - "github.com/ivoronin/wch/internal/tui/searchrender" -) - -// searchPromptLabel is what appears in front of the editable search query in inputState -// when opened via the search flow. -const searchPromptLabel = "/" - -// Body for searchState returns the captured body with the selected match highlighted. -// It deliberately does NOT scroll the viewport -- that would let a bar toggle ('t') or a -// terminal resize yank the user's scroll position back to the match even when they had -// scrolled away to read context. Scroll-to-match happens explicitly via the snap() -// target passed to repaintWith at search entry and on n/p navigation. -func (s searchState) Body(m Model) (string, bool) { - if len(s.matches) == 0 { - return "", false - } - sel := s.matches[s.selected] - return searchrender.Render(s.body, sel.line, sel.col, sel.length), true -} - -// Timestamp returns the captured frame's timestamp (set at search entry from the cursor's -// then-current frame). The clock stays pinned to the frame the user searched in even as -// new executions advance the underlying cursor in the background. -func (s searchState) Timestamp(m Model) (time.Time, bool) { - if s.captured.IsZero() { - return time.Time{}, false - } - return s.captured, true -} - -// snap returns the viewport scroll-to-cell effect for the currently selected match, -// or nil when no matches. Used at search entry and on n/p navigation -- never from -// Body, so unrelated re-paints (bar toggle, resize) don't disturb the user's scroll -// position. -func (s searchState) snap() *snapTarget { - if len(s.matches) == 0 { - return nil - } - sel := s.matches[s.selected] - return &snapTarget{line: sel.line, col: sel.col, length: sel.length} -} - -// ShowsBar returns false: search hides under -t by design (the user opted out -// of the bar, and the highlighted match in the viewport remains visible). -// Model.barShown ORs this with m.prefs.StatusBar at the caller, so search's bar -// appears exactly when the user-configured preference is on. The return value -// is prev-agnostic: even in the search-from-picker flow (where prev is -// pickerState) the bar still hides — search's UX takes precedence over the -// picker's bar need. -func (searchState) ShowsBar() bool { return false } - -// IsFrozen returns true: searchState's viewport holds a captured body that -// the user navigates with n/p. New history must not repaint — otherwise the -// highlight overlay disappears mid-navigation. -func (searchState) IsFrozen() bool { return true } - -// FollowsTail returns the captured follow flag (set at search entry from the -// user's at-tail state). New history advances historyIndex in the background -// so an Esc from search lands at the new tail without a second keystroke. -func (s searchState) FollowsTail(_ bool) bool { return s.follow } - -// RenderBar composes the three-column status bar with search-specific slot -// contents: the query and the match counter sit together at the bar's left -// edge with a single-space separator; when the query is too long to fit -// alongside the counter in the left zone, the query is ellipsized so the -// counter always stays visible. -func (s searchState) RenderBar(m Model) string { - query := searchPromptLabel + s.query - // Brackets give the counter just enough visual weight to read as a discrete element - // without leaning on color or bold — those would compete with the timestamp+❄ group. - counter := fmt.Sprintf("[%d of %d]", s.selected+1, len(s.matches)) - indicator := indicatorStyle.Render("❄") - left := queryWithCounter(query, counter, barLeftZoneWidth(m.width)) - // n is advertised since it's the primary post-match action; the rest (p, / restart) - // lives in the help overlay. - help := renderHelp(minimalBarBindings(commonKeys.Escape, searchKeys.NavNext)) - return m.renderBarLayout(left, indicator, help) -} - -// Handle processes a key for searchState. n/p navigates matches with wrap; -// '/' opens a new search input (replacing this search on the stack); Esc -// pops back to prev. Unhandled keys fall through to globalKeys so navigation -// defaults scroll the frozen body without losing the highlight. -func (s searchState) Handle(m Model, msg tea.KeyPressMsg) (Model, state, tea.Cmd, bool) { - switch { - case key.Matches(msg, searchKeys.NavNext): - m, s = s.advance(m, 1) - return m, s, nil, true - case key.Matches(msg, searchKeys.NavPrev): - m, s = s.advance(m, -1) - return m, s, nil, true - case key.Matches(msg, commonKeys.Search): - m2, st, cmd := m.openSearchInput(s) - return m2, st, cmd, true - case key.Matches(msg, commonKeys.Escape): - return m, s.prev, nil, true - } - return m, s, nil, false -} - -// advance moves the selection by delta (wrapping around), repaints the highlight, -// and snaps the viewport to the new match. Shared by NavNext and NavPrev. -func (s searchState) advance(m Model, delta int) (Model, searchState) { - n := len(s.matches) - s.selected = ((s.selected+delta)%n + n) % n - return m.repaintWith(s, s.snap()), s -} - -// queryWithCounter joins head and tail with a single space, ellipsizing head if the pair -// doesn't fit in zoneWidth. The tail is treated as a hard requirement; if the zone is too -// narrow to fit even tail+space+1 cell of head, the tail is returned alone. -func queryWithCounter(head, tail string, zoneWidth int) string { - tailW := lipgloss.Width(tail) - if zoneWidth <= tailW+1 { - return tail - } - headBudget := zoneWidth - tailW - 1 - head = ansi.Truncate(head, headBudget, "…") - return lipgloss.JoinHorizontal(lipgloss.Top, head, " ", tail) -} diff --git a/internal/tui/state_search_test.go b/internal/tui/state_search_test.go deleted file mode 100644 index d1fd73f..0000000 --- a/internal/tui/state_search_test.go +++ /dev/null @@ -1,402 +0,0 @@ -package tui - -import ( - "strings" - "testing" - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/ivoronin/wch/internal/session" - "github.com/ivoronin/wch/internal/tui/searchrender" -) - -// searchTestModel builds a sized model with one frame containing repeated "Pending" entries -// for the search integration tests. -func searchTestModel(t *testing.T) Model { - t.Helper() - m := New(Config{Command: "kubectl get pods -A", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 80, Height: 24}) - m = feed(t, m, execResultMsg{exec: session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC), - Stdout: "pod-01 Running\npod-02 Pending\npod-03 Running\npod-04 Pending\npod-05 PENDING\n", - }}) - return m -} - -// Pressing '/' opens inputState with the search prompt and an empty value; the pre-search -// state lives on as input.prev. -func TestSearchOpenInput(t *testing.T) { - m := searchTestModel(t) - m = pressKey(t, m, '/') - - in, ok := m.state.(inputState) - if !ok { - t.Fatalf("after /, m.state = %T, want inputState", m.state) - } - if in.input.Value() != "" { - t.Errorf("search input should start empty; got %q", in.input.Value()) - } - if in.input.Prompt != searchPromptLabel { - t.Errorf("search input prompt = %q, want %q", in.input.Prompt, searchPromptLabel) - } - if _, ok := in.prev.(viewState); !ok { - t.Errorf("input.prev = %T, want viewState", in.prev) - } -} - -// Submitting a query with matches transitions to searchState; body and matches are captured. -func TestSearchSubmitWithMatchesEntersSearchMode(t *testing.T) { - m := searchTestModel(t) - m = pressKey(t, m, '/') - m = submitInputValue(t, m, "Pending") - - sm, ok := m.state.(searchState) - if !ok { - t.Fatalf("after submit with matches, m.state = %T, want searchState", m.state) - } - if sm.query != "Pending" { - t.Errorf("query = %q, want Pending", sm.query) - } - if len(sm.matches) != 2 { - t.Errorf("matches = %d, want 2", len(sm.matches)) - } - if sm.selected != 0 { - t.Errorf("selected = %d, want 0", sm.selected) - } - if _, ok := sm.prev.(viewState); !ok { - t.Errorf("search.prev = %T, want viewState", sm.prev) - } -} - -// No matches → notification + pop back to the pre-input state. -func TestSearchSubmitNoMatchNotifiesAndPops(t *testing.T) { - m := searchTestModel(t) - m = pressKey(t, m, '/') - m = submitInputValue(t, m, "asdfqwer") - - if _, ok := m.state.(viewState); !ok { - t.Errorf("after no-match submit, m.state = %T, want viewState", m.state) - } - if !m.notify.Active() { - t.Errorf("expected notification bubble on no-match") - } -} - -// Empty/whitespace query is a silent cancel. -func TestSearchSubmitEmptyIsSilent(t *testing.T) { - m := searchTestModel(t) - m = pressKey(t, m, '/') - m = submitInputValue(t, m, " ") - - if _, ok := m.state.(viewState); !ok { - t.Errorf("after empty submit, m.state = %T, want viewState", m.state) - } - if m.notify.Active() { - t.Errorf("empty submit must not push a notification") - } -} - -// n/p navigates matches with wrap. -func TestSearchNavigationWraps(t *testing.T) { - m := searchTestModel(t) - m = pressKey(t, m, '/') - m = submitInputValue(t, m, "pending") // smart-case → 3 matches - - sm := m.state.(searchState) - if len(sm.matches) != 3 { - t.Fatalf("setup: matches = %d, want 3", len(sm.matches)) - } - - m = pressKey(t, m, 'n') - if m.state.(searchState).selected != 1 { - t.Errorf("after 1×n, selected = %d, want 1", m.state.(searchState).selected) - } - m = pressKey(t, m, 'n') - m = pressKey(t, m, 'n') - if m.state.(searchState).selected != 0 { - t.Errorf("after 3×n, selected = %d, want 0 (wrap)", m.state.(searchState).selected) - } - m = pressKey(t, m, 'N') - if m.state.(searchState).selected != 2 { - t.Errorf("after N from 0, selected = %d, want 2 (wrap)", m.state.(searchState).selected) - } -} - -// execResultMsg during searchState appends to history but does NOT change the viewport -// content (frozen snapshot). -func TestSearchFreezeDuringExecResult(t *testing.T) { - m := searchTestModel(t) - m = pressKey(t, m, '/') - m = submitInputValue(t, m, "Pending") - - before := strings.Clone(m.state.(searchState).body) - historyLenBefore := len(m.session.History) - - m = feed(t, m, execResultMsg{exec: session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, 1, 0, time.UTC), - Stdout: "pod-99 Running\n", - }}) - - if _, ok := m.state.(searchState); !ok { - t.Errorf("searchState lost across execResultMsg; got %T", m.state) - } - if got := m.state.(searchState).body; got != before { - t.Errorf("frozen body changed after execResultMsg") - } - if len(m.session.History) == historyLenBefore { - t.Errorf("history should still grow during searchState; remained %d", historyLenBefore) - } -} - -// search-from-tail: Esc must resume live following in one keystroke. -func TestSearchEscRestoresLiveTail(t *testing.T) { - m := searchTestModel(t) - if !m.isFollowing() { - t.Fatalf("setup: model should start at the live tail") - } - indexAtSearchStart := m.cursor.Index() - - m = pressKey(t, m, '/') - m = submitInputValue(t, m, "Pending") - - m = feed(t, m, execResultMsg{exec: session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, 1, 0, time.UTC), - Stdout: "pod-06 Pending\n", - }}) - m = feed(t, m, execResultMsg{exec: session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, 2, 0, time.UTC), - Stdout: "pod-07 Running\n", - }}) - - m = feed(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) - if _, ok := m.state.(viewState); !ok { - t.Fatalf("after Esc, m.state = %T, want viewState", m.state) - } - if !m.isFollowing() { - t.Errorf("after Esc, isFollowing() = false; want true (search-from-tail resumes following)") - } - if m.cursor.Index() == indexAtSearchStart { - t.Errorf("after Esc, historyIndex = %d (stuck at search-start); should advance to %d", - m.cursor.Index(), len(m.session.History)-1) - } -} - -// search-from-past: Esc returns to that same past frame; no follow. -func TestSearchEscFromPastFrameStaysOnPast(t *testing.T) { - m := searchTestModel(t) - m = feed(t, m, execResultMsg{exec: session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, 1, 0, time.UTC), - Stdout: "pod-06 Running\n", - }}) - m = m.withCursor(0) - if m.isFollowing() { - t.Fatalf("setup: should be viewing past frame") - } - pastIndex := m.cursor.Index() - - m = pressKey(t, m, '/') - m = submitInputValue(t, m, "Pending") - - m = feed(t, m, execResultMsg{exec: session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, 2, 0, time.UTC), - Stdout: "pod-07 Running\n", - }}) - - m = feed(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) - if m.cursor.Index() != pastIndex { - t.Errorf("after Esc from past-frame search, historyIndex = %d, want %d", m.cursor.Index(), pastIndex) - } -} - -// Esc out of searchState returns to the pre-search state. -func TestSearchEscReturnsToPreSearchMode(t *testing.T) { - m := searchTestModel(t) - m = pressKey(t, m, '/') - m = submitInputValue(t, m, "Pending") - if _, ok := m.state.(searchState); !ok { - t.Fatalf("setup: should be in searchState, got %T", m.state) - } - - m = feed(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) - if _, ok := m.state.(viewState); !ok { - t.Errorf("after Esc, m.state = %T, want viewState", m.state) - } -} - -// picker → search → Esc returns to picker via the prev chain. -func TestSearchFromPickerEscReturnsToPicker(t *testing.T) { - m := searchTestModel(t) - m = pressKey(t, m, 'b') // enter picker - if _, ok := m.state.(pickerState); !ok { - t.Fatalf("setup: should be in pickerState, got %T", m.state) - } - - m = pressKey(t, m, '/') - m = submitInputValue(t, m, "Pending") - if _, ok := m.state.(searchState); !ok { - t.Fatalf("should be searchState, got %T", m.state) - } - - m = feed(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) - if _, ok := m.state.(pickerState); !ok { - t.Errorf("after Esc from search-via-picker, m.state = %T, want pickerState", m.state) - } -} - -// '/' inside searchState replaces the current search on the stack: input.prev skips the -// abandoned search. Esc from the replacement returns to the original predecessor (viewState). -func TestSearchSubmitFromSearchModeReplacesOnStack(t *testing.T) { - m := searchTestModel(t) - m = pressKey(t, m, '/') - m = submitInputValue(t, m, "Running") - first := m.state.(searchState) - - // '/' from within searchState. - m = pressKey(t, m, '/') - in, ok := m.state.(inputState) - if !ok { - t.Fatalf("after / in search, m.state = %T, want inputState", m.state) - } - // input.prev should be the searchState's prev (viewState), NOT the old searchState. - if _, ok := in.prev.(viewState); !ok { - t.Errorf("input.prev = %T, want viewState (search-from-search replaces)", in.prev) - } - - m = submitInputValue(t, m, "Pending") - second, ok := m.state.(searchState) - if !ok { - t.Fatalf("after second submit, m.state = %T, want searchState", m.state) - } - if second.query == first.query { - t.Errorf("new searchState kept old query %q; should be Pending", second.query) - } - - m = feed(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) - if _, ok := m.state.(viewState); !ok { - t.Errorf("after Esc from replacing search, m.state = %T, want viewState", m.state) - } -} - -// TestSearchStateBodyReturnsOverlay: searchState.Body returns the captured body -// with the selected match wrapped in the searchrender overlay. -func TestSearchStateBodyReturnsOverlay(t *testing.T) { - m := makePaintModel(t) - body := m.frames.Frame(m.cursor.Index(), m.prefs.Diff) - ss := searchState{ - query: "pod-15", - body: body, - matches: findMatches(body, "pod-15"), - selected: 0, - } - if len(ss.matches) == 0 { - t.Fatalf("test setup: no matches for pod-15 in body") - } - sel := ss.matches[0] - want := searchrender.Render(body, sel.line, sel.col, sel.length) - - got, ok := ss.Body(m) - if !ok { - t.Fatalf("searchState.Body: ok=false with matches present") - } - if got != want { - t.Errorf("searchState.Body did not produce the searchrender overlay") - } -} - -// TestSearchStateBodyZeroMatches: with zero matches, searchState.Body reports -// ok=false so the orchestrator skips repaint. -func TestSearchStateBodyZeroMatches(t *testing.T) { - m := makePaintModel(t) - ss := searchState{body: "anything", matches: nil} - if _, ok := ss.Body(m); ok { - t.Errorf("searchState.Body: ok=true with zero matches") - } -} - -// TestRestartSearchClearsOverlay: pressing '/' from within searchState -// transitions to inputState{prev: viewState}; the viewport must show the -// live (un-highlighted) body, not the prior search's overlay. -// -// The dispatchKey bar-transition hook ends with m.repaint(), which for inputState -// delegates to its prev's Body, which yields the live frame. Before the seam -// landed, the viewport stayed on the prior search overlay until the next exec. -func TestRestartSearchClearsOverlay(t *testing.T) { - m := New(Config{Command: "x", Interval: time.Second}) - m.prefs.StatusBar = false - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("5m", podNames(20))}}) - - // Enter search. - m = pressKey(t, m, '/') - m = submitInputValue(t, m, "pod-15") - if _, ok := m.state.(searchState); !ok { - t.Fatalf("after submit: m.state = %T, want searchState", m.state) - } - overlay := m.frames.View() - if !strings.Contains(overlay, "\x1b[7m") { - t.Fatalf("test setup: overlay snapshot lacks reverse-video highlight; searchState did not paint as expected") - } - - // Re-search via '/'. State transitions search → input{prev: view}. - // The bar-transition resize hook must repaint, which (via input → view) - // commits the live body to the viewport. - m = pressKey(t, m, '/') - if _, ok := m.state.(inputState); !ok { - t.Fatalf("after second '/': m.state = %T, want inputState", m.state) - } - got := m.frames.View() - - if got == overlay { - t.Fatalf("viewport still shows the search overlay after re-search; expected the live body to replace it") - } - if strings.Contains(got, "\x1b[7m") { - t.Errorf("viewport still carries reverse-video overlay after re-search; got: %q", got) - } -} - -func TestSearchStateShowsBar(t *testing.T) { - if got := (searchState{prev: viewState{}}).ShowsBar(); got != false { - t.Errorf("searchState.ShowsBar() = %v, want false", got) - } -} - -func TestSearchStateIsFrozen(t *testing.T) { - if got := (searchState{prev: viewState{}}).IsFrozen(); got != true { - t.Errorf("searchState.IsFrozen() = %v, want true", got) - } -} - -func TestSearchStateFollowsTailUsesCapturedFlag(t *testing.T) { - cases := []struct { - name string - follow bool - wasAtTail bool - want bool - }{ - {"follow=true ignores wasAtTail=false", true, false, true}, - {"follow=false ignores wasAtTail=true", false, true, false}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - s := searchState{prev: viewState{}, follow: c.follow} - if got := s.FollowsTail(c.wasAtTail); got != c.want { - t.Errorf("FollowsTail(%v) = %v, want %v", c.wasAtTail, got, c.want) - } - }) - } -} - -// TestSearchStateHandleEscReturnsToPrev pins Esc returns to the prev (pre-search) state. -func TestSearchStateHandleEscReturnsToPrev(t *testing.T) { - m := makePaintModel(t) - ss := searchState{prev: viewState{}} - m.state = ss - _, st, _, handled := ss.Handle(m, tea.KeyPressMsg{Code: tea.KeyEsc}) - if !handled { - t.Fatalf("searchState.Handle(Esc) reported handled=false") - } - if _, ok := st.(viewState); !ok { - t.Errorf("searchState.Handle(Esc) returned %T, want viewState (prev)", st) - } -} diff --git a/internal/tui/state_view.go b/internal/tui/state_view.go deleted file mode 100644 index e18bd81..0000000 --- a/internal/tui/state_view.go +++ /dev/null @@ -1,80 +0,0 @@ -package tui - -import ( - "time" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" -) - -// Body returns the rendered frame at the cursor position. Reports ok=false when there is -// no history yet so the caller leaves the viewport untouched. Frame returns "" for i<0, -// matching the no-cursor case. -func (viewState) Body(m Model) (string, bool) { - i, ok := m.cursor.At() - return m.frames.Frame(i, m.prefs.Diff), ok -} - -// Timestamp returns the timestamp of the frame at the cursor position. Reports ok=false -// when there is no history yet. -func (viewState) Timestamp(m Model) (time.Time, bool) { - i, ok := m.cursor.At() - if !ok { - return time.Time{}, false - } - return m.session.History[i].Timestamp, true -} - -// ShowsBar returns false because viewState's bar visibility depends only on -// the user preference m.prefs.StatusBar — there is no view-driven need for the bar -// to stay on. Model.barShown ORs this with m.prefs.StatusBar at the caller. -func (viewState) ShowsBar() bool { return false } - -// IsFrozen returns false: viewState always repaints on a new exec; the user -// is watching live output. -func (viewState) IsFrozen() bool { return false } - -// FollowsTail returns the caller's wasAtTail: viewState advances historyIndex -// to the new tail iff the user was already at the tail before the new exec -// arrived. -func (viewState) FollowsTail(wasAtTail bool) bool { return wasAtTail } - -// RenderBar composes the standard three-column status bar for viewState. -// renderBarLayout → renderLeft already truncates to its leftWidth slot, so no -// pre-truncate is needed here. -func (viewState) RenderBar(m Model) string { - return m.renderBarLayout(m.session.Command, m.renderIndicator(), renderHelp(viewHelpBindings(m))) -} - -// Handle processes a key for viewState. Common bindings (diff/pause/record/ -// search) are tried first via handleCommonKey; viewState-specific bindings -// come after. Unmatched keys fall through to handleGlobalKey (q/t/navigation) -// via dispatchKey's `if !handled` path. -func (s viewState) Handle(m Model, msg tea.KeyPressMsg) (Model, state, tea.Cmd, bool) { - if newM, st, cmd, handled := m.handleCommonKey(s, msg); handled { - return newM, st, cmd, true - } - switch { - case key.Matches(msg, viewKeys.Picker): - if len(m.session.History) == 0 { - return m, s, nil, true - } - return m, pickerState{}, nil, true - case key.Matches(msg, commonKeys.Escape): - n := len(m.session.History) - if !m.cursor.Following(n) { - return m.withCursor(n - 1), s, nil, true - } - return m, s, nil, true - } - return m, s, nil, false -} - -// viewHelpBindings returns the bar trailer for viewState. At the live tail we advertise -// b to enter the picker; when viewing a past frame we swap that for Esc (back to tail). -func viewHelpBindings(m Model) []key.Binding { - if m.isFollowing() { - return minimalBarBindings(viewKeys.Picker) - } - return minimalBarBindings(commonKeys.Escape) -} diff --git a/internal/tui/state_view_test.go b/internal/tui/state_view_test.go deleted file mode 100644 index 18cb623..0000000 --- a/internal/tui/state_view_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package tui - -import ( - "testing" - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/ivoronin/wch/internal/session" -) - -// TestViewStateBodyReturnsFrame: viewState.Body returns the frame at historyIndex, -// matching frames.Frame(historyIndex, prefs.Diff). -func TestViewStateBodyReturnsFrame(t *testing.T) { - m := makePaintModel(t) - m.state = viewState{} - - body, ok := (viewState{}).Body(m) - if !ok { - t.Fatalf("viewState.Body: ok=false with history present") - } - if want := m.frames.Frame(m.cursor.Index(), m.prefs.Diff); body != want { - t.Errorf("viewState.Body returned different body than frames.Frame") - } -} - -// TestViewStateBodyEmptyHistory: viewState.Body returns ok=false before any -// frame is recorded, so the orchestrator leaves the viewport untouched. -func TestViewStateBodyEmptyHistory(t *testing.T) { - m := New(Config{Command: "x", Interval: time.Second}) - if _, ok := (viewState{}).Body(m); ok { - t.Errorf("viewState.Body: ok=true with empty history") - } -} - -func TestViewStateShowsBar(t *testing.T) { - if got := (viewState{}).ShowsBar(); got != false { - t.Errorf("viewState.ShowsBar() = %v, want false", got) - } -} - -func TestViewStateIsFrozen(t *testing.T) { - if got := (viewState{}).IsFrozen(); got != false { - t.Errorf("viewState.IsFrozen() = %v, want false", got) - } -} - -func TestViewStateFollowsTail(t *testing.T) { - cases := []struct { - name string - wasAtTail bool - want bool - }{ - {"at tail", true, true}, - {"not at tail", false, false}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := (viewState{}).FollowsTail(c.wasAtTail); got != c.want { - t.Errorf("FollowsTail(%v) = %v, want %v", c.wasAtTail, got, c.want) - } - }) - } -} - -// TestViewStateHandlePicker pins that 'b' from viewState enters pickerState. -func TestViewStateHandlePicker(t *testing.T) { - m := makePaintModel(t) - m.state = viewState{} - _, st, _, handled := viewState{}.Handle(m, tea.KeyPressMsg{Code: 'b', Text: "b"}) - if !handled { - t.Fatalf("viewState.Handle('b') reported handled=false") - } - if _, ok := st.(pickerState); !ok { - t.Errorf("viewState.Handle('b') returned %T, want pickerState", st) - } -} - -// viewHelpBindings swaps the first slot based on whether the user is following the tail -// (b history) or viewing a past frame (Esc back). Help and Quit are always present. -func TestViewHelpBindingsSwapsFirstSlotByFollowing(t *testing.T) { - m := New(Config{Command: "x", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - m = feed(t, m, execResultMsg{exec: session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC), Stdout: "alpha\n", - }}) - m = feed(t, m, execResultMsg{exec: session.Execution{ - Timestamp: time.Date(2026, 5, 30, 12, 0, 1, 0, time.UTC), Stdout: "beta\n", - }}) - - if !m.isFollowing() { - t.Fatalf("setup: should be following") - } - bindings := viewHelpBindings(m) - if got := len(bindings); got != 3 { - t.Errorf("at tail: viewHelpBindings len = %d, want 3 (Picker, Help, Quit)", got) - } - if got := bindings[0].Help().Key; got != viewKeys.Picker.Help().Key { - t.Errorf("at tail: first binding key = %q, want %q", got, viewKeys.Picker.Help().Key) - } - - m = m.withCursor(0) // view first frame - if m.isFollowing() { - t.Fatalf("setup: should be viewing past") - } - bindings = viewHelpBindings(m) - if got := len(bindings); got != 3 { - t.Errorf("past frame: viewHelpBindings len = %d, want 3 (Esc, Help, Quit)", got) - } - if got := bindings[0].Help().Key; got != commonKeys.Escape.Help().Key { - t.Errorf("past frame: first binding key = %q, want %q", got, commonKeys.Escape.Help().Key) - } -} diff --git a/internal/tui/styles.go b/internal/tui/styles.go deleted file mode 100644 index 3a4ab9b..0000000 --- a/internal/tui/styles.go +++ /dev/null @@ -1,86 +0,0 @@ -package tui - -import ( - "charm.land/lipgloss/v2" - "charm.land/lipgloss/v2/compat" - "github.com/charmbracelet/x/ansi" -) - -// insertFg is the foreground the diff renderer applies to changed cells. -var insertFg ansi.Color = ansi.Green - -// Layout constants -const ( - itemSpacing = 2 // visual gap between picker timestamps (matches " ") - arrowWidth = 2 // width of "◀ " or " ▶" navigation arrows - timestampFmt = "15:04:05" // HH:MM:SS format for history timestamps -) - -var ( - // barBg / barFg are the only AdaptiveColor pair the bar surface needs. Plates use these; - // accents on top use named ANSI so the terminal's palette resolves them per theme. - barBg = compat.AdaptiveColor{ - Light: lipgloss.Color("#e5e5e5"), - Dark: lipgloss.Color("#262626"), - } - barFg = compat.AdaptiveColor{ - Light: lipgloss.Color("#1f1f1f"), - Dark: lipgloss.Color("#d6d6d6"), - } - - statusBarStyle = lipgloss.NewStyle(). - Background(barBg). - Foreground(barFg). - Padding(0, 1) - - // barInnerStyle is the fg/bg every inline bar element carries so its own SGR Reset - // doesn't tear the plate. Each element re-opens this style. - barInnerStyle = lipgloss.NewStyle(). - Background(barBg). - Foreground(barFg) - - helpStyle = lipgloss.NewStyle(). - PaddingLeft(1). - Background(barBg). - Foreground(barFg) - - // helpColumnGap separates the two columns inside the help overlay panel. - helpColumnGap = lipgloss.NewStyle().Width(4).Render("") - - helpPanelStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - Padding(1, 2) - - // indicatorStyle must render exactly 1 visible cell — centerBlockWidth assumes 1-cell - // slots with gaps emitted explicitly by renderCenterBlock. Padding here would push the - // bar past contentWidth and wrap the help text onto a second row. - indicatorStyle = lipgloss.NewStyle(). - Background(barBg). - Foreground(barFg) - - errorStyle = lipgloss.NewStyle(). - Foreground(ansi.Red) - - // recStyle: same 1-cell invariant as indicatorStyle. Red dot on the bar's own plate, not - // a red slab — the glyph itself signals recording, the surrounding bg stays consistent. - recStyle = lipgloss.NewStyle(). - Background(barBg). - Foreground(ansi.Red) - - pickerItemStyle = lipgloss.NewStyle(). - Background(barBg). - Foreground(barFg) - - // pickerSelectedStyle: yellow plate, black text — distinguishes the active timestamp - // from the surrounding bar plate. - pickerSelectedStyle = lipgloss.NewStyle(). - Background(ansi.Yellow). - Foreground(ansi.Black) -) - -// boldKeep wraps s in a bold span that terminates with SGR 22 (bold-off) instead of the -// full reset that lipgloss.Render emits. Use inside a styled outer span (e.g. helpStyle) -// where a full reset would erase the outer fg/bg for the tail of the joined string. -func boldKeep(s string) string { - return "\x1b[1m" + s + "\x1b[22m" -} diff --git a/internal/tui/testhelpers_test.go b/internal/tui/testhelpers_test.go deleted file mode 100644 index 7cba511..0000000 --- a/internal/tui/testhelpers_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package tui - -// Helpers shared by every *_test.go in this package. Lives without a paired source -// because it's explicitly helper-only — no Test* functions, just setup code that -// would otherwise be duplicated across multiple test files. - -import ( - "fmt" - "strings" - "testing" - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/ivoronin/wch/internal/session" -) - -// podTable renders a kubectl-like table: a stable header plus one row per name, each row -// carrying the given (volatile) age. -func podTable(age string, names []string) string { - rows := []string{"NAME READY STATUS AGE"} - for _, n := range names { - rows = append(rows, n+" 1/1 Running "+age) - } - return strings.Join(rows, "\n") -} - -func podNames(n int) []string { - out := make([]string, n) - for i := range out { - out[i] = fmt.Sprintf("pod-%02d", i+1) - } - return out -} - -func feed(t *testing.T, m Model, msg tea.Msg) Model { - t.Helper() - next, _ := m.Update(msg) - return next.(Model) -} - -// pressKey simulates a single key press through the full dispatcher (Update path). -func pressKey(t *testing.T, m Model, code rune) Model { - t.Helper() - return feed(t, m, tea.KeyPressMsg{Code: code, Text: string(code)}) -} - -// submitInputValue sets the textinput's value directly on m.state (which must be inputState) -// and presses Enter so the configured submit function runs. Bypasses character-by-character -// typing, which would otherwise dominate the test setup. -func submitInputValue(t *testing.T, m Model, value string) Model { - t.Helper() - s, ok := m.state.(inputState) - if !ok { - t.Fatalf("submitInputValue: m.state = %T, want inputState", m.state) - } - s.input.SetValue(value) - m.state = s - return feed(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) -} - -func newSizedModel(t *testing.T, initial string) Model { - t.Helper() - m := New(Config{Command: "x", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - return feed(t, m, execResultMsg{exec: session.Execution{Stdout: initial}}) -} - -// makePaintModel returns a model sized 40x10 with a single recorded execution. -// All Body equivalence tests share the same setup so that the "what should the -// viewport look like?" baseline is identical across cases. -func makePaintModel(t *testing.T) Model { - t.Helper() - m := New(Config{Command: "x", Interval: time.Second}) - m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10}) - m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("5m", podNames(20))}}) - return m -} diff --git a/internal/tui/view.go b/internal/tui/view.go deleted file mode 100644 index 91570ab..0000000 --- a/internal/tui/view.go +++ /dev/null @@ -1,53 +0,0 @@ -package tui - -import ( - tea "charm.land/bubbletea/v2" - - "github.com/ivoronin/wch/internal/tui/helprender" - "github.com/ivoronin/wch/internal/tui/notify" -) - -// View renders the UI. Layer order: viewport → bar → help overlay (when toggled) → notify -// bubbles. Notify draws last so a transient bubble still surfaces over the help panel. -func (m Model) View() tea.View { - var content string - if !m.ready { - content = "Initializing..." - } else { - content = m.frames.View() - if m.barShown() { - if bar := m.state.RenderBar(m); bar != "" { - content += "\n" + bar - } - } - if m.prefs.HelpVisible { - content = helprender.Overlay(content, renderHelpPanel(), m.width, m.height) - } - if m.notify.Active() { - // Adaptive insets: snug to the bottom-right corner of the *available* content - // area. We add an inset only when there's something to avoid overlaying — the - // vertical scrollbar on the right, the status bar and/or horizontal scrollbar - // at the bottom. No fixed gaps. - var insets notify.Insets - if m.frames.NeedsVerticalScrollbar() { - insets.Right = 1 - } - if m.barShown() { - insets.Bottom++ - } - if m.frames.NeedsHorizontalScrollbar() { - insets.Bottom++ - } - content = m.notify.Overlay(content, m.width, m.height, insets) - } - } - - v := tea.NewView(content) - v.AltScreen = true - if m.isLive() { - v.WindowTitle = "wch: " + m.session.Command - } else { - v.WindowTitle = "wch (replay): " + m.session.Command - } - return v -} diff --git a/src/args.zig b/src/args.zig new file mode 100644 index 0000000..57d39b4 --- /dev/null +++ b/src/args.zig @@ -0,0 +1,71 @@ +//! Parses watch options and preserves the remaining command arguments. + +const std = @import("std"); +const clap = @import("clap"); +const build_metadata = @import("build_metadata"); + +const option_parameters = clap.parseParamsComptime( + \\-h, --help Display this help and exit. + \\-v, --version Display version and exit. + \\-i, --interval Seconds between runs. One by default. + \\-l, --limit How many runs to keep. 3600 by default. + \\ +); + +const usage_text = "usage: wch [options] command [args...]\n\noptions:\n"; + +/// Validated command-line input. +pub const WatchOptions = struct { + run_interval: std.Io.Duration, + history_limit: usize, + /// Command arguments retain their original shell boundaries. + command_arguments: []const []const u8, +}; + +/// Parse arena-backed options, printing help or errors before exiting when needed. +pub fn parse( + arena: std.mem.Allocator, + io: std.Io, + process_arguments: std.process.Args, +) !WatchOptions { + const raw_arguments = try process_arguments.toSlice(arena); + const user_arguments = try arena.alloc([]const u8, raw_arguments.len - 1); + for (raw_arguments[1..], user_arguments) |raw_argument, *user_argument| + user_argument.* = raw_argument; + + var diagnostic: clap.Diagnostic = .{}; + var argument_parser: clap.args.SliceIterator = .{ .args = user_arguments }; + const parsed_arguments = clap.parseEx( + clap.Help, + &option_parameters, + clap.parsers.default, + &argument_parser, + .{ + .diagnostic = &diagnostic, + .allocator = arena, + // Leave every argument after the command to the command itself. + .terminating_positional = 0, + }, + ) catch |parse_error| { + // The diagnostic is complete; returning the error would add a useless stack trace. + try diagnostic.reportToFile(io, .stderr(), parse_error); + std.process.exit(1); + }; + if (parsed_arguments.args.version != 0) { + const version_text = try std.fmt.allocPrint(arena, "wch {s}\n", .{build_metadata.version}); + try std.Io.File.stdout().writeStreamingAll(io, version_text); + std.process.exit(0); + } + if (parsed_arguments.args.help != 0 or parsed_arguments.positionals[0] == null) { + try std.Io.File.stdout().writeStreamingAll(io, usage_text); + try clap.helpToFile(io, .stdout(), clap.Help, &option_parameters, .{}); + std.process.exit(0); + } + + return .{ + .run_interval = .fromSeconds(@max(parsed_arguments.args.interval orelse 1, 1)), + .history_limit = parsed_arguments.args.limit orelse 3600, + // parseEx stops after consuming the command name. + .command_arguments = user_arguments[argument_parser.index - 1 ..], + }; +} diff --git a/src/bar.zig b/src/bar.zig new file mode 100644 index 0000000..e2043ac --- /dev/null +++ b/src/bar.zig @@ -0,0 +1,186 @@ +//! Draws live or history status in the bottom row. + +const std = @import("std"); +const vaxis = @import("vaxis"); +const zeit = @import("zeit"); + +const clock_width = 8; +const edge_padding: u16 = 1; +const history_spacing: u16 = 2; + +const bar_style: vaxis.Style = .{ .bg = .{ .index = 8 }, .fg = .{ .index = 15 } }; +const shortcut_style: vaxis.Style = .{ .bg = bar_style.bg, .fg = bar_style.fg, .bold = true }; + +const live_help: []const vaxis.Segment = &.{ + .{ .text = "b", .style = shortcut_style }, + .{ .text = " hist", .style = bar_style }, + .{ .text = " · ", .style = bar_style }, + .{ .text = "q", .style = shortcut_style }, + .{ .text = " quit", .style = bar_style }, +}; + +const history_help: []const vaxis.Segment = &.{ + .{ .text = "j", .style = shortcut_style }, + .{ .text = " prev", .style = bar_style }, + .{ .text = " · ", .style = bar_style }, + .{ .text = "k", .style = shortcut_style }, + .{ .text = " next", .style = bar_style }, + .{ .text = " · ", .style = bar_style }, + .{ .text = "b", .style = shortcut_style }, + .{ .text = " live", .style = bar_style }, + .{ .text = " · ", .style = bar_style }, + .{ .text = "q", .style = shortcut_style }, + .{ .text = " quit", .style = bar_style }, +}; + +const StatusIndicator = enum { + idle, + running, + history, + + /// Return the glyph for this state. + fn glyph(self: StatusIndicator) []const u8 { + return switch (self) { + .idle => "·", + .running => "*", + .history => "←", + }; + } +}; + +pub const StatusBar = struct { + pub const height = 1; + + /// Borrowed until the status bar is released. + command_label: []const u8, + timezone: zeit.TimeZone, + // Vaxis cells borrow formatted text until the frame is rendered. + clock_buffer: [clock_width]u8 = undefined, + run_position_buffer: [64]u8 = undefined, + + /// Create a status bar using local time when available. + pub fn init( + allocator: std.mem.Allocator, + io: std.Io, + command_label: []const u8, + ) StatusBar { + return .{ + .command_label = command_label, + .timezone = zeit.local(allocator, io, .{}) catch zeit.utc, + }; + } + + /// Release timezone storage. + pub fn deinit(self: *StatusBar) void { + self.timezone.deinit(); + self.* = undefined; + } + + /// Draw the command, latest time, activity, and live-mode help. + pub fn drawLive( + self: *StatusBar, + window: vaxis.Window, + last_run_at: ?std.Io.Timestamp, + run_in_progress: bool, + ) void { + window.fill(.{ .style = bar_style }); + const help_column = window.width -| + window.print(live_help, .{ .wrap = .none, .commit = false }).col -| + edge_padding; + _ = window.print(live_help, .{ .col_offset = help_column, .wrap = .none }); + + const clock_text = if (last_run_at) |completed_at| + formatClockTime(self, completed_at) + else + ""; + const clock_column = clockColumn(window.width); + const indicator_column = clock_column +| clock_width +| 1; + const indicator_text = (if (run_in_progress) StatusIndicator.running else StatusIndicator.idle).glyph(); + + const middle_status_visible = indicator_column +| window.gwidth(indicator_text) <= help_column; + if (middle_status_visible) { + _ = window.printSegment( + .{ .text = clock_text, .style = bar_style }, + .{ .col_offset = clock_column, .wrap = .none }, + ); + _ = window.printSegment( + .{ .text = indicator_text, .style = bar_style }, + .{ .col_offset = indicator_column, .wrap = .none }, + ); + } + + const command_end_column = if (middle_status_visible) clock_column else help_column; + self.drawCommand(window, command_end_column); + } + + /// Draw history position, time, and navigation help. + pub fn drawHistory( + self: *StatusBar, + window: vaxis.Window, + run_number: usize, + run_count: usize, + completed_at: std.Io.Timestamp, + ) void { + window.fill(.{ .style = bar_style }); + const clock_column = clockColumn(window.width); + _ = window.printSegment( + .{ .text = formatClockTime(self, completed_at), .style = bar_style }, + .{ .col_offset = clock_column, .wrap = .none }, + ); + _ = window.printSegment( + .{ .text = StatusIndicator.history.glyph(), .style = bar_style }, + .{ .col_offset = clock_column +| clock_width +| 1, .wrap = .none }, + ); + + const run_position_text = std.fmt.bufPrint( + &self.run_position_buffer, + "Run {d}/{d}", + .{ run_number, run_count }, + ) catch unreachable; + if (edge_padding +| window.gwidth(run_position_text) +| history_spacing <= clock_column) + _ = window.printSegment( + .{ .text = run_position_text, .style = bar_style }, + .{ .col_offset = edge_padding, .wrap = .none }, + ); + + const help_column = window.width -| + window.print(history_help, .{ .wrap = .none, .commit = false }).col -| + edge_padding; + if (clock_column +| clock_width +| history_spacing <= help_column) + _ = window.print(history_help, .{ .col_offset = help_column, .wrap = .none }); + } + + /// Draw the command before an end column and mark truncation. + fn drawCommand(self: *const StatusBar, window: vaxis.Window, end_column: u16) void { + const command_window = window.child(.{ + .x_off = edge_padding, + .width = end_column -| edge_padding -| edge_padding, + }); + if (command_window.printSegment( + .{ .text = self.command_label, .style = bar_style }, + .{ .wrap = .none }, + ).overflow) + command_window.writeCell(command_window.width -| 1, 0, .{ + .char = .{ .grapheme = "…", .width = 1 }, + .style = bar_style, + }); + } +}; + +/// Format a timestamp as local `HH:MM:SS` in the status bar buffer. +fn formatClockTime( + self: *StatusBar, + timestamp: std.Io.Timestamp, +) []const u8 { + const local_time = zeit.instant(.{ .unix_nano = timestamp.nanoseconds }, &self.timezone).time(); + return std.fmt.bufPrint( + &self.clock_buffer, + "{d:0>2}:{d:0>2}:{d:0>2}", + .{ local_time.hour, local_time.minute, local_time.second }, + ) catch unreachable; +} + +/// Center the fixed-width clock. +fn clockColumn(bar_width: u16) u16 { + return (bar_width -| clock_width) / 2; +} diff --git a/src/diff.zig b/src/diff.zig new file mode 100644 index 0000000..567819b --- /dev/null +++ b/src/diff.zig @@ -0,0 +1,315 @@ +//! Computes terminal-independent line and word changes with Dizzy. + +const std = @import("std"); +const dizzy = @import("dizzy"); + +const word_delimiters = delimiters: { + var delimiter_set = std.StaticBitSet(256).initEmpty(); + for (" \t\r\n.,:;/|()[]") |delimiter| delimiter_set.set(delimiter); + break :delimiters delimiter_set; +}; + +/// Part lists and their arena-backed Dizzy edits. +/// Delete ranges index `before`, insert ranges index `after`, and equal ranges pair both. +/// The slices borrow input text and live in the caller's arena. +pub const Changes = struct { + before: []const []const u8, + after: []const []const u8, + edits: []const dizzy.Edit, + + /// Map a before line to its after position or deletion point. + pub fn mapBeforeLine(self: Changes, before_line: u32) u32 { + var after_line_index: u32 = 0; + + for (self.edits) |edit| { + switch (edit.kind) { + .insert => after_line_index = edit.range.end, + .delete => if (before_line < edit.range.end) return after_line_index, + .equal => { + if (before_line < edit.range.end) + return after_line_index + before_line - edit.range.start; + after_line_index += edit.range.end - edit.range.start; + }, + } + } + + return after_line_index; + } +}; + +/// Diff visible lines, treating the first output as unchanged. +pub fn compareLines( + arena: std.mem.Allocator, + before_output: ?[]const u8, + after_output: []const u8, +) !Changes { + const after_lines = try splitLines(arena, after_output); + const previous_output = before_output orelse return unchangedLines(arena, after_lines); + if (std.mem.eql(u8, previous_output, after_output)) + return unchangedLines(arena, after_lines); + const before_lines = try splitLines(arena, previous_output); + + const before_columns_by_line = try splitColumns(arena, before_lines); + const after_columns_by_line = try splitColumns(arena, after_lines); + const stable_column = try chooseStableColumn( + arena, + before_columns_by_line, + after_columns_by_line, + ); + const before_keys = try selectKeys( + arena, + before_lines, + before_columns_by_line, + stable_column, + ); + const after_keys = try selectKeys( + arena, + after_lines, + after_columns_by_line, + stable_column, + ); + + return compareParts(arena, before_lines, after_lines, before_keys, after_keys); +} + +/// Diff words while keeping delimiter runs as parts. +pub fn compareWords( + arena: std.mem.Allocator, + before_line: []const u8, + after_line: []const u8, +) !Changes { + const before_parts = try splitWords(arena, before_line); + const after_parts = try splitWords(arena, after_line); + return compareParts(arena, before_parts, after_parts, before_parts, after_parts); +} + +/// Represent the first output as unchanged lines. +fn unchangedLines(arena: std.mem.Allocator, lines: []const []const u8) !Changes { + const edits = if (lines.len == 0) + &.{} + else + try arena.dupe(dizzy.Edit, &.{.{ + .kind = .equal, + .range = .{ .start = 0, .end = @intCast(lines.len) }, + }}); + return .{ .before = lines, .after = lines, .edits = edits }; +} + +/// Split LF-delimited text and omit its final empty line and trailing carriage returns. +fn splitLines(arena: std.mem.Allocator, output: []const u8) ![]const []const u8 { + if (output.len == 0) return &.{}; + + var lines: std.ArrayList([]const u8) = .empty; + const text = if (output[output.len - 1] == '\n') output[0 .. output.len - 1] else output; + var line_iterator = std.mem.splitScalar(u8, text, '\n'); + while (line_iterator.next()) |line| + try lines.append(arena, std.mem.trimEnd(u8, line, "\r")); + + return lines.items; +} + +/// Split one line into alternating delimiter and non-delimiter runs. +fn splitWords(arena: std.mem.Allocator, line: []const u8) ![]const []const u8 { + var parts: std.ArrayList([]const u8) = .empty; + + var part_start: usize = 0; + for (line, 0..) |byte, byte_index| { + if (word_delimiters.isSet(byte) == word_delimiters.isSet(line[part_start])) continue; + try parts.append(arena, line[part_start..byte_index]); + part_start = byte_index; + } + if (part_start < line.len) try parts.append(arena, line[part_start..]); + + return parts.items; +} + +/// Split each line into whitespace-delimited columns. +fn splitColumns( + arena: std.mem.Allocator, + lines: []const []const u8, +) ![]const []const []const u8 { + var columns_by_line: std.ArrayList([]const []const u8) = .empty; + + for (lines) |line| { + var line_columns: std.ArrayList([]const u8) = .empty; + var column_iterator = std.mem.tokenizeAny(u8, line, " \t\r\n"); + while (column_iterator.next()) |column| try line_columns.append(arena, column); + try columns_by_line.append(arena, line_columns.items); + } + + return columns_by_line.items; +} + +const KeyOccurrences = struct { + before_count: usize = 0, + after_count: usize = 0, +}; + +/// Pick the column with the most values unique on both sides. +fn chooseStableColumn( + arena: std.mem.Allocator, + before_columns_by_line: []const []const []const u8, + after_columns_by_line: []const []const []const u8, +) !?usize { + var maximum_column_count: usize = 0; + for (before_columns_by_line) |line_columns| + maximum_column_count = @max(maximum_column_count, line_columns.len); + for (after_columns_by_line) |line_columns| + maximum_column_count = @max(maximum_column_count, line_columns.len); + + var occurrences_by_key = std.StringHashMap(KeyOccurrences).init(arena); + defer occurrences_by_key.deinit(); + + const maximum_unique_key_count = @min( + before_columns_by_line.len, + after_columns_by_line.len, + ); + var best_column: ?usize = null; + var best_unique_key_count: usize = 0; + + for (0..maximum_column_count) |column_index| { + occurrences_by_key.clearRetainingCapacity(); + + for (before_columns_by_line) |line_columns| { + if (column_index >= line_columns.len) continue; + const occurrence_entry = try occurrences_by_key.getOrPut(line_columns[column_index]); + if (!occurrence_entry.found_existing) occurrence_entry.value_ptr.* = .{}; + occurrence_entry.value_ptr.before_count += 1; + } + + for (after_columns_by_line) |line_columns| { + if (column_index >= line_columns.len) continue; + const occurrence_entry = try occurrences_by_key.getOrPut(line_columns[column_index]); + if (!occurrence_entry.found_existing) occurrence_entry.value_ptr.* = .{}; + occurrence_entry.value_ptr.after_count += 1; + } + + var unique_key_count: usize = 0; + var occurrence_iterator = occurrences_by_key.valueIterator(); + while (occurrence_iterator.next()) |occurrences| { + if (occurrences.before_count == 1 and occurrences.after_count == 1) + unique_key_count += 1; + } + + if (unique_key_count <= best_unique_key_count) continue; + + best_unique_key_count = unique_key_count; + best_column = column_index; + if (unique_key_count == maximum_unique_key_count) return column_index; + } + + return best_column; +} + +/// Read keys from one column, falling back to the complete line. +fn selectKeys( + arena: std.mem.Allocator, + lines: []const []const u8, + columns_by_line: []const []const []const u8, + stable_column: ?usize, +) ![]const []const u8 { + const column_index = stable_column orelse return lines; + + const comparison_keys = try arena.alloc([]const u8, lines.len); + for (lines, columns_by_line, comparison_keys) |line, line_columns, *comparison_key| + comparison_key.* = if (column_index < line_columns.len) + line_columns[column_index] + else + line; + + return comparison_keys; +} + +const PartDiffer = dizzy.SliceDiffer([]const u8, std.hash_map.StringContext); + +/// Run Dizzy on comparison keys and return edits over the original parts. +fn compareParts( + arena: std.mem.Allocator, + before_parts: []const []const u8, + after_parts: []const []const u8, + before_keys: []const []const u8, + after_keys: []const []const u8, +) !Changes { + std.debug.assert(before_parts.len == before_keys.len); + std.debug.assert(after_parts.len == after_keys.len); + + const scratch = try arena.alloc(u32, 4 * (before_parts.len + after_parts.len) + 2); + var edits: std.ArrayList(dizzy.Edit) = .empty; + try PartDiffer.diff(arena, &edits, before_keys, after_keys, scratch); + + // Keep deletions before insertions so replacements read old then new. + for (0..edits.items.len -| 1) |edit_index| + if (edits.items[edit_index].kind == .insert and + edits.items[edit_index + 1].kind == .delete) + std.mem.swap( + dizzy.Edit, + &edits.items[edit_index], + &edits.items[edit_index + 1], + ); + + return .{ .before = before_parts, .after = after_parts, .edits = edits.items }; +} + +test "pairs changed rows by their stable key" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const line_changes = try compareLines( + arena.allocator(), + "api-1 Running 0 5m\r\napi-2 Running 0 5m\r\n", + "api-1 Running 0 6m\napi-2 Pending 0 6m", + ); + try std.testing.expectEqualDeep(&[_]dizzy.Edit{.{ + .kind = .equal, + .range = .{ .start = 0, .end = 2 }, + }}, line_changes.edits); +} + +test "leaves unrelated rows unpaired" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const line_changes = try compareLines( + arena.allocator(), + "api-1 Running 0 5m\nweb-1 Running 0 5m", + "api-1 Running 0 6m\ndb-9 Pending 3 1s", + ); + try std.testing.expectEqualDeep(&[_]dizzy.Edit{ + .{ .kind = .equal, .range = .{ .start = 0, .end = 1 } }, + .{ .kind = .delete, .range = .{ .start = 1, .end = 2 } }, + .{ .kind = .insert, .range = .{ .start = 1, .end = 2 } }, + }, line_changes.edits); +} + +test "falls back to whole lines without a key column" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var deleted_lines: usize = 0; + var inserted_lines: usize = 0; + const line_changes = try compareLines( + arena.allocator(), + "group a\ngroup b", + "group c\ngroup d", + ); + for (line_changes.edits) |edit| switch (edit.kind) { + .delete => deleted_lines += edit.range.end - edit.range.start, + .insert => inserted_lines += edit.range.end - edit.range.start, + else => return error.UnexpectedEdit, + }; + + try std.testing.expectEqual(@as(usize, 2), deleted_lines); + try std.testing.expectEqual(@as(usize, 2), inserted_lines); +} + +test "diffs words and keeps delimiter runs" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const word_changes = try compareWords(arena.allocator(), "cpu: 12", "cpu: 13"); + try std.testing.expectEqualDeep(&[_]dizzy.Edit{ + .{ .kind = .equal, .range = .{ .start = 0, .end = 2 } }, + .{ .kind = .delete, .range = .{ .start = 2, .end = 3 } }, + .{ .kind = .insert, .range = .{ .start = 2, .end = 3 } }, + }, word_changes.edits); +} diff --git a/src/history.zig b/src/history.zig new file mode 100644 index 0000000..227b83a --- /dev/null +++ b/src/history.zig @@ -0,0 +1,155 @@ +//! Stores recent distinct runs and resolves stable cursors into display views. + +const std = @import("std"); +const Run = @import("run.zig").Run; + +pub const History = struct { + pub const Cursor = usize; + + /// Borrowed until History changes. + pub const View = struct { + cursor: Cursor, + run: *const Run, + previous_output: ?[]const u8, + run_number: usize, + run_count: usize, + }; + + allocator: std.mem.Allocator, + runs: std.ArrayList(Run) = .empty, + run_limit: usize, + oldest_cursor: Cursor = 0, + + /// Create an empty history that retains at least one run. + pub fn init(allocator: std.mem.Allocator, run_limit: usize) History { + return .{ .allocator = allocator, .run_limit = @max(run_limit, 1) }; + } + + /// Free every retained run and the run list. + pub fn deinit(self: *History) void { + for (self.runs.items) |*run| run.deinit(self.allocator); + self.runs.deinit(self.allocator); + self.* = undefined; + } + + /// Take ownership of a run and retain it when storage permits. + pub fn append(self: *History, run: Run) void { + var owned_run = run; + if (self.runs.getLastOrNull()) |newest_run| { + if (std.mem.eql(u8, owned_run.output, newest_run.output)) { + owned_run.deinit(self.allocator); + return; + } + } + + self.runs.append(self.allocator, owned_run) catch { + owned_run.deinit(self.allocator); + return; + }; + owned_run = undefined; + if (self.runs.items.len > self.run_limit) { + var dropped_run = self.runs.orderedRemove(0); + dropped_run.deinit(self.allocator); + self.oldest_cursor += 1; + } + } + + /// Resolve null to the newest run and an expired cursor to the oldest. + pub fn resolve(self: *const History, cursor: ?Cursor) ?View { + const retained_runs = self.runs.items; + if (retained_runs.len == 0) return null; + + const selected_run_index = if (cursor) |selected_cursor| + self.runIndex(selected_cursor) + else + retained_runs.len - 1; + const selected_run = &retained_runs[selected_run_index]; + return .{ + .cursor = self.oldest_cursor + selected_run_index, + .run = selected_run, + .previous_output = if (selected_run_index == 0) + null + else + retained_runs[selected_run_index - 1].output, + .run_number = selected_run_index + 1, + .run_count = retained_runs.len, + }; + } + + /// Return the previous cursor, clamped to the oldest retained run. + pub fn previous(self: *const History, cursor: Cursor) Cursor { + std.debug.assert(self.runs.items.len > 0); + + const current_run_index = self.runIndex(cursor); + return self.oldest_cursor + (current_run_index -| 1); + } + + /// Return the next cursor, clamped to the newest retained run. + pub fn next(self: *const History, cursor: Cursor) Cursor { + std.debug.assert(self.runs.items.len > 0); + + const current_run_index = self.runIndex(cursor); + const next_run_index = @min(current_run_index + 1, self.runs.items.len - 1); + return self.oldest_cursor + next_run_index; + } + + /// Resolve an unretained cursor to the oldest run index. + fn runIndex(self: *const History, cursor: Cursor) usize { + if (cursor < self.oldest_cursor) return 0; + const run_index = cursor - self.oldest_cursor; + return if (run_index < self.runs.items.len) run_index else 0; + } +}; + +/// Create a test run with owned output. +fn createTestRun(allocator: std.mem.Allocator, output: []const u8) !Run { + return .{ .completed_at = .zero, .output = try allocator.dupe(u8, output) }; +} + +test "history resolves and moves through retained runs" { + const allocator = std.testing.allocator; + var history: History = .init(allocator, 2); + defer history.deinit(); + + history.append(try createTestRun(allocator, "a")); + const expired_cursor = history.resolve(null).?.cursor; + for ([_][]const u8{ "a", "b", "c", "d" }) |run_output| + history.append(try createTestRun(allocator, run_output)); + + const oldest_run_view = history.resolve(expired_cursor).?; + const newest_run_view = history.resolve(null).?; + + try std.testing.expectEqualStrings("c", oldest_run_view.run.output); + try std.testing.expect(oldest_run_view.previous_output == null); + try std.testing.expectEqual(1, oldest_run_view.run_number); + try std.testing.expectEqual(2, oldest_run_view.run_count); + try std.testing.expectEqualStrings("d", newest_run_view.run.output); + try std.testing.expectEqualStrings("c", newest_run_view.previous_output.?); + try std.testing.expectEqual(2, newest_run_view.run_number); + try std.testing.expectEqualSlices( + History.Cursor, + &.{ + oldest_run_view.cursor, + newest_run_view.cursor, + oldest_run_view.cursor, + newest_run_view.cursor, + }, + &.{ + history.previous(oldest_run_view.cursor), + history.next(oldest_run_view.cursor), + history.previous(newest_run_view.cursor), + history.next(newest_run_view.cursor), + }, + ); +} + +test "a run that cannot be stored is dropped" { + var failing_allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 1 }); + const allocator = failing_allocator.allocator(); + + var history: History = .init(allocator, std.math.maxInt(usize)); + defer history.deinit(); + + history.append(try createTestRun(allocator, "run")); + try std.testing.expect(history.resolve(null) == null); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..a3a2f66 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,127 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); +const cli = @import("args.zig"); +const Model = @import("model.zig").Model; +const Run = @import("run.zig").Run; + +/// Events from Vaxis and the command watcher. +const Event = union(enum) { + key_press: vaxis.Key, + mouse: vaxis.Mouse, + winsize: vaxis.Winsize, + run_started, + run_finished: Run, +}; + +const EventLoop = vaxis.Loop(Event); + +/// Run the command on schedule and post lifecycle events until cancellation. +fn watchCommand( + allocator: std.mem.Allocator, + event_loop: *EventLoop, + command_arguments: []const []const u8, + run_interval: std.Io.Duration, +) void { + while (true) { + event_loop.postEvent(.run_started) catch return; + + // Cancellation may surface from capture before sleep sees it. + if (Run.capture(allocator, event_loop.io, command_arguments)) |captured_run| { + var run = captured_run; + event_loop.postEvent(.{ .run_finished = run }) catch { + run.deinit(allocator); + return; + }; + } else |capture_error| if (capture_error == error.Canceled) return; + + event_loop.io.sleep(run_interval, .awake) catch return; + } +} + +/// Run the terminal event loop until the user quits or an operation fails. +pub fn main(process: std.process.Init) !void { + const io = process.io; + const allocator = process.gpa; + + var terminal_buffer: [1024]u8 = undefined; + + // Parse before entering alternate screen so help and errors remain visible. + const watch_options = try cli.parse(process.arena.allocator(), io, process.minimal.args); + + // Reverse defer order stops the watcher before terminal teardown. + var terminal: vaxis.Tty = try .init(io, &terminal_buffer); + defer terminal.deinit(); + + var tui: vaxis.Vaxis = try .init(io, allocator, process.environ_map, .{}); + defer tui.deinit(allocator, terminal.writer()); + + var event_loop: EventLoop = .init(io, &terminal, &tui); + try event_loop.start(); + defer event_loop.stop(); + + try tui.enterAltScreen(terminal.writer()); + try tui.queryTerminal(terminal.writer(), .fromSeconds(1)); + + // Avoid a lock-taking signal handler when in-band resize works. + if (!tui.state.in_band_resize) try event_loop.installResizeHandler(); + defer event_loop.uninstallResizeHandler(); + + // Mouse mode supplies wheel events; pixel coordinates are unused. + tui.caps.sgr_pixels = false; + try tui.setMouseMode(terminal.writer(), true); + + // The status bar needs a display string; execution keeps argument boundaries. + const command_label = try std.mem.join( + process.arena.allocator(), + " ", + watch_options.command_arguments, + ); + + var model: Model = .init(allocator, io, watch_options.history_limit, command_label); + defer model.deinit(); + + // Cancel first to wake a blocked producer, then free runs still in the queue. + var command_watcher = try io.concurrent( + watchCommand, + .{ + allocator, + &event_loop, + watch_options.command_arguments, + watch_options.run_interval, + }, + ); + defer { + command_watcher.cancel(io); + while (event_loop.tryEvent() catch null) |event| switch (event) { + .run_finished => |queued_run| { + var run = queued_run; + run.deinit(allocator); + }, + else => {}, + }; + } + + while (true) { + // Draw one frame per queued burst, not per input event. + try event_loop.pollEvent(); + while (try event_loop.tryEvent()) |event| switch (event) { + .key_press => |key| { + if (key.matches('q', .{})) return; + model.handleKeyPress(key, tui.window()); + }, + .mouse => |mouse| model.scrollWithMouse(mouse), + .winsize => |window_size| try tui.resize(allocator, terminal.writer(), window_size), + .run_started => model.run_in_progress = true, + .run_finished => |run| model.finishRun(run), + }; + + const window = tui.window(); + window.clear(); + try model.drawFrame(window); + try tui.render(terminal.writer()); + } +} + +test { + std.testing.refAllDecls(@This()); +} diff --git a/src/model.zig b/src/model.zig new file mode 100644 index 0000000..03ed6b8 --- /dev/null +++ b/src/model.zig @@ -0,0 +1,152 @@ +//! Owns run selection and draws complete screen frames. + +const std = @import("std"); +const vaxis = @import("vaxis"); +const History = @import("history.zig").History; +const Output = @import("output.zig").Output; +const Viewport = @import("viewport.zig").Viewport; +const Run = @import("run.zig").Run; +const StatusBar = @import("bar.zig").StatusBar; + +/// Return the screen area above the one-line status bar. +fn viewportWindow(window: vaxis.Window) vaxis.Window { + return window.child(.{ + .width = window.width, + .height = window.height -| StatusBar.height, + }); +} + +pub const Model = struct { + history: History, + output: Output, + viewport: Viewport, + status_bar: StatusBar, + + /// Null follows the newest run. + selected_cursor: ?History.Cursor = null, + /// Null before the first run is shown. + displayed_cursor: ?History.Cursor = null, + run_in_progress: bool = false, + + /// Create an empty model and its owned modules. + pub fn init( + allocator: std.mem.Allocator, + io: std.Io, + history_limit: usize, + command_label: []const u8, + ) Model { + return .{ + .history = .init(allocator, history_limit), + .output = .init(allocator), + .viewport = .{}, + .status_bar = .init(allocator, io, command_label), + }; + } + + /// Release model-owned storage. + pub fn deinit(self: *Model) void { + self.history.deinit(); + self.output.deinit(); + self.status_bar.deinit(); + self.* = undefined; + } + + /// Record a completed run and leave the model idle. + pub fn finishRun(self: *Model, run: Run) void { + self.run_in_progress = false; + self.history.append(run); + } + + /// Apply model keys, then pass unclaimed input to the viewport. + pub fn handleKeyPress(self: *Model, key: vaxis.Key, window: vaxis.Window) void { + if (key.matches('b', .{})) { + self.selected_cursor = if (self.selected_cursor == null) + self.displayed_cursor + else + null; + return; + } + + if (self.selected_cursor) |selected_cursor| { + if (key.matches(vaxis.Key.escape, .{})) { + self.selected_cursor = null; + return; + } + + if (key.matches('j', .{})) { + self.selected_cursor = self.history.previous(selected_cursor); + return; + } + if (key.matches('k', .{})) { + self.selected_cursor = self.history.next(selected_cursor); + return; + } + } + + self.viewport.scrollWithKey(key, viewportWindow(window)); + } + + /// Forward mouse input to the viewport. + pub fn scrollWithMouse(self: *Model, mouse: vaxis.Mouse) void { + self.viewport.scrollWithMouse(mouse); + } + + /// Resolve selection, refresh output, and draw the viewport and status bar. + pub fn drawFrame(self: *Model, window: vaxis.Window) std.mem.Allocator.Error!void { + const viewport_window = viewportWindow(window); + const status_bar_window = window.child(.{ + .y_off = @intCast(viewport_window.height), + .width = window.width, + .height = StatusBar.height, + }); + + const run_view = self.history.resolve(self.selected_cursor); + + if (run_view) |resolved_run_view| try self.showRun(viewport_window, resolved_run_view); + self.viewport.draw(viewport_window, self.output.display_lines); + + if (self.selected_cursor != null) { + const selected_run_view = run_view orelse return; + self.status_bar.drawHistory( + status_bar_window, + selected_run_view.run_number, + selected_run_view.run_count, + selected_run_view.run.completed_at, + ); + } else { + self.status_bar.drawLive( + status_bar_window, + if (run_view) |latest_run_view| latest_run_view.run.completed_at else null, + self.run_in_progress, + ); + } + } + + /// Replace displayed output when the resolved history run changes. + fn showRun( + self: *Model, + viewport_window: vaxis.Window, + run_view: History.View, + ) std.mem.Allocator.Error!void { + if (self.displayed_cursor == run_view.cursor) return; + + const viewport_position = self.viewport.position(viewport_window); + const visible_line = switch (viewport_position) { + .line => |line_index| line_index, + else => null, + }; + const mapped_visible_line = try self.output.replaceText( + viewport_window.screen.width_method, + run_view.previous_output, + run_view.run.output, + visible_line, + ); + const next_position: Viewport.Position = if (mapped_visible_line) |line_index| + .{ .line = line_index } + else + viewport_position; + self.viewport.replaceLines(viewport_window, self.output.display_lines, next_position); + + self.displayed_cursor = run_view.cursor; + } +}; diff --git a/src/output.zig b/src/output.zig new file mode 100644 index 0000000..328843a --- /dev/null +++ b/src/output.zig @@ -0,0 +1,257 @@ +//! Owns normalized command output and its display-ready terminal segments. + +const std = @import("std"); +const vaxis = @import("vaxis"); +const diff = @import("diff.zig"); + +const added_style: vaxis.Style = .{ .fg = .{ .index = 2 } }; +const tab_stop = 8; +const DisplayLine = []const vaxis.Segment; + +pub const Output = struct { + normalized_output: ?[]const u8 = null, + display_lines: []const DisplayLine = &.{}, + storage: std.heap.ArenaAllocator, + + /// Create empty output storage. + pub fn init(allocator: std.mem.Allocator) Output { + return .{ .storage = .init(allocator) }; + } + + /// Release all output storage. + pub fn deinit(self: *Output) void { + self.storage.deinit(); + self.* = undefined; + } + + /// Atomically replace output and map an optional line from the displayed output. + pub fn replaceText( + self: *Output, + width_method: vaxis.gwidth.Method, + previous_output: ?[]const u8, + current_output: []const u8, + visible_line: ?u32, + ) std.mem.Allocator.Error!?u32 { + const backing_allocator = self.storage.child_allocator; + + var next_storage: std.heap.ArenaAllocator = .init(backing_allocator); + errdefer next_storage.deinit(); + + var line_scratch: std.heap.ArenaAllocator = .init(backing_allocator); + defer line_scratch.deinit(); + var word_scratch: std.heap.ArenaAllocator = .init(backing_allocator); + defer word_scratch.deinit(); + + const retained_allocator = next_storage.allocator(); + const line_allocator = line_scratch.allocator(); + + const normalized_previous_output = if (previous_output) |raw_previous_output| + try expandTabs(line_allocator, width_method, raw_previous_output) + else + null; + const normalized_current_output = try expandTabs( + retained_allocator, + width_method, + current_output, + ); + const line_changes = try diff.compareLines( + line_allocator, + normalized_previous_output, + normalized_current_output, + ); + + var mapped_visible_line: ?u32 = null; + if (visible_line) |line_index| if (self.normalized_output) |displayed_output| { + const previous_is_displayed = if (normalized_previous_output) |normalized_previous| + std.mem.eql(u8, displayed_output, normalized_previous) + else + false; + const mapping_changes = if (previous_is_displayed) + line_changes + else + try diff.compareLines( + line_allocator, + displayed_output, + normalized_current_output, + ); + mapped_visible_line = mapping_changes.mapBeforeLine(line_index); + }; + + const rendered_lines = try renderLines( + retained_allocator, + &word_scratch, + line_changes, + ); + + self.storage.deinit(); + self.* = .{ + .normalized_output = normalized_current_output, + .display_lines = rendered_lines, + .storage = next_storage, + }; + return mapped_visible_line; + } +}; + +/// Expand tabs to terminal stops using display-cell widths. +fn expandTabs( + arena: std.mem.Allocator, + width_method: vaxis.gwidth.Method, + output_text: []const u8, +) ![]const u8 { + if (std.mem.findScalar(u8, output_text, '\t') == null) + return arena.dupe(u8, output_text); + + var expanded_output: std.ArrayList(u8) = .empty; + var column: usize = 0; + var remaining_output = output_text; + + while (std.mem.findAny(u8, remaining_output, "\t\n\r")) |control_index| { + const preceding_text = remaining_output[0..control_index]; + try expanded_output.appendSlice(arena, preceding_text); + + if (remaining_output[control_index] == '\t') { + column += vaxis.gwidth.gwidth(preceding_text, width_method); + const space_count = tab_stop - column % tab_stop; + try expanded_output.appendNTimes(arena, ' ', space_count); + column += space_count; + } else { + try expanded_output.append(arena, remaining_output[control_index]); + column = 0; + } + remaining_output = remaining_output[control_index + 1 ..]; + } + + try expanded_output.appendSlice(arena, remaining_output); + return expanded_output.items; +} + +/// Render the new side of line changes with additions highlighted. +fn renderLines( + retained_allocator: std.mem.Allocator, + word_scratch: *std.heap.ArenaAllocator, + line_changes: diff.Changes, +) ![]const DisplayLine { + var rendered_lines: std.ArrayList(DisplayLine) = .empty; + var after_line_index: usize = 0; + + for (line_changes.edits) |edit| { + const line_count: usize = @intCast(edit.range.end - edit.range.start); + switch (edit.kind) { + .equal => { + for ( + line_changes.before[edit.range.start..edit.range.end], + line_changes.after[after_line_index..][0..line_count], + ) |before_line, after_line| { + const rendered_line = if (std.mem.eql(u8, before_line, after_line)) + try retained_allocator.dupe(vaxis.Segment, &.{.{ .text = after_line }}) + else + try renderWordChanges( + retained_allocator, + word_scratch, + before_line, + after_line, + ); + try rendered_lines.append(retained_allocator, rendered_line); + } + after_line_index += line_count; + }, + .delete => {}, + .insert => { + for (line_changes.after[after_line_index..][0..line_count]) |inserted_line| + try rendered_lines.append( + retained_allocator, + try retained_allocator.dupe(vaxis.Segment, &.{.{ + .text = inserted_line, + .style = added_style, + }}), + ); + after_line_index += line_count; + }, + } + } + + return rendered_lines.items; +} + +/// Render one matched line with inserted words highlighted. +fn renderWordChanges( + retained_allocator: std.mem.Allocator, + word_scratch: *std.heap.ArenaAllocator, + before_line: []const u8, + after_line: []const u8, +) !DisplayLine { + _ = word_scratch.reset(.retain_capacity); + const word_changes = try diff.compareWords( + word_scratch.allocator(), + before_line, + after_line, + ); + + var rendered_segments: std.ArrayList(vaxis.Segment) = .empty; + var after_part_index: usize = 0; + + for (word_changes.edits) |edit| { + const part_count: usize = @intCast(edit.range.end - edit.range.start); + switch (edit.kind) { + .delete => {}, + .equal, .insert => { + const after_parts = word_changes.after[after_part_index..][0..part_count]; + try rendered_segments.append(retained_allocator, .{ + .text = partSpan(after_parts), + .style = if (edit.kind == .insert) added_style else .{}, + }); + after_part_index += part_count; + }, + } + } + std.debug.assert(after_part_index == word_changes.after.len); + + return rendered_segments.items; +} + +/// Return the text covered by adjacent parts without copying it. +fn partSpan(parts: []const []const u8) []const u8 { + std.debug.assert(parts.len > 0); + + const first_part = parts[0]; + const last_part = parts[parts.len - 1]; + const span_length = @intFromPtr(last_part.ptr) + last_part.len - @intFromPtr(first_part.ptr); + return first_part.ptr[0..span_length]; +} + +test "tabs follow terminal stops" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const expanded = try expandTabs(arena.allocator(), .unicode, "漢\tx\nabcdefgh\ty"); + try std.testing.expectEqualStrings("漢 x\nabcdefgh y", expanded); +} + +test "maps owned output after the caller changes" { + var output: Output = .init(std.testing.allocator); + defer output.deinit(); + + var initial_output = [_]u8{ 'a', '\n', 'b', '\n', 'c', '\n', 'd' }; + _ = try output.replaceText(.unicode, null, &initial_output, null); + @memset(&initial_output, 'x'); + + try std.testing.expectEqual( + @as(?u32, 2), + try output.replaceText( + .unicode, + "id old\na\nb\nc\nd", + "id new\na\nb\nc\nd", + 1, + ), + ); + + const normalized_output = output.normalized_output.?; + const output_start = @intFromPtr(normalized_output.ptr); + const output_end = output_start + normalized_output.len; + for (output.display_lines) |display_line| for (display_line) |segment| { + const segment_start = @intFromPtr(segment.text.ptr); + try std.testing.expect(segment_start >= output_start); + try std.testing.expect(segment_start + segment.text.len <= output_end); + }; +} diff --git a/src/run.zig b/src/run.zig new file mode 100644 index 0000000..066f7ee --- /dev/null +++ b/src/run.zig @@ -0,0 +1,62 @@ +//! Captures one execution of the watched command. + +const std = @import("std"); + +pub const Run = struct { + completed_at: std.Io.Timestamp, + /// Owned stderr followed by stdout, with tabs left unchanged. + output: []const u8, + + /// Run once, returning launch failures as output and cancellation as an error. + pub fn capture( + allocator: std.mem.Allocator, + io: std.Io, + command_arguments: []const []const u8, + ) !Run { + // A fixed shell interprets one command string independently of `$SHELL`. + const process_arguments: []const []const u8 = if (command_arguments.len == 1) + &.{ "/bin/sh", "-c", command_arguments[0] } + else + command_arguments; + + if (std.process.run(allocator, io, .{ .argv = process_arguments })) |process_result| { + const captured_output = if (process_result.stderr.len == 0) stdout_only: { + allocator.free(process_result.stderr); + break :stdout_only process_result.stdout; + } else if (process_result.stdout.len == 0) stderr_only: { + allocator.free(process_result.stdout); + break :stderr_only process_result.stderr; + } else both_streams: { + defer allocator.free(process_result.stdout); + defer allocator.free(process_result.stderr); + break :both_streams try std.mem.concat( + allocator, + u8, + &.{ process_result.stderr, process_result.stdout }, + ); + }; + + return .{ + .completed_at = .now(io, .real), + .output = captured_output, + }; + } else |launch_error| { + if (launch_error == error.Canceled) return error.Canceled; + + return .{ + .completed_at = .now(io, .real), + .output = try std.fmt.allocPrint( + allocator, + "Error running command: {any}", + .{launch_error}, + ), + }; + } + } + + /// Free captured output. + pub fn deinit(self: *Run, allocator: std.mem.Allocator) void { + allocator.free(self.output); + self.* = undefined; + } +}; diff --git a/src/viewport.zig b/src/viewport.zig new file mode 100644 index 0000000..02d991f --- /dev/null +++ b/src/viewport.zig @@ -0,0 +1,279 @@ +//! Scrolls display-ready terminal lines without retaining them. + +const std = @import("std"); +const vaxis = @import("vaxis"); + +const DisplayLine = []const vaxis.Segment; + +// Matching heavy glyphs keep rail-free thumbs visible over content. +const vertical_thumb_cell: vaxis.Cell = .{ + .char = .{ .grapheme = "┃", .width = 1 }, + .style = .{ .fg = .{ .index = 8 } }, +}; +const horizontal_thumb_cell: vaxis.Cell = .{ + .char = .{ .grapheme = "━", .width = 1 }, + .style = .{ .fg = .{ .index = 8 } }, +}; + +const wheel_step = 3; + +const ContentSize = struct { width: u16, height: u16 }; + +pub const Viewport = struct { + pub const Position = union(enum) { + top, + bottom, + line: u32, + }; + + top_line: u32 = 0, + left_column: u32 = 0, + line_count: usize = 0, + content_width: u32 = 0, + + /// Resolve the text area after both scrollbars account for each other. + fn contentSize(self: Viewport, window: vaxis.Window) ContentSize { + var content_size: ContentSize = .{ .width = window.width, .height = window.height }; + // Each scrollbar can only enable the other, so two passes reach a fixed point. + for (0..2) |_| { + content_size.width = window.width -| + @intFromBool(self.line_count > content_size.height); + content_size.height = window.height -| + @intFromBool(self.content_width > content_size.width); + } + return content_size; + } + + /// Report top, bottom, or the first visible line, preferring top when content fits. + pub fn position(self: Viewport, window: vaxis.Window) Position { + if (self.top_line == 0) return .top; + if (self.top_line >= self.line_count -| self.contentSize(window).height) return .bottom; + return .{ .line = self.top_line }; + } + + /// Measure replacement lines and move vertically while preserving horizontal scroll. + pub fn replaceLines( + self: *Viewport, + window: vaxis.Window, + display_lines: []const DisplayLine, + target_position: Position, + ) void { + self.line_count = display_lines.len; + self.content_width = measureContentWidth(window, display_lines); + + self.top_line = switch (target_position) { + .top => 0, + .bottom => @as(u32, @intCast(self.line_count)) -| self.contentSize(window).height, + .line => |line_index| line_index, + }; + } + + /// Pull both offsets inside the drawable range. + fn clampPosition(self: *Viewport, window: vaxis.Window) void { + const content_size = self.contentSize(window); + self.top_line = @min( + self.top_line, + @as(u32, @intCast(self.line_count)) -| content_size.height, + ); + // A Vaxis child cannot reach past maxInt(u16), even when content is wider. + const maximum_left_column = std.math.maxInt(u16) - content_size.width; + self.left_column = @min( + self.left_column, + @min(self.content_width -| content_size.width, maximum_left_column), + ); + } + + /// Apply one keyboard scroll command without clamping the result. + pub fn scrollWithKey(self: *Viewport, key: vaxis.Key, window: vaxis.Window) void { + if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) { + self.top_line +|= 1; + } else if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) { + self.top_line -|= 1; + } else if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) { + self.left_column +|= 1; + } else if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) { + self.left_column -|= 1; + } else if (key.matches(vaxis.Key.down, .{ .shift = true }) or + key.matches(vaxis.Key.page_down, .{})) + { + self.top_line +|= self.contentSize(window).height; + } else if (key.matches(vaxis.Key.up, .{ .shift = true }) or + key.matches(vaxis.Key.page_up, .{})) + { + self.top_line -|= self.contentSize(window).height; + } else if (key.matches(vaxis.Key.right, .{ .shift = true })) { + self.left_column +|= self.contentSize(window).width; + } else if (key.matches(vaxis.Key.left, .{ .shift = true })) { + self.left_column -|= self.contentSize(window).width; + } else if (key.matches(vaxis.Key.escape, .{})) { + // Model consumes Escape in history; live mode uses it to return to top. + self.top_line = 0; + } + } + + /// Apply one mouse-wheel scroll command. + pub fn scrollWithMouse(self: *Viewport, mouse: vaxis.Mouse) void { + switch (mouse.button) { + .wheel_up => self.top_line -|= wheel_step, + .wheel_down => self.top_line +|= wheel_step, + // Horizontal wheel events intentionally move content in the opposite direction. + .wheel_left => self.left_column +|= wheel_step, + .wheel_right => self.left_column -|= wheel_step, + else => return, + } + } + + /// Clamp offsets, draw visible lines, and overlay scrollbar thumbs. + pub fn draw(self: *Viewport, window: vaxis.Window, display_lines: []const DisplayLine) void { + std.debug.assert(display_lines.len == self.line_count); + self.clampPosition(window); + std.debug.assert(self.left_column <= std.math.maxInt(u16) -| window.width); + const content_size = self.contentSize(window); + + // A wider child shifted left lets Vaxis clip horizontal scroll for us. + const content_window = window.child(.{ + .x_off = -@as(i17, @intCast(self.left_column)), + .width = content_size.width + @as(u16, @intCast(self.left_column)), + .height = content_size.height, + }); + + for (0..content_size.height) |viewport_row| { + const line_index = self.top_line + viewport_row; + if (line_index >= display_lines.len) break; + // The child extends off-screen, so wrapping would create false rows. + _ = content_window.print( + display_lines[line_index], + .{ .row_offset = @intCast(viewport_row), .wrap = .none }, + ); + } + + if (content_size.width < window.width) { + const vertical_thumb = scrollbarThumb( + content_size.height, + @intCast(self.line_count), + self.top_line, + ); + for (vertical_thumb.offset..vertical_thumb.offset + vertical_thumb.length) |thumb_row| + window.writeCell(window.width -| 1, @intCast(thumb_row), vertical_thumb_cell); + } + if (content_size.height < window.height) { + const horizontal_thumb = scrollbarThumb( + content_size.width, + self.content_width, + self.left_column, + ); + for (horizontal_thumb.offset..horizontal_thumb.offset + horizontal_thumb.length) |thumb_column| + window.writeCell( + @intCast(thumb_column), + window.height -| 1, + horizontal_thumb_cell, + ); + } + } +}; + +/// Measure the widest line in terminal cells. +fn measureContentWidth(window: vaxis.Window, display_lines: []const DisplayLine) u32 { + var content_width: u32 = 0; + for (display_lines) |display_line| { + var line_width: u32 = 0; + for (display_line) |segment| line_width += displayWidth(window, segment.text); + content_width = @max(content_width, line_width); + } + return content_width; +} + +/// Measure printable ASCII directly and leave all other text to Vaxis. +fn displayWidth(window: vaxis.Window, text: []const u8) u32 { + for (text) |byte| if (!std.ascii.isPrint(byte)) return window.gwidth(text); + return @intCast(text.len); +} + +const ScrollbarThumb = struct { offset: u16, length: u16 }; + +/// Scale an overflowing axis into a thumb that lands exactly at both ends. +fn scrollbarThumb( + track_length: u16, + content_length: u32, + content_offset: u32, +) ScrollbarThumb { + const thumb_length: u16 = @min( + track_length, + @max(1, @as(u16, @intCast(@as(u32, track_length) * track_length / content_length))), + ); + const thumb_travel = track_length -| thumb_length; + const scroll_range = content_length - track_length; + return .{ + .offset = @intCast(@as(u32, thumb_travel) * content_offset / scroll_range), + .length = thumb_length, + }; +} + +test "positions and clamps stay inside the content" { + const line_segments = [_]vaxis.Segment{.{ .text = "x" }}; + const display_lines = [_]DisplayLine{&line_segments} ** 30; + + var view = try vaxis.widgets.View.init(std.testing.allocator, .{ .width = 10, .height = 8 }); + defer view.deinit(); + const window = view.window(); + + var viewport: Viewport = .{}; + viewport.replaceLines(window, &display_lines, .top); + try std.testing.expectEqual(@as(u32, 0), viewport.top_line); + + viewport.top_line = 1; + try std.testing.expectEqualDeep( + @as(Viewport.Position, .{ .line = 1 }), + viewport.position(window), + ); + viewport.replaceLines(window, &display_lines, .{ .line = 2 }); + try std.testing.expectEqual(@as(u32, 2), viewport.top_line); + + viewport.replaceLines(window, &display_lines, .bottom); + try std.testing.expectEqual(@as(u32, 22), viewport.top_line); + try std.testing.expectEqualDeep( + @as(Viewport.Position, .bottom), + viewport.position(window), + ); + + viewport.top_line = 100; + viewport.clampPosition(window); + try std.testing.expectEqual(@as(u32, 22), viewport.top_line); + + viewport.left_column = 100; + viewport.clampPosition(window); + try std.testing.expectEqual(@as(u32, 0), viewport.left_column); + + var tall_view = try vaxis.widgets.View.init(std.testing.allocator, .{ .width = 10, .height = 40 }); + defer tall_view.deinit(); + viewport.clampPosition(tall_view.window()); + try std.testing.expectEqual(@as(u32, 0), viewport.top_line); +} + +test "drawing clamps content wider than a Vaxis child" { + var view = try vaxis.widgets.View.init(std.testing.allocator, .{ .width = 80, .height = 4 }); + defer view.deinit(); + const window = view.window(); + + const wide_text = "x" ** 66_000; + const line_segments = [_]vaxis.Segment{.{ .text = wide_text }}; + const display_lines = [_]DisplayLine{&line_segments}; + + var viewport: Viewport = .{}; + viewport.replaceLines(window, &display_lines, .top); + + viewport.left_column = 90_000; + viewport.draw(window, &display_lines); + try std.testing.expect(viewport.left_column <= std.math.maxInt(u16) - window.width); + try std.testing.expectEqual(@as(u8, 'x'), view.readCell(0, 0).?.char.grapheme[0]); +} + +test "display width preserves Vaxis handling outside printable ASCII" { + var view = try vaxis.widgets.View.init(std.testing.allocator, .{ .width = 10, .height = 2 }); + defer view.deinit(); + const window = view.window(); + + try std.testing.expectEqual(@as(u32, 5), displayWidth(window, "plain")); + try std.testing.expectEqual(@as(u32, window.gwidth("漢")), displayWidth(window, "漢")); + try std.testing.expectEqual(@as(u32, window.gwidth("\x1b")), displayWidth(window, "\x1b")); +}