├── .changes ├── header.tpl.md ├── unreleased │ ├── .gitkeep │ └── Fixed-20241127-171732.yaml ├── v0.1.0.md ├── v0.10.0.md ├── v0.11.0.md ├── v0.11.1.md ├── v0.12.0.md ├── v0.13.0.md ├── v0.13.1.md ├── v0.14.0.md ├── v0.14.1.md ├── v0.2.0.md ├── v0.3.0.md ├── v0.3.1.md ├── v0.4.0.md ├── v0.5.0.md ├── v0.5.1.md ├── v0.6.0.md ├── v0.6.1.md ├── v0.6.2.md ├── v0.7.0.md ├── v0.7.1.md ├── v0.7.2.md ├── v0.8.0.md └── v0.9.0.md ├── .changie.yaml ├── .github └── workflows │ ├── ci.yml │ ├── release.yml │ └── stitchmd.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── action.go ├── action_test.go ├── alphabet.go ├── alphabet_test.go ├── app.go ├── app_test.go ├── codecov.yml ├── config.go ├── config_test.go ├── doc ├── README.md ├── credits.md ├── faq.md ├── howto-clipboard.md ├── howto-regex-name.md ├── howto-select.md ├── install.md ├── intro.md ├── license.md ├── multi-select.md ├── opt-action.md ├── opt-alphabet.md ├── opt-key.md ├── opt-regex.md ├── opt-shift-action.md ├── regex-names.md ├── similar.md ├── static │ ├── README.md │ ├── files.gif │ ├── gitlog.gif │ ├── ip.gif │ ├── logo.png │ └── uuid.gif └── usage.md ├── fastcopy.tmux ├── go.mod ├── go.sum ├── install.sh ├── integration ├── doc.go ├── go.mod ├── go.sum ├── integration_test.go └── unquote_test.go ├── internal ├── envtest │ ├── env.go │ └── env_test.go ├── fastcopy │ ├── hint.go │ ├── hint_test.go │ ├── mock_handler_test.go │ ├── widget.go │ └── widget_test.go ├── log │ ├── log.go │ ├── log_test.go │ ├── logtest │ │ └── logger.go │ ├── writer.go │ └── writer_test.go ├── must │ ├── must.go │ └── must_test.go ├── paniclog │ ├── handle.go │ └── handle_test.go ├── stringobj │ ├── stringobj.go │ └── stringobj_test.go ├── tail │ ├── tail.go │ └── tail_test.go ├── tmux │ ├── doc.go │ ├── driver.go │ ├── gen.go │ ├── inspect.go │ ├── inspect_test.go │ ├── shell.go │ ├── shell_test.go │ ├── tmuxfmt │ │ ├── capture.go │ │ ├── capture_test.go │ │ ├── doc.go │ │ ├── expr.go │ │ ├── expr_test.go │ │ ├── render.go │ │ └── render_test.go │ ├── tmuxopt │ │ ├── tmuxopt.go │ │ └── tmuxopt_test.go │ └── tmuxtest │ │ ├── doc.go │ │ ├── matchers.go │ │ └── mock_driver.go └── ui │ ├── annotated_text.go │ ├── annotated_text_test.go │ ├── app.go │ ├── app_test.go │ ├── mock_widget_test.go │ ├── pos.go │ ├── pos_test.go │ ├── screen_test.go │ ├── text.go │ ├── text_test.go │ └── widget.go ├── main.go ├── main_test.go ├── matcher.go ├── matcher_test.go ├── mise.lock ├── mise.toml ├── renovate.json ├── wrap.go └── wrap_test.go /.changes/header.tpl.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), 6 | and is generated by [Changie](https://github.com/miniscruff/changie). 7 | -------------------------------------------------------------------------------- /.changes/unreleased/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhinav/tmux-fastcopy/fa4748b6893d4a862b2aafa7a643fb57e5517a40/.changes/unreleased/.gitkeep -------------------------------------------------------------------------------- /.changes/unreleased/Fixed-20241127-171732.yaml: -------------------------------------------------------------------------------- 1 | kind: Fixed 2 | body: Tweak label assignment algorithm to generate shorter labels in certain cases. 3 | time: 2024-11-27T17:17:32.425602-08:00 4 | -------------------------------------------------------------------------------- /.changes/v0.1.0.md: -------------------------------------------------------------------------------- 1 | ## v0.1.0 - 2021-08-14 2 | - Initial release. 3 | -------------------------------------------------------------------------------- /.changes/v0.10.0.md: -------------------------------------------------------------------------------- 1 | ## v0.10.0 - 2023-03-25 2 | ### Changed 3 | - Use event-based rendering instead of fixed rate rendering. 4 | This should reduce flickering on slow systems. 5 | -------------------------------------------------------------------------------- /.changes/v0.11.0.md: -------------------------------------------------------------------------------- 1 | ## v0.11.0 - 2023-06-11 2 | ### Changed 3 | - Relicense under GPL-2.0. 4 | - Run `fastcopy-action` and `fastcopy-shift-action` inside the pane's current directory, if available. 5 | -------------------------------------------------------------------------------- /.changes/v0.11.1.md: -------------------------------------------------------------------------------- 1 | ## v0.11.1 - 2023-06-11 2 | ### Fixed 3 | - Fix release archive names for AUR, Homebrew, etc. 4 | -------------------------------------------------------------------------------- /.changes/v0.12.0.md: -------------------------------------------------------------------------------- 1 | ## v0.12.0 - 2023-06-18 2 | ### Added 3 | - Actions are now run with `FASTCOPY_TARGET_PANE_ID` set to the ID of the pane 4 | where fastcopy was invoked. 5 | Use this to run operations against the pane from within the action. 6 | -------------------------------------------------------------------------------- /.changes/v0.13.0.md: -------------------------------------------------------------------------------- 1 | ## v0.13.0 - 2023-09-14 2 | ### Added 3 | - Add support for multiple selections. Press `Tab` after invoking tmux-fastcopy to enter multi-select mode. 4 | 5 | Thanks to @hansmansson for their contribution to this release. 6 | -------------------------------------------------------------------------------- /.changes/v0.13.1.md: -------------------------------------------------------------------------------- 1 | ## v0.13.1 - 2023-09-14 2 | ### Fixed 3 | - Fix installation script for latest release. 4 | -------------------------------------------------------------------------------- /.changes/v0.14.0.md: -------------------------------------------------------------------------------- 1 | ## v0.14.0 - 2023-10-15 2 | ### Removed 3 | - Drop Windows builds. 4 | ### Fixed 5 | - Fix hang when destroy-unattached is set to on. 6 | -------------------------------------------------------------------------------- /.changes/v0.14.1.md: -------------------------------------------------------------------------------- 1 | ## v0.14.1 - 2023-12-03 2 | ### Fixed 3 | - Properly unescape escapes in regex strings when decoding tmux options. 4 | -------------------------------------------------------------------------------- /.changes/v0.2.0.md: -------------------------------------------------------------------------------- 1 | ## v0.2.0 - 2021-08-15 2 | ### Changed 3 | - Download pre-built binary from GitHub if Go isn't available. 4 | -------------------------------------------------------------------------------- /.changes/v0.3.0.md: -------------------------------------------------------------------------------- 1 | ## v0.3.0 - 2021-08-21 2 | This release includes support for customizing regular expressions used by 3 | tmux-fastcopy with the [`@fastcopy-regex-*` options][]. Check out the README 4 | for more details. 5 | 6 | ### Added 7 | - Support defining custom regular expressions, and overwriting or removing the 8 | default regular expressions. 9 | 10 | ### Removed 11 | - Remove `-log` flag in favor of controlling the log file with an environment 12 | variable. 13 | 14 | ### Fixed 15 | - Fix crashes of the wrapped binary not getting logged. 16 | 17 | [`@fastcopy-regex-*` options]: https://github.com/abhinav/tmux-fastcopy#fastcopy-regex- 18 | -------------------------------------------------------------------------------- /.changes/v0.3.1.md: -------------------------------------------------------------------------------- 1 | ## v0.3.1 - 2021-08-23 2 | ### Fixed 3 | - Make path regex more accurate and avoid matching URLs. 4 | -------------------------------------------------------------------------------- /.changes/v0.4.0.md: -------------------------------------------------------------------------------- 1 | ## v0.4.0 - 2021-09-06 2 | Highlight: The minimum required version of Tmux was lowered to 3.0. 3 | 4 | ### Added 5 | - Add back `-log` flag. Use this flag to specify the destination for log 6 | messages. 7 | - Add `-tmux` flag to specify the location of the tmux executable. 8 | 9 | ### Changed 10 | - Support Tmux 3.0. Previously, tmux-fastcopy required at least Tmux 3.2. 11 | -------------------------------------------------------------------------------- /.changes/v0.5.0.md: -------------------------------------------------------------------------------- 1 | ## v0.5.0 - 2021-09-07 2 | ### Changed 3 | - Change default action to `tmux load-buffer -`. This eliminates risk of 4 | hitting ARG_MAX with `set-buffer`--however unlikely that was. 5 | 6 | ### Fixed 7 | - [(#38)]: Fix infinite loop when there's a single match. 8 | 9 | [(#38)]: https://github.com/abhinav/tmux-fastcopy/issues/38 10 | -------------------------------------------------------------------------------- /.changes/v0.5.1.md: -------------------------------------------------------------------------------- 1 | ## v0.5.1 - 2021-09-09 2 | ### Fixed 3 | - Don't consume 100% CPU when idling. 4 | -------------------------------------------------------------------------------- /.changes/v0.6.0.md: -------------------------------------------------------------------------------- 1 | ## v0.6.0 - 2021-09-13 2 | ### Added 3 | - Publish Homebrew formulae for the project. 4 | -------------------------------------------------------------------------------- /.changes/v0.6.1.md: -------------------------------------------------------------------------------- 1 | ## v0.6.1 - 2021-10-28 2 | ### Fixed 3 | - Homebrew formula: Conform to new Homebrew requirements. 4 | -------------------------------------------------------------------------------- /.changes/v0.6.2.md: -------------------------------------------------------------------------------- 1 | ## v0.6.2 - 2021-12-29 2 | ### Fixed 3 | - Better handle single-quoted strings in Tmux configuration. 4 | -------------------------------------------------------------------------------- /.changes/v0.7.0.md: -------------------------------------------------------------------------------- 1 | ## v0.7.0 - 2022-02-10 2 | ### Changed 3 | - Handle wrapping of long lines by Tmux. 4 | These lines will now be joined when copied. 5 | -------------------------------------------------------------------------------- /.changes/v0.7.1.md: -------------------------------------------------------------------------------- 1 | ## v0.7.1 - 2022-02-18 2 | ### Added 3 | - Publish a Linux ARM 32-bit binary with each release. 4 | - Publish a `tmux-fastcopy-bin` package to AUR. 5 | -------------------------------------------------------------------------------- /.changes/v0.7.2.md: -------------------------------------------------------------------------------- 1 | ## v0.7.2 - 2022-02-19 2 | ### Added 3 | - For 32-bit ARM binaries, support ARM v5, v6, and v7. 4 | -------------------------------------------------------------------------------- /.changes/v0.8.0.md: -------------------------------------------------------------------------------- 1 | ## v0.8.0 - 2022-04-01 2 | ### Added 3 | - Expose the name of the matched regex to the action with the 4 | `FASTCOPY_REGEX_NAME` environment variable. 5 | -------------------------------------------------------------------------------- /.changes/v0.9.0.md: -------------------------------------------------------------------------------- 1 | ## v0.9.0 - 2022-05-29 2 | ### Added 3 | - Add `@fastcopy-shift-action` to specify an alternative action to be run when 4 | a label is selected with the Shift key pressed. 5 | -------------------------------------------------------------------------------- /.changie.yaml: -------------------------------------------------------------------------------- 1 | changesDir: .changes 2 | unreleasedDir: unreleased 3 | headerPath: header.tpl.md 4 | changelogPath: CHANGELOG.md 5 | versionExt: md 6 | versionFormat: '## {{.Version}} - {{.Time.Format "2006-01-02"}}' 7 | kindFormat: '### {{.Kind}}' 8 | changeFormat: '- {{.Body}}' 9 | kinds: 10 | - label: Added 11 | auto: minor 12 | - label: Changed 13 | auto: major 14 | - label: Deprecated 15 | auto: minor 16 | - label: Removed 17 | auto: major 18 | - label: Fixed 19 | auto: patch 20 | - label: Security 21 | auto: patch 22 | newlines: 23 | afterChangelogHeader: 0 24 | beforeChangelogVersion: 1 25 | endOfVersion: 1 26 | envPrefix: CHANGIE_ 27 | replacements: 28 | - path: install.sh 29 | find: 'VERSION=.*' 30 | replace: 'VERSION={{.VersionNoPrefix}}' 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ '*' ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | 12 | lint: 13 | name: Lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | name: Check out repository 18 | - name: Set up mise 19 | uses: jdx/mise-action@v2 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | with: 23 | cache_key_prefix: mise-v0-stable 24 | - run: mise run lint 25 | 26 | test: 27 | runs-on: ubuntu-latest 28 | name: Unit Test 29 | 30 | steps: 31 | - uses: actions/checkout@v4 32 | name: Check out repository 33 | - name: Set up mise 34 | uses: jdx/mise-action@v2 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | with: 38 | cache_key_prefix: mise-v0-stable 39 | - name: Build tmux-fastcopy 40 | run: mise run build 41 | - name: Test 42 | run: mise run cover:unit 43 | - name: Coverage 44 | uses: codecov/codecov-action@v5 45 | with: 46 | files: ./cover.out 47 | 48 | integration: 49 | runs-on: ubuntu-latest 50 | name: Integration Test / Tmux ${{ matrix.tmux-version }} 51 | strategy: 52 | matrix: 53 | tmux-version: ["3.3a", "3.2a", "3.1c", "3.0a", "2.9a", "2.8", "2.7"] 54 | 55 | steps: 56 | - uses: actions/checkout@v4 57 | name: Check out repository 58 | - name: Set up mise 59 | uses: jdx/mise-action@v2 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | with: 63 | cache_key_prefix: mise-v0-stable 64 | 65 | - name: Checkout Tmux 66 | uses: actions/checkout@v4 67 | with: 68 | repository: tmux/tmux 69 | ref: ${{ matrix.tmux-version }} 70 | path: src/tmux 71 | - name: Load cached Tmux 72 | uses: actions/cache@v4 73 | with: 74 | path: ~/.local 75 | key: ${{ runner.os }}-tmux-${{ matrix.tmux-version }} 76 | 77 | - name: Download and install dependencies 78 | run: | 79 | sudo apt-get install -y libevent-dev libncurses-dev 80 | - name: Install Tmux 81 | working-directory: src/tmux 82 | run: | 83 | if [[ ! -x "$HOME/.local/bin/tmux" ]]; then 84 | sh autogen.sh 85 | ./configure --prefix="$HOME/.local" 86 | make install 87 | else 88 | echo "Using cached tmux" 89 | fi 90 | 91 | - name: Build tmux-fastcopy 92 | run: mise run build 93 | 94 | - name: Integration test 95 | run: mise run cover:integration 96 | 97 | - name: Upload coverage 98 | uses: codecov/codecov-action@v5 99 | with: 100 | files: ./cover.integration.out 101 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: ['v*'] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Setup Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version-file: go.mod 24 | cache: true 25 | 26 | - name: Install parse-changelog 27 | uses: taiki-e/install-action@v2 28 | with: 29 | tool: parse-changelog@0.5.1 30 | 31 | - name: Prepare release 32 | run: | 33 | set -eou pipefail 34 | VERSION=${{ github.ref }} 35 | VERSION="${VERSION#refs/tags/}" 36 | echo "VERSION=$VERSION" >> "$GITHUB_ENV" 37 | echo "Releasing $VERSION" 38 | echo "Release notes:" 39 | echo "----" 40 | parse-changelog CHANGELOG.md "${VERSION#v}" | tee "changes.$VERSION.txt" 41 | echo "----" 42 | 43 | - name: Release 44 | uses: goreleaser/goreleaser-action@v6 45 | with: 46 | distribution: goreleaser 47 | version: latest 48 | args: release --clean --release-notes changes.${{ env.VERSION }}.txt 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | AUR_KEY: ${{ secrets.AUR_KEY }} 52 | HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} 53 | GORELEASER_CURRENT_TAG: ${{ env.VERSION }} 54 | -------------------------------------------------------------------------------- /.github/workflows/stitchmd.yml: -------------------------------------------------------------------------------- 1 | name: Stitch README.md 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | # Change the event to pull_request_target 8 | # so that it runs in the context of the base repository. 9 | pull_request_target: 10 | 11 | jobs: 12 | stitchmd: 13 | name: ${{ github.event_name == 'pull_request_target' && 'Update' || 'Check' }} 14 | runs-on: ubuntu-latest 15 | 16 | permissions: 17 | contents: write 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | repository: ${{ github.event.pull_request.head.repo.full_name }} 23 | ref: ${{ github.head_ref }} 24 | 25 | - name: Check or update README 26 | uses: abhinav/stitchmd-action@v1 27 | with: 28 | mode: ${{ github.event_name == 'pull_request_target' && 'write' || 'check' }} 29 | summary: doc/README.md 30 | output: README.md 31 | 32 | - uses: stefanzweifel/git-auto-commit-action@v5 33 | if: ${{ github.event_name == 'pull_request_target' }} 34 | with: 35 | file_pattern: README.md 36 | commit_message: 'Update README.md' 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | /cover.* 3 | /dist 4 | /changes.*.txt 5 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | issues: 4 | max-issues-per-linter: 0 5 | max-same-issues: 0 6 | 7 | linters: 8 | enable: 9 | - nolintlint 10 | - revive 11 | settings: 12 | errcheck: 13 | exclude-functions: 14 | - fmt.Fprint 15 | - fmt.Fprintf 16 | - fmt.Fprintln 17 | govet: 18 | enable: 19 | - nilness 20 | - reflectvaluecompare 21 | - sortslice 22 | - unusedwrite 23 | exclusions: 24 | generated: lax 25 | rules: 26 | - linters: 27 | - revive 28 | text: 'unused-parameter: parameter \S+ seems to be unused, consider removing or renaming it as _' 29 | 30 | formatters: 31 | enable: 32 | - gofumpt 33 | exclusions: 34 | generated: lax 35 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: tmux-fastcopy 2 | 3 | before: 4 | hooks: 5 | # Verify that the version number in the install.sh matches the planned 6 | # version. 7 | - ./install.sh -c {{.Version}} 8 | 9 | builds: 10 | - env: 11 | - CGO_ENABLED=0 12 | goarch: 13 | - 386 14 | - amd64 15 | - arm 16 | - arm64 17 | goos: 18 | - linux 19 | - darwin 20 | goarm: [5, 6, 7] 21 | ignore: 22 | - goos: darwin 23 | goarch: arm 24 | ldflags: '-s -w -X main._version={{.Version}}' 25 | 26 | archives: 27 | - name_template: >- 28 | {{ .ProjectName }}_ 29 | {{- .Version }}_ 30 | {{- title .Os }}_ 31 | {{- if eq .Arch "amd64" }}x86_64 32 | {{- else if eq .Arch "386" }}i386 33 | {{- else }}{{ .Arch }}{{ end }} 34 | {{- with .Arm }}v{{ . }}{{ end }} 35 | 36 | aurs: 37 | - name: tmux-fastcopy-bin 38 | homepage: https://github.com/abhinav/tmux-fastcopy 39 | description: "easymotion-style text copying for tmux." 40 | maintainers: 41 | - 'Abhinav Gupta ' 42 | license: "GPL-2.0" 43 | git_url: "ssh://aur@aur.archlinux.org/tmux-fastcopy-bin.git" 44 | skip_upload: auto 45 | private_key: '{{ .Env.AUR_KEY }}' 46 | package: |- 47 | install -Dm755 "./tmux-fastcopy" "${pkgdir}/usr/bin/tmux-fastcopy" 48 | install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/tmux-fastcopy/LICENSE" 49 | install -Dm644 "./README.md" "${pkgdir}/usr/share/doc/tmux-fastcopy/README.md" 50 | install -Dm644 "./CHANGELOG.md" "${pkgdir}/usr/share/doc/tmux-fastcopy/CHANGELOG.md" 51 | commit_author: 52 | name: Abhinav Gupta 53 | email: mail@abhinavg.net 54 | 55 | brews: 56 | - repository: 57 | owner: abhinav 58 | name: homebrew-tap 59 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 60 | commit_msg_template: "{{ .ProjectName }}: Update formula to {{ .Tag }}" 61 | commit_author: 62 | name: Abhinav Gupta 63 | email: mail@abhinavg.net 64 | homepage: https://github.com/abhinav/tmux-fastcopy 65 | description: "easymotion-style text copying for tmux." 66 | license: "GPL-2.0" 67 | skip_upload: auto 68 | dependencies: 69 | - name: tmux 70 | test: | 71 | system "#{bin}/tmux-fastcopy -version" 72 | 73 | checksum: 74 | name_template: 'checksums.txt' 75 | 76 | snapshot: 77 | name_template: "{{ incminor .Tag }}-dev" 78 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), 6 | and is generated by [Changie](https://github.com/miniscruff/changie). 7 | 8 | ## v0.14.1 - 2023-12-03 9 | ### Fixed 10 | - Properly unescape escapes in regex strings when decoding tmux options. 11 | 12 | ## v0.14.0 - 2023-10-15 13 | ### Removed 14 | - Drop Windows builds. 15 | ### Fixed 16 | - Fix hang when destroy-unattached is set to on. 17 | 18 | ## v0.13.1 - 2023-09-14 19 | ### Fixed 20 | - Fix installation script for latest release. 21 | 22 | ## v0.13.0 - 2023-09-14 23 | ### Added 24 | - Add support for multiple selections. Press `Tab` after invoking tmux-fastcopy to enter multi-select mode. 25 | 26 | Thanks to @hansmansson for their contribution to this release. 27 | 28 | ## v0.12.0 - 2023-06-18 29 | ### Added 30 | - Actions are now run with `FASTCOPY_TARGET_PANE_ID` set to the ID of the pane 31 | where fastcopy was invoked. 32 | Use this to run operations against the pane from within the action. 33 | 34 | ## v0.11.1 - 2023-06-11 35 | ### Fixed 36 | - Fix release archive names for AUR, Homebrew, etc. 37 | 38 | ## v0.11.0 - 2023-06-11 39 | ### Changed 40 | - Relicense under GPL-2.0. 41 | - Run `fastcopy-action` and `fastcopy-shift-action` inside the pane's current directory, if available. 42 | 43 | ## v0.10.0 - 2023-03-25 44 | ### Changed 45 | - Use event-based rendering instead of fixed rate rendering. 46 | This should reduce flickering on slow systems. 47 | 48 | ## v0.9.0 - 2022-05-29 49 | ### Added 50 | - Add `@fastcopy-shift-action` to specify an alternative action to be run when 51 | a label is selected with the Shift key pressed. 52 | 53 | ## v0.8.0 - 2022-04-01 54 | ### Added 55 | - Expose the name of the matched regex to the action with the 56 | `FASTCOPY_REGEX_NAME` environment variable. 57 | 58 | ## v0.7.2 - 2022-02-19 59 | ### Added 60 | - For 32-bit ARM binaries, support ARM v5, v6, and v7. 61 | 62 | ## v0.7.1 - 2022-02-18 63 | ### Added 64 | - Publish a Linux ARM 32-bit binary with each release. 65 | - Publish a `tmux-fastcopy-bin` package to AUR. 66 | 67 | ## v0.7.0 - 2022-02-10 68 | ### Changed 69 | - Handle wrapping of long lines by Tmux. 70 | These lines will now be joined when copied. 71 | 72 | ## v0.6.2 - 2021-12-29 73 | ### Fixed 74 | - Better handle single-quoted strings in Tmux configuration. 75 | 76 | ## v0.6.1 - 2021-10-28 77 | ### Fixed 78 | - Homebrew formula: Conform to new Homebrew requirements. 79 | 80 | ## v0.6.0 - 2021-09-13 81 | ### Added 82 | - Publish Homebrew formulae for the project. 83 | 84 | ## v0.5.1 - 2021-09-09 85 | ### Fixed 86 | - Don't consume 100% CPU when idling. 87 | 88 | ## v0.5.0 - 2021-09-07 89 | ### Changed 90 | - Change default action to `tmux load-buffer -`. This eliminates risk of 91 | hitting ARG_MAX with `set-buffer`--however unlikely that was. 92 | 93 | ### Fixed 94 | - [(#38)]: Fix infinite loop when there's a single match. 95 | 96 | [(#38)]: https://github.com/abhinav/tmux-fastcopy/issues/38 97 | 98 | ## v0.4.0 - 2021-09-06 99 | Highlight: The minimum required version of Tmux was lowered to 3.0. 100 | 101 | ### Added 102 | - Add back `-log` flag. Use this flag to specify the destination for log 103 | messages. 104 | - Add `-tmux` flag to specify the location of the tmux executable. 105 | 106 | ### Changed 107 | - Support Tmux 3.0. Previously, tmux-fastcopy required at least Tmux 3.2. 108 | 109 | ## v0.3.1 - 2021-08-23 110 | ### Fixed 111 | - Make path regex more accurate and avoid matching URLs. 112 | 113 | ## v0.3.0 - 2021-08-21 114 | This release includes support for customizing regular expressions used by 115 | tmux-fastcopy with the [`@fastcopy-regex-*` options][]. Check out the README 116 | for more details. 117 | 118 | ### Added 119 | - Support defining custom regular expressions, and overwriting or removing the 120 | default regular expressions. 121 | 122 | ### Removed 123 | - Remove `-log` flag in favor of controlling the log file with an environment 124 | variable. 125 | 126 | ### Fixed 127 | - Fix crashes of the wrapped binary not getting logged. 128 | 129 | [`@fastcopy-regex-*` options]: https://github.com/abhinav/tmux-fastcopy#fastcopy-regex- 130 | 131 | ## v0.2.0 - 2021-08-15 132 | ### Changed 133 | - Download pre-built binary from GitHub if Go isn't available. 134 | 135 | ## v0.1.0 - 2021-08-14 136 | - Initial release. 137 | -------------------------------------------------------------------------------- /action.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "os/exec" 6 | "strings" 7 | 8 | "github.com/abhinav/tmux-fastcopy/internal/fastcopy" 9 | "github.com/abhinav/tmux-fastcopy/internal/log" 10 | shellwords "github.com/mattn/go-shellwords" 11 | "go.uber.org/multierr" 12 | ) 13 | 14 | const ( 15 | _placeholderArg = "{}" 16 | _regexNamesEnvKey = "FASTCOPY_REGEX_NAME" 17 | _targetPaneEnvKey = "FASTCOPY_TARGET_PANE_ID" 18 | ) 19 | 20 | func regexNamesEnvEntry(matchers []string) string { 21 | return _regexNamesEnvKey + "=" + strings.Join(matchers, " ") 22 | } 23 | 24 | type actionFactory struct { 25 | Log *log.Logger 26 | Environ func() []string 27 | Getwd func() (string, error) 28 | } 29 | 30 | type newActionRequest struct { 31 | // Action is a multi-word shell command. 32 | // It should use "{}" as an argument to reference the selected text. 33 | // If no "{}" is present, the selection will be sent to the command 34 | // over stdin. 35 | Action string 36 | 37 | // Dir is the working directory to run the command in. 38 | // 39 | // If empty, the current working directory is used. 40 | Dir string 41 | 42 | // TargetPaneID is the ID of the pane to send the output to. 43 | TargetPaneID string 44 | } 45 | 46 | // New builds a command handler from the provided string. 47 | // 48 | // The string is a multi-word shell command. It should use "{}" as an argument 49 | // to reference the selected text. If no "{}" is present, the selection will be 50 | // sent to the command over stdin. 51 | func (f *actionFactory) New(req newActionRequest) (action, error) { 52 | args, err := shellwords.Parse(req.Action) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | if len(args) == 0 { 58 | return nil, errors.New("empty action") 59 | } 60 | 61 | dir := req.Dir 62 | if dir == "" { 63 | dir, err = f.Getwd() 64 | if err != nil { 65 | // This should never happen, but if it does, use an 66 | // empty string and let exec.Command() figure it out. 67 | dir = "" 68 | } 69 | } 70 | 71 | cmd, args := args[0], args[1:] 72 | for i, arg := range args { 73 | if arg == _placeholderArg { 74 | return &argAction{ 75 | Cmd: cmd, 76 | BeforeArgs: args[:i], 77 | AfterArgs: args[i+1:], 78 | Log: f.Log, 79 | Environ: f.Environ, 80 | Dir: dir, 81 | PaneID: req.TargetPaneID, 82 | }, nil 83 | } 84 | } 85 | 86 | // No "{}" use stdin. 87 | return &stdinAction{ 88 | Cmd: cmd, 89 | Args: args, 90 | Log: f.Log, 91 | Environ: f.Environ, 92 | Dir: dir, 93 | PaneID: req.TargetPaneID, 94 | }, nil 95 | } 96 | 97 | // action specifies how to handle the user's selection. 98 | type action interface { 99 | Run(fastcopy.Selection) error 100 | } 101 | 102 | type stdinAction struct { 103 | Cmd string 104 | Dir string 105 | Args []string 106 | Log *log.Logger 107 | PaneID string 108 | Environ func() []string // == os.Environ 109 | } 110 | 111 | func (h *stdinAction) Run(sel fastcopy.Selection) (err error) { 112 | logw := &log.Writer{ 113 | Log: h.Log.WithName(h.Cmd), 114 | } 115 | defer multierr.AppendInvoke(&err, multierr.Close(logw)) 116 | 117 | cmd := exec.Command(h.Cmd, h.Args...) 118 | cmd.Stdin = strings.NewReader(sel.Text) 119 | cmd.Stdout = logw 120 | cmd.Stderr = logw 121 | cmd.Dir = h.Dir 122 | cmd.Env = append(h.Environ(), 123 | regexNamesEnvEntry(sel.Matchers), 124 | _targetPaneEnvKey+"="+h.PaneID) 125 | return cmd.Run() 126 | } 127 | 128 | type argAction struct { 129 | Cmd string 130 | Dir string 131 | BeforeArgs, AfterArgs []string 132 | Log *log.Logger 133 | PaneID string 134 | Environ func() []string // == os.Environ 135 | } 136 | 137 | func (h *argAction) Run(sel fastcopy.Selection) (err error) { 138 | logw := &log.Writer{ 139 | Log: h.Log.WithName(h.Cmd), 140 | } 141 | defer multierr.AppendInvoke(&err, multierr.Close(logw)) 142 | 143 | args := make([]string, 0, len(h.BeforeArgs)+len(h.AfterArgs)+1) 144 | args = append(args, h.BeforeArgs...) 145 | args = append(args, sel.Text) 146 | args = append(args, h.AfterArgs...) 147 | 148 | cmd := exec.Command(h.Cmd, args...) 149 | cmd.Stdout = logw 150 | cmd.Stderr = logw 151 | cmd.Dir = h.Dir 152 | cmd.Env = append(h.Environ(), 153 | regexNamesEnvEntry(sel.Matchers), 154 | _targetPaneEnvKey+"="+h.PaneID) 155 | return cmd.Run() 156 | } 157 | -------------------------------------------------------------------------------- /action_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/abhinav/tmux-fastcopy/internal/fastcopy" 9 | "github.com/abhinav/tmux-fastcopy/internal/log" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestNewCommandAction(t *testing.T) { 15 | t.Parallel() 16 | 17 | cwd := "/foo/bar" 18 | 19 | tests := []struct { 20 | desc string 21 | give newActionRequest 22 | 23 | wantArg *argAction 24 | wantStdin *stdinAction 25 | wantErr string 26 | }{ 27 | { 28 | desc: "empty", 29 | give: newActionRequest{Action: ""}, 30 | wantErr: `empty action`, 31 | }, 32 | { 33 | desc: "parse error", 34 | give: newActionRequest{Action: `foo "`}, 35 | wantErr: `invalid command line string`, 36 | }, 37 | { 38 | desc: "stdin", 39 | give: newActionRequest{Action: "pbcopy"}, 40 | wantStdin: &stdinAction{ 41 | Cmd: "pbcopy", 42 | Args: []string{}, 43 | Dir: cwd, 44 | }, 45 | }, 46 | { 47 | desc: "argument", 48 | give: newActionRequest{Action: "tmux set-buffer -- {}"}, 49 | wantArg: &argAction{ 50 | Cmd: "tmux", 51 | BeforeArgs: []string{"set-buffer", "--"}, 52 | AfterArgs: []string{}, 53 | Dir: cwd, 54 | }, 55 | }, 56 | { 57 | desc: "stdin with dir", 58 | give: newActionRequest{ 59 | Action: "pbcopy", 60 | Dir: "/tmp", 61 | }, 62 | wantStdin: &stdinAction{ 63 | Cmd: "pbcopy", 64 | Args: []string{}, 65 | Dir: "/tmp", 66 | }, 67 | }, 68 | { 69 | desc: "argument with dir", 70 | give: newActionRequest{ 71 | Action: "tmux set-buffer -- {}", 72 | Dir: "/tmp", 73 | }, 74 | wantArg: &argAction{ 75 | Cmd: "tmux", 76 | BeforeArgs: []string{"set-buffer", "--"}, 77 | AfterArgs: []string{}, 78 | Dir: "/tmp", 79 | }, 80 | }, 81 | { 82 | desc: "stdin with pane ID", 83 | give: newActionRequest{ 84 | Action: "pbcopy", 85 | TargetPaneID: "123", 86 | }, 87 | wantStdin: &stdinAction{ 88 | Cmd: "pbcopy", 89 | Args: []string{}, 90 | PaneID: "123", 91 | Dir: cwd, 92 | }, 93 | }, 94 | { 95 | desc: "argument with pane ID", 96 | give: newActionRequest{ 97 | Action: "tmux set-buffer -- {}", 98 | TargetPaneID: "123", 99 | }, 100 | wantArg: &argAction{ 101 | Cmd: "tmux", 102 | BeforeArgs: []string{"set-buffer", "--"}, 103 | AfterArgs: []string{}, 104 | PaneID: "123", 105 | Dir: cwd, 106 | }, 107 | }, 108 | } 109 | 110 | for _, tt := range tests { 111 | tt := tt 112 | t.Run(tt.desc, func(t *testing.T) { 113 | t.Parallel() 114 | 115 | got, err := (&actionFactory{ 116 | Getwd: func() (string, error) { 117 | return cwd, nil 118 | }, 119 | }).New(tt.give) 120 | 121 | switch { 122 | case len(tt.wantErr) > 0: 123 | require.Error(t, err) 124 | assert.Contains(t, err.Error(), tt.wantErr) 125 | 126 | case tt.wantArg != nil: 127 | require.NoError(t, err) 128 | assert.Equal(t, tt.wantArg, got) 129 | 130 | case tt.wantStdin != nil: 131 | require.NoError(t, err) 132 | assert.Equal(t, tt.wantStdin, got) 133 | 134 | default: 135 | assert.FailNow(t, "invalid test case") 136 | } 137 | }) 138 | } 139 | } 140 | 141 | func TestNewCommandAction_noCWD(t *testing.T) { 142 | t.Parallel() 143 | 144 | got, err := (&actionFactory{ 145 | Getwd: func() (string, error) { 146 | return "", errors.New("great sadness") 147 | }, 148 | }).New(newActionRequest{Action: "pbcopy"}) 149 | require.NoError(t, err) 150 | 151 | assert.Empty(t, got.(*stdinAction).Dir) 152 | } 153 | 154 | func TestStdinAction(t *testing.T) { 155 | t.Parallel() 156 | 157 | var buff bytes.Buffer 158 | 159 | action := stdinAction{ 160 | Cmd: "cat", 161 | Log: log.New(&buff), 162 | Environ: func() []string { return nil }, 163 | } 164 | require.NoError(t, action.Run(fastcopy.Selection{ 165 | Text: "foo", 166 | Matchers: []string{"x"}, 167 | })) 168 | assert.Equal(t, "[cat] foo\n", buff.String()) 169 | } 170 | 171 | func TestStdinAction_RegexesEnv(t *testing.T) { 172 | t.Parallel() 173 | 174 | var buff bytes.Buffer 175 | 176 | action := stdinAction{ 177 | Cmd: "env", 178 | Log: log.New(&buff), 179 | Environ: func() []string { 180 | return []string{"FOO=bar"} 181 | }, 182 | } 183 | require.NoError(t, action.Run(fastcopy.Selection{ 184 | Text: "foo", 185 | Matchers: []string{"x", "y"}, 186 | })) 187 | assert.Contains(t, buff.String(), "[env] FASTCOPY_REGEX_NAME=x y\n") 188 | assert.Contains(t, buff.String(), "[env] FOO=bar\n") 189 | } 190 | 191 | func TestArgAction(t *testing.T) { 192 | t.Parallel() 193 | 194 | var buff bytes.Buffer 195 | action := argAction{ 196 | Cmd: "echo", 197 | BeforeArgs: []string{"1", "2"}, 198 | AfterArgs: []string{"3", "4"}, 199 | Log: log.New(&buff), 200 | Environ: func() []string { return nil }, 201 | } 202 | require.NoError(t, action.Run(fastcopy.Selection{ 203 | Text: "foo", 204 | Matchers: []string{"x"}, 205 | })) 206 | assert.Equal(t, "[echo] 1 2 foo 3 4\n", buff.String()) 207 | } 208 | 209 | func TestArgAction_RegexesEnv(t *testing.T) { 210 | t.Parallel() 211 | 212 | var buff bytes.Buffer 213 | action := argAction{ 214 | Cmd: "bash", 215 | BeforeArgs: []string{"-c", "env"}, 216 | Log: log.New(&buff), 217 | Environ: func() []string { 218 | return []string{"FOO=bar"} 219 | }, 220 | } 221 | require.NoError(t, action.Run(fastcopy.Selection{ 222 | Text: "foo", 223 | Matchers: []string{"x", "y"}, 224 | })) 225 | assert.Contains(t, buff.String(), "[bash] FASTCOPY_REGEX_NAME=x y\n") 226 | assert.Contains(t, buff.String(), "[bash] FOO=bar\n") 227 | } 228 | -------------------------------------------------------------------------------- /alphabet.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "sort" 8 | ) 9 | 10 | const _defaultAlphabet alphabet = "abcdefghijklmnopqrstuvwxyz" 11 | 12 | type alphabet string 13 | 14 | var _ flag.Value = (*alphabet)(nil) 15 | 16 | func (al *alphabet) String() string { 17 | return string(*al) 18 | } 19 | 20 | func (al *alphabet) Set(alpha string) error { 21 | *al = alphabet(alpha) 22 | return al.Validate() 23 | } 24 | 25 | func (al alphabet) Validate() error { 26 | if len(al) < 2 { 27 | return errors.New("alphabet must have at least two items") 28 | } 29 | 30 | seen := make(map[rune]struct{}, len(al)) 31 | dupes := make(map[rune]struct{}) 32 | for _, r := range al { 33 | if _, ok := seen[r]; ok { 34 | dupes[r] = struct{}{} 35 | } 36 | seen[r] = struct{}{} 37 | } 38 | 39 | if len(dupes) == 0 { 40 | return nil // success 41 | } 42 | 43 | dlist := make([]rune, 0, len(dupes)) 44 | for r := range dupes { 45 | dlist = append(dlist, r) 46 | } 47 | sort.Slice(dlist, func(i, j int) bool { 48 | return dlist[i] < dlist[j] 49 | }) 50 | 51 | return fmt.Errorf("alphabet has duplicates: %q", dlist) 52 | } 53 | -------------------------------------------------------------------------------- /alphabet_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestValidateAlphabet(t *testing.T) { 11 | t.Parallel() 12 | 13 | tests := []struct { 14 | desc string 15 | give string 16 | wantErr string 17 | }{ 18 | { 19 | desc: "empty", 20 | wantErr: "must have at least two items", 21 | }, 22 | { 23 | desc: "single", 24 | give: "a", 25 | wantErr: "must have at least two items", 26 | }, 27 | { 28 | desc: "good", 29 | give: "0123456789", 30 | }, 31 | { 32 | desc: "dupes", 33 | give: "asdffghhjjkl", 34 | wantErr: "alphabet has duplicates: ['f' 'h' 'j']", 35 | }, 36 | } 37 | 38 | for _, tt := range tests { 39 | tt := tt 40 | t.Run(tt.desc, func(t *testing.T) { 41 | t.Parallel() 42 | 43 | var alpha alphabet 44 | err := alpha.Set(tt.give) 45 | if len(tt.wantErr) == 0 { 46 | assert.NoError(t, err) 47 | return 48 | } 49 | 50 | require.Error(t, err) 51 | assert.Contains(t, err.Error(), tt.wantErr) 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/abhinav/tmux-fastcopy/internal/fastcopy" 7 | "github.com/abhinav/tmux-fastcopy/internal/log" 8 | "github.com/abhinav/tmux-fastcopy/internal/tmux" 9 | "github.com/abhinav/tmux-fastcopy/internal/ui" 10 | tcell "github.com/gdamore/tcell/v2" 11 | ) 12 | 13 | // app implements the main fastcopy application logic. It assumes that it's 14 | // running inside a tmux window that it has full control over. (wrapper takes 15 | // care of ensuring that.) 16 | type app struct { 17 | Log *log.Logger 18 | Tmux tmux.Driver 19 | NewAction func(newActionRequest) (action, error) 20 | 21 | NewScreen func() (tcell.Screen, error) // == tcell.NewScreen 22 | } 23 | 24 | // Run runs the application with the provided configuration. 25 | func (app *app) Run(cfg *config) error { 26 | cfg.FillFrom(defaultConfig(cfg)) 27 | 28 | matcher := make(matcher, 0, len(cfg.Regexes)) 29 | for name, reg := range cfg.Regexes { 30 | m, err := compileRegexpMatcher(name, reg) 31 | if err != nil { 32 | return fmt.Errorf("compile regex %q: %v", name, reg) 33 | } 34 | if m != nil { 35 | matcher = append(matcher, m) 36 | } 37 | } 38 | 39 | targetPane, err := tmux.InspectPane(app.Tmux, cfg.Pane) 40 | if err != nil { 41 | return fmt.Errorf("inspect pane %q: %v", cfg.Pane, err) 42 | } 43 | 44 | // Size specification in new-session doesn't always take and causes 45 | // flickers when swapping panes around. Make sure that the window is 46 | // right-sized. 47 | myPane, err := tmux.InspectPane(app.Tmux, "") 48 | if err != nil { 49 | return err 50 | } 51 | 52 | if myPane.Width != targetPane.Width || myPane.Height != targetPane.Height { 53 | resizeReq := tmux.ResizeWindowRequest{ 54 | Window: myPane.WindowID, 55 | Width: targetPane.Width, 56 | Height: targetPane.Height, 57 | } 58 | if err := app.Tmux.ResizeWindow(resizeReq); err != nil { 59 | app.Log.Errorf("unable to resize %q: %v", 60 | myPane.WindowID, err) 61 | // Not the end of the world. Keep going. 62 | } 63 | } 64 | 65 | creq := tmux.CapturePaneRequest{Pane: targetPane.ID} 66 | if targetPane.Mode == tmux.CopyMode { 67 | // If the pane is in copy-mode, the default capture-pane will 68 | // capture the bottom of the screen that would normally be 69 | // visible if not in copy mode. Supply positions to capture for 70 | // that case. 71 | creq.StartLine = -targetPane.ScrollPosition 72 | creq.EndLine = creq.StartLine + targetPane.Height - 1 73 | } 74 | 75 | bs, err := app.Tmux.CapturePane(creq) 76 | if err != nil { 77 | return fmt.Errorf("capture pane %q: %v", cfg.Pane, err) 78 | } 79 | 80 | screen, err := app.NewScreen() 81 | if err != nil { 82 | return err 83 | } 84 | 85 | if err := screen.Init(); err != nil { 86 | return err 87 | } 88 | defer screen.Fini() 89 | 90 | ctrl := ctrl{ 91 | Screen: screen, 92 | Log: app.Log, 93 | Text: string(bs), 94 | Alphabet: []rune(cfg.Alphabet), 95 | Matcher: matcher, 96 | } 97 | ctrl.Init() 98 | 99 | if err := app.Tmux.SwapPane(tmux.SwapPaneRequest{ 100 | Source: targetPane.ID, 101 | Destination: myPane.ID, 102 | }); err != nil { 103 | return err 104 | } 105 | 106 | // If the window was zoomed, zoom the swapped pane as well. In Tmux 3.1 107 | // or newer, we can use the '-Z' flag of swap-pane, but that's not 108 | // available in older versions. 109 | if targetPane.WindowZoomed { 110 | _ = app.Tmux.ResizePane(tmux.ResizePaneRequest{ 111 | Target: myPane.ID, 112 | ToggleZoom: true, 113 | }) 114 | 115 | defer func() { 116 | _ = app.Tmux.ResizePane(tmux.ResizePaneRequest{ 117 | Target: targetPane.ID, 118 | ToggleZoom: true, 119 | }) 120 | }() 121 | } 122 | 123 | defer func() { 124 | _ = app.Tmux.SwapPane(tmux.SwapPaneRequest{ 125 | Destination: targetPane.ID, 126 | Source: myPane.ID, 127 | }) 128 | }() 129 | 130 | selection, err := ctrl.Wait() 131 | if err != nil { 132 | return err 133 | } 134 | 135 | actionStr := cfg.Action 136 | if selection.Shift { 137 | actionStr = cfg.ShiftAction 138 | } 139 | 140 | if len(actionStr) == 0 { 141 | return nil 142 | } 143 | 144 | action, err := app.NewAction(newActionRequest{ 145 | Action: actionStr, 146 | Dir: targetPane.CurrentPath, 147 | TargetPaneID: targetPane.ID, 148 | }) 149 | if err != nil { 150 | return fmt.Errorf("load action %q: %v", actionStr, err) 151 | } 152 | 153 | return action.Run(selection) 154 | } 155 | 156 | type ctrl struct { 157 | Screen tcell.Screen 158 | Log *log.Logger 159 | Alphabet []rune 160 | Text string 161 | Matcher matcher 162 | 163 | w *fastcopy.Widget 164 | ui *ui.App 165 | sel fastcopy.Selection 166 | } 167 | 168 | func (c *ctrl) Init() { 169 | base := tcell.StyleDefault. 170 | Background(tcell.ColorBlack). 171 | Foreground(tcell.ColorWhite) 172 | 173 | c.w = (&fastcopy.WidgetConfig{ 174 | Text: c.Text, 175 | Matches: c.Matcher.Match(c.Text), 176 | Handler: c, 177 | HintAlphabet: c.Alphabet, 178 | Style: fastcopy.Style{ 179 | Normal: base, 180 | Match: base.Foreground(tcell.ColorGreen), 181 | SkippedMatch: base.Foreground(tcell.ColorGray), 182 | HintLabel: base.Foreground(tcell.ColorRed), 183 | HintLabelInput: base.Foreground(tcell.ColorYellow), 184 | SelectedMatch: base.Foreground(tcell.ColorYellow), 185 | DeselectLabel: base.Foreground(tcell.ColorDarkRed), 186 | }, 187 | }).Build() 188 | 189 | c.ui = &ui.App{ 190 | Root: c.w, 191 | Screen: c.Screen, 192 | Log: c.Log, 193 | } 194 | 195 | c.ui.Start() 196 | } 197 | 198 | func (c *ctrl) Wait() (fastcopy.Selection, error) { 199 | err := c.ui.Wait() 200 | return c.sel, err 201 | } 202 | 203 | func (c *ctrl) HandleSelection(sel fastcopy.Selection) { 204 | c.sel = sel 205 | c.ui.Stop() 206 | } 207 | -------------------------------------------------------------------------------- /app_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/abhinav/tmux-fastcopy/internal/log/logtest" 8 | "github.com/abhinav/tmux-fastcopy/internal/tmux/tmuxtest" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "go.uber.org/mock/gomock" 12 | ) 13 | 14 | func TestApp_Run_badRegex(t *testing.T) { 15 | t.Parallel() 16 | 17 | mockCtrl := gomock.NewController(t) 18 | err := (&app{ 19 | Log: logtest.NewLogger(t), 20 | Tmux: tmuxtest.NewMockDriver(mockCtrl), 21 | }).Run(&config{ 22 | Regexes: regexes{ 23 | "foo": "not(a{valid[regex", 24 | }, 25 | }) 26 | require.Error(t, err, "run must fail") 27 | assert.ErrorContains(t, err, `compile regex "foo"`) 28 | } 29 | 30 | func TestApp_Run_inspectPaneError(t *testing.T) { 31 | t.Parallel() 32 | 33 | mockCtrl := gomock.NewController(t) 34 | 35 | tmuxDriver := tmuxtest.NewMockDriver(mockCtrl) 36 | tmuxDriver.EXPECT(). 37 | DisplayMessage(tmuxtest.DisplayMessageRequestMatcher{Pane: "42"}). 38 | Return(nil, errors.New("great sadness")) 39 | 40 | err := (&app{ 41 | Log: logtest.NewLogger(t), 42 | Tmux: tmuxDriver, 43 | }).Run(&config{Pane: "42"}) 44 | require.Error(t, err, "run must fail") 45 | assert.ErrorContains(t, err, "great sadness") 46 | } 47 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | ignore: 3 | - "**/mock_*.go" 4 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "sort" 8 | "strings" 9 | 10 | "github.com/abhinav/tmux-fastcopy/internal/must" 11 | "github.com/abhinav/tmux-fastcopy/internal/tmux/tmuxopt" 12 | ) 13 | 14 | var _defaultRegexes = map[string]string{ 15 | "ipv4": `\b\d{1,3}(?:\.\d{1,3}){3}\b`, 16 | "gitsha": `\b[0-9a-f]{7,40}\b`, 17 | "hexaddr": `\b(?i)0x[0-9a-f]{2,}\b`, 18 | "hexcolor": `(?i)#(?:[0-9a-f]{3}|[0-9a-f]{6})\b`, 19 | "int": `(?:-?|\b)\d{4,}\b`, 20 | "path": `(?:[^\w\-\.~/]|\A)(([\w\-\.]+|~)?(/[\w\-\.]+){2,})\b`, 21 | "uuid": `\b(?i)[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\b`, 22 | "isodate": `\d{4}-\d{2}-\d{2}`, 23 | } 24 | 25 | // regexes is a map from regex name to body. If body is empty, this regex 26 | // should be skipped. 27 | type regexes map[string]string 28 | 29 | func (m *regexes) Put(k, v string) error { 30 | if len(k) == 0 { 31 | return errors.New("regex must have a name") 32 | } 33 | 34 | if *m == nil { 35 | *m = make(map[string]string) 36 | } 37 | (*m)[k] = v 38 | return nil 39 | } 40 | 41 | func (m regexes) Flags() (args []string) { 42 | names := make([]string, 0, len(m)) 43 | for name := range m { 44 | names = append(names, name) 45 | } 46 | sort.Strings(names) 47 | 48 | for _, name := range names { 49 | args = append(args, "-regex", name+":"+m[name]) 50 | } 51 | 52 | return args 53 | } 54 | 55 | func (m regexes) String() string { 56 | return fmt.Sprint(m.Flags()) 57 | } 58 | 59 | func (m *regexes) Set(v string) error { 60 | idx := strings.IndexByte(v, ':') 61 | if idx < 0 { 62 | return errors.New("regex flags must be in the form NAME:REGEX") 63 | } 64 | 65 | return m.Put(v[:idx], v[idx+1:]) 66 | } 67 | 68 | func (m *regexes) FillFrom(o regexes) { 69 | for k, v := range o { 70 | if _, ok := (*m)[k]; !ok { 71 | err := m.Put(k, v) 72 | must.NotErrorf(err, "unexpected invalid key %q", k) 73 | } 74 | } 75 | } 76 | 77 | type config struct { 78 | Pane string 79 | Action string 80 | ShiftAction string 81 | Alphabet alphabet 82 | Verbose bool 83 | Regexes regexes 84 | Tmux string 85 | LogFile string 86 | } 87 | 88 | // Generates a new default configuration. 89 | func defaultConfig(cfg *config) *config { 90 | return &config{ 91 | Action: fmt.Sprintf("%v load-buffer -", cfg.Tmux), 92 | Alphabet: _defaultAlphabet, 93 | Regexes: _defaultRegexes, 94 | } 95 | } 96 | 97 | func (c *config) RegisterFlags(flag *flag.FlagSet) { 98 | // No help here because we put it all in _usage. 99 | flag.StringVar(&c.Pane, "pane", "", "") 100 | flag.StringVar(&c.Action, "action", "", "") 101 | flag.StringVar(&c.ShiftAction, "shift-action", "", "") 102 | flag.Var(&c.Alphabet, "alphabet", "") 103 | flag.Var(&c.Regexes, "regex", "") 104 | flag.BoolVar(&c.Verbose, "verbose", false, "") 105 | flag.StringVar(&c.LogFile, "log", "", "") 106 | flag.StringVar(&c.Tmux, "tmux", "tmux", "") 107 | } 108 | 109 | func (c *config) RegisterOptions(load *tmuxopt.Loader) { 110 | load.StringVar(&c.Action, "@fastcopy-action") 111 | load.StringVar(&c.ShiftAction, "@fastcopy-shift-action") 112 | load.Var(&c.Alphabet, "@fastcopy-alphabet") 113 | load.MapVar(&c.Regexes, "@fastcopy-regex-") 114 | } 115 | 116 | // FillFrom updates this config object, filling empty values with values from 117 | // the provided struct but not overwriting those that are already set. 118 | func (c *config) FillFrom(o *config) { 119 | if len(c.Pane) == 0 { 120 | c.Pane = o.Pane 121 | } 122 | if len(c.Action) == 0 { 123 | c.Action = o.Action 124 | } 125 | if len(c.ShiftAction) == 0 { 126 | c.ShiftAction = o.ShiftAction 127 | } 128 | if len(c.Alphabet) == 0 { 129 | c.Alphabet = o.Alphabet 130 | } 131 | if len(c.LogFile) == 0 { 132 | c.LogFile = o.LogFile 133 | } 134 | if len(c.Tmux) == 0 { 135 | c.Tmux = o.Tmux 136 | } 137 | c.Regexes.FillFrom(o.Regexes) 138 | c.Verbose = c.Verbose || o.Verbose 139 | } 140 | 141 | // Flags rebuilds a list of arguments from which this configuration may be 142 | // parsed. 143 | func (c *config) Flags() []string { 144 | var args []string 145 | if len(c.Pane) > 0 { 146 | args = append(args, "-pane", c.Pane) 147 | } 148 | if len(c.Action) > 0 { 149 | args = append(args, "-action", c.Action) 150 | } 151 | if len(c.ShiftAction) > 0 { 152 | args = append(args, "-shift-action", c.ShiftAction) 153 | } 154 | if len(c.Alphabet) > 0 { 155 | args = append(args, "-alphabet", c.Alphabet.String()) 156 | } 157 | args = append(args, c.Regexes.Flags()...) 158 | if c.Verbose { 159 | args = append(args, "-verbose") 160 | } 161 | if len(c.LogFile) > 0 { 162 | args = append(args, "-log", c.LogFile) 163 | } 164 | if len(c.Tmux) > 0 { 165 | args = append(args, "-tmux", c.Tmux) 166 | } 167 | return args 168 | } 169 | -------------------------------------------------------------------------------- /doc/README.md: -------------------------------------------------------------------------------- 1 | # tmux-fastcopy 2 | 3 | - [Introduction](intro.md) 4 | - [Installation](install.md) 5 | - [Usage](usage.md) 6 | - [Multiple selections](multi-select.md) 7 | - Options 8 | - [`@fastcopy-key`](opt-key.md) 9 | - [`@fastcopy-action`](opt-action.md) 10 | - [`@fastcopy-shift-action`](opt-shift-action.md) 11 | - [`@fastcopy-alphabet`](opt-alphabet.md) 12 | - [`@fastcopy-regex-*`](opt-regex.md) 13 | - [Regex names](regex-names.md) 14 | - How to 15 | - [Access the regex name](howto-regex-name.md) 16 | - [Copy text to the clipboard](howto-clipboard.md) 17 | - [Select text without copying](howto-select.md) 18 | - [FAQ](faq.md) 19 | - [Credits](credits.md) 20 | - [Similar projects](similar.md) 21 | - [License](license.md) 22 | -------------------------------------------------------------------------------- /doc/credits.md: -------------------------------------------------------------------------------- 1 | # Credits 2 | 3 | The plugin is inspired by functionality provided by the [Vimium][] and 4 | [Vimperator][] Chrome and Firefox plugins. 5 | 6 | [Vimium]: https://vimium.github.io/ 7 | [Vimperator]: http://vimperator.org/vimperator 8 | -------------------------------------------------------------------------------- /doc/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## What's the `\b` at the ends of some regexes? 4 | 5 | The `\b` at either end of the regular expression above specifies that it must 6 | start and/or end at a word boundary. A word boundary is the start or end of a 7 | line, or a non-alphanumeric character. 8 | 9 | For example, the regular expression `\bgit\b` will match the string `git` 10 | inside `git rebase --continue` and `git-rebase`, but not inside `github` 11 | because the "h" following the "git" is not a word boundary. 12 | 13 | ## The entire string did not get copied 14 | 15 | If your regular expression uses capturing groups `(...)`, tmux-fastcopy will 16 | only copy the first of these from the matched string. 17 | 18 | In the regex below, only the strings "continue" or "abort" will be copied. 19 | 20 | set-option -g @fastcopy-regex-git-rebase "git rebase --(continue|abort)" 21 | 22 | To copy the entire string, you can put the whole string in a capturing group, 23 | making it the first capturing group. 24 | 25 | set-option -g @fastcopy-regex-git-rebase "(git rebase --(continue|abort))" 26 | 27 | Or you can mark the `(continue|abort)` group as ignored by starting it with 28 | `?:`. 29 | 30 | set-option -g @fastcopy-regex-git-rebase "git rebase --(?:continue|abort)" 31 | 32 | ## Are regular expressions case sensitive? 33 | 34 | Yes, the regular expressions matched by tmux-fastcopy are case sensitive. For 35 | example, 36 | 37 | set-option -g @fastcopy-regex-github-project "github.com/(\w+/\w+)" 38 | 39 | This will match `github.com/abhinav/tmux-fastcopy` but not 40 | `GitHub.com/abhinav/tux-fastcopy`. 41 | 42 | If you want to turn your regular expression case insensitive, prefix it with 43 | `(?i)`. 44 | 45 | set-option -g @fastcopy-regex-github-project "(?i)github.com/(\w+/\w+)" 46 | 47 | ## How to overwrite or remove default regexes? 48 | 49 | To overwrite or remove default regular expressions, add a new regex to your 50 | `tmux.conf` with the same name as the default one, using a blank string as the 51 | value to delete it. 52 | 53 | For example, the following deletes the `isodate` regular expression. 54 | 55 | set-option -g @fastcopy-regex-isodate "" 56 | 57 | ## Can I have different actions for different regexes? 58 | 59 | The `FASTCOPY_REGEX_NAME` environment variable holds the name of the regex that 60 | matched your selection. 61 | You can run different actions on a per-regex basis by inspecting the 62 | `FASTCOPY_REGEX_NAME` environment variable in your 63 | [`@fastcopy-action`](opt-action.md). 64 | 65 | See [Accessing the regex name](howto-regex-name.md) for more details. 66 | -------------------------------------------------------------------------------- /doc/howto-clipboard.md: -------------------------------------------------------------------------------- 1 | # Copy text to the clipboard? 2 | 3 | To copy text to your system clipboard, you can use tmux's `set-clipboard` 4 | option and change the action to `tmux load-buffer -w -` if you're using 5 | at least tmux 3.2. 6 | 7 | set-option -g set-clipboard on 8 | set-option -g @fastcopy-action 'tmux load-buffer -w -' 9 | 10 | With this option set, and the `-w` flag for `load-buffer`, tmux will use the 11 | OSC52 escape sequence to directly set the clipboard for your terminal 12 | emulator--it should work even through an SSH session. Check out 13 | [A guide on how to copy text from anywhere][osc52] to read more about OSC52. 14 | 15 | [osc52]: https://old.reddit.com/r/vim/comments/k1ydpn/a_guide_on_how_to_copy_text_from_anywhere/ 16 | 17 | If you're using an older version of tmux or your terminal emulator does not 18 | support OSC52, you can configure `@fastcopy-action` to have tmux-fastcopy 19 | send the text elsewhere. For example, 20 | 21 | # On macOS: 22 | set-option -g @fastcopy-action pbcopy 23 | 24 | # For Linux systems using X11, install [xclip] and use: 25 | # 26 | # [xclip]: https://github.com/astrand/xclip 27 | set-option -g @fastcopy-action 'xclip -selection clipboard' 28 | 29 | # For Linux systems using Wayland, install [wl-clipboard] and use: 30 | # 31 | # [wl-clipboard]: https://github.com/bugaevc/wl-clipboard 32 | set-option -g @fastcopy-action wl-copy 33 | -------------------------------------------------------------------------------- /doc/howto-regex-name.md: -------------------------------------------------------------------------------- 1 | # Access the regex name 2 | 3 | tmux-fastcopy executes the action with the `FASTCOPY_REGEX_NAME` environment 4 | variable set. This holds the [name of the regex](regex-names.md) that matched the 5 | selected string. 6 | If multiple different regexes matched the string, `FASTCOPY_REGEX_NAME` holds a 7 | space-separated list of them. 8 | 9 | You can use this to customize the action on a per-regex basis. 10 | 11 | For example, the following will copy most strings to the tmux buffer as usual. 12 | However, if the string is matched by the "path" regular expression and it 13 | represents an existing directory, this will open that directory in the file 14 | browser. 15 | 16 | ```bash 17 | #!/usr/bin/env bash 18 | 19 | # Place this inside a file like "fastcopy.sh", 20 | # mark it executable (chmod +x fastcopy.sh), 21 | # and set the @fastcopy-action setting to: 22 | # '/path/to/fastcopy.sh {}' 23 | 24 | if [ "$FASTCOPY_REGEX_NAME" == path ] && [ -d "$1" ]; then 25 | xdg-open "$1" # on macOS, use "open" instead 26 | exit 0 27 | fi 28 | 29 | tmux set-buffer -w "$1" 30 | ``` 31 | -------------------------------------------------------------------------------- /doc/howto-select.md: -------------------------------------------------------------------------------- 1 | # Select text without copying 2 | 3 | If you'd like to select the matched text rather than copy in, 4 | you can define an action that takes the target pane in copy mode, 5 | and moves your cursor over to the matched text. 6 | 7 | The following script should suffice for this: 8 | 9 | ```bash 10 | #!/usr/bin/env bash 11 | 12 | MATCH_TEXT="$1" 13 | PANE_ID="$FASTCOPY_TARGET_PANE_ID" 14 | 15 | tmux \ 16 | copy-mode -t "$PANE_ID" ';' \ 17 | send-keys -t "$PANE_ID" -X search-backward-text "$MATCH_TEXT" ';' \ 18 | send-keys -t "$PANE_ID" -X begin-selection ';' \ 19 | send-keys -t "$PANE_ID" -X -N "$((${#MATCH_TEXT} - 1))" cursor-right ';' \ 20 | send-keys -t "$PANE_ID" -X end-selection 21 | ``` 22 | 23 |
24 | Explanation 25 | 26 | The script above expects the matched text as an argument, 27 | and grabs the target pane ID from the environment. 28 | tmux-fastcopy sets `FASTCOPY_TARGET_PANE_ID` when running the action 29 | (see [Execution context](opt-action.md#execution-context)). 30 | 31 | It then runs the following tmux commands on the pane: 32 | 33 | - switch it to copy mode 34 | - search for the closest recent instance of the matched text 35 | and move your cursor there 36 | - begin a selection 37 | - move the cursor to the end of the selected text 38 | - end the selection 39 | 40 | The end result of this is that when the action runs, 41 | your cursor will have selected the matched text 42 | leaving you room to adjust the selection before copying. 43 | 44 |
45 | 46 | Place this script in a location of your choice, say, `~/.tmux/select.sh` 47 | and mark it as an executable: 48 | 49 | ```bash 50 | chmod +x ~/.tmux/select.sh 51 | ``` 52 | 53 | Then add the following to your `~/tmux.conf`. 54 | 55 | ```tmux 56 | set -g @fastcopy-action "~/.tmux/select.sh {}" 57 | ``` 58 | 59 | Or add the following if you want to do this 60 | only when you press shift along with the label 61 | (see [`@fastcopy-shift-action`](opt-shift-action.md)). 62 | 63 | ```tmux 64 | set -g @fastcopy-shift-action "~/.tmux/select.sh {}" 65 | ``` 66 | -------------------------------------------------------------------------------- /doc/install.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Before you install, make sure you are running a supported version of tmux. 4 | 5 | ``` 6 | $ tmux -V 7 | ``` 8 | 9 | Minimum supported version: 2.7. 10 | 11 | The following methods of installation are available: 12 | 13 | - via [Tmux Plugin Manager](#tmux-plugin-manager) 14 | - [Manual installation](#manual-installation) 15 | - [Binary installation](#binary-installation) 16 | 17 | ## Tmux Plugin Manager 18 | 19 | **Prerequisite**: To use this method, you must have a Go compiler available on 20 | your system. 21 | 22 | If you're using [Tmux Plugin Manager](https://github.com/tmux-plugins/tpm), to 23 | install, add tmux-fastcopy to the plugin list in your `.tmux.conf`: 24 | 25 | set -g @plugin 'abhinav/tmux-fastcopy' 26 | 27 | Hit ` + I` to fetch and build it. 28 | 29 | ## Manual installation 30 | 31 | **Prerequisite**: To use this method, you must have a Go compiler available on 32 | your system. 33 | 34 | Clone the repository somewhere on your system: 35 | 36 | git clone https://github.com/abhinav/tmux-fastcopy ~/.tmux/plugins/tmux-fastcopy 37 | 38 | Source it in your `.tmux.conf`. 39 | 40 | run-shell ~/.tmux/plugins/tmux-fastcopy/fastcopy.tmux 41 | 42 | Refresh your tmux server if it's already running. 43 | 44 | tmux source-file ~/.tmux.conf 45 | 46 | ## Binary installation 47 | 48 | Instead of installing tmux-fastcopy as a tmux plugin, 49 | you can install it as an independent binary. 50 | 51 | Use one of the following to install the binary. 52 | 53 | - If you're using **Homebrew**/Linuxbrew, run: 54 | 55 | ```bash 56 | brew install abhinav/tap/tmux-fastcopy 57 | ``` 58 | 59 | - If you're using **ArchLinux**, install it from AUR using the [tmux-fastcopy](https://aur.archlinux.org/packages/tmux-fastcopy/) package, 60 | or the [tmux-fastcopy-bin](https://aur.archlinux.org/packages/tmux-fastcopy-bin/) package if you don't want to build it from source. 61 | 62 | ```bash 63 | git clone https://aur.archlinux.org/tmux-fastcopy.git 64 | cd tmux-fastcopy 65 | makepkg -si 66 | ``` 67 | 68 | With an AUR helper like [yay](https://github.com/Jguer/yay), run: 69 | 70 | ```bash 71 | yay -S tmux-fastcopy 72 | # or 73 | yay -S tmux-fastcopy-bin 74 | ``` 75 | 76 | - Download a **pre-built binary** from the [releases page](https://github.com/abhinav/tmux-fastcopy/releases) 77 | and place it on your `$PATH`. 78 | 79 | - Build it from source with Go. 80 | 81 | ```bash 82 | go install github.com/abhinav/tmux-fastcopy@latest 83 | ``` 84 | 85 | Once you have the binary installed, add the following to your `.tmux.conf`. 86 | 87 | ``` 88 | bind-key f run-shell -b tmux-fastcopy 89 | ``` 90 | -------------------------------------------------------------------------------- /doc/intro.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | [![Go](https://github.com/abhinav/tmux-fastcopy/actions/workflows/ci.yml/badge.svg)](https://github.com/abhinav/tmux-fastcopy/actions/workflows/ci.yml) 6 | [![codecov](https://codecov.io/gh/abhinav/tmux-fastcopy/branch/main/graph/badge.svg?token=WJGAZQH4PI)](https://codecov.io/gh/abhinav/tmux-fastcopy) 7 | 8 |
9 | 10 | tmux-fastcopy aids in copying of text in a tmux pane with ease. 11 | 12 | **How?** When you invoke tmux-fastcopy, it inspects your tmux pane and overlays 13 | important pieces of text you may want to copy with very short labels that you 14 | can use to copy them. 15 | 16 | **Demos**: A gif is worth a paragraph or two. 17 | 18 |
19 | Git hashes 20 | 21 | ![git hashes demo](./static/gitlog.gif) 22 |
23 | 24 |
25 | File paths 26 | 27 | ![file paths demo](./static/files.gif) 28 |
29 | 30 |
31 | IP addresses 32 | 33 | ![IP addresses demo](./static/ip.gif) 34 |
35 | 36 |
37 | UUIDs 38 | 39 | ![UUIDs demo](./static/uuid.gif) 40 |
41 | 42 | -------------------------------------------------------------------------------- /doc/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | This software is distributed under the GPL-2.0 License: 4 | 5 | tmux-fastcopy 6 | Copyright (C) 2023 Abhinav Gupta 7 | 8 | This program is free software; you can redistribute it and/or modify 9 | it under the terms of the GNU General Public License as published by 10 | the Free Software Foundation; either version 2 of the License, or 11 | (at your option) any later version. 12 | 13 | This program is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | GNU General Public License for more details. 17 | 18 | You should have received a copy of the GNU General Public License along 19 | with this program; if not, write to the Free Software Foundation, Inc., 20 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 21 | 22 | The LICENSE file holds the full text of the GPL-2.0 license. 23 | -------------------------------------------------------------------------------- /doc/multi-select.md: -------------------------------------------------------------------------------- 1 | # Multiple selections 2 | 3 | tmux-fastcopy also supports a multi-selection mode. 4 | To select multiple items: 5 | 6 | 1. Press ` + f` to invoke tmux-fastcopy as usual. 7 | 2. Press `Tab`. This enters multi-selection mode. 8 | 3. Enter all the labels for text you want to copy. 9 | If you selected something accidentally, 10 | enter that label again to deselect it. 11 | 4. Press `Tab` or `Enter` to accept your selections. 12 | 13 | tmux-fastcopy will join your selections together and copy the result. 14 | -------------------------------------------------------------------------------- /doc/opt-action.md: -------------------------------------------------------------------------------- 1 | # `@fastcopy-action` 2 | 3 | Change how text is copied with this action. 4 | 5 | **Default**: 6 | 7 | set-option -g @fastcopy-action 'tmux load-buffer -' 8 | 9 | The string specifies the command to run with the selection, as well as the 10 | arguments for the command. The special argument `{}` acts as a placeholder for 11 | the selected text. 12 | 13 | set-option -g @fastcopy-action 'tmux set-buffer {}' 14 | 15 | If `{}` is absent from the command, tmux-fastcopy will pass the selected text 16 | to the command over stdin. For example, 17 | 18 | set-option -g @fastcopy-action pbcopy # for macOS 19 | 20 | Note that if the command string uses `{}`, 21 | the selected text is *not* passed via stdin. 22 | 23 | ## Execution context 24 | 25 | The command string is executed directly by tmux-fastcopy, 26 | so it must be a path to a binary or shell script that is executable. 27 | It is not executed in the context of a full login shell. 28 | 29 | The command runs inside the directory of the pane 30 | where tmux-fastcopy was invoked if this information is available from tmux. 31 | It runs with the following environment variables set: 32 | 33 | - `FASTCOPY_REGEX_NAME`: 34 | Name of `@fastcopy-regex` rule that matched. 35 | See [Regex names](regex-names.md) and [Accessing the regex name](howto-regex-name.md) 36 | for more information. 37 | - `FASTCOPY_TARGET_PANE_ID`: 38 | Unique identifier for the pane inside which fastcopy was invoked. 39 | Use this when running tmux operations inside the action 40 | to target them to that pane. 41 | -------------------------------------------------------------------------------- /doc/opt-alphabet.md: -------------------------------------------------------------------------------- 1 | # `@fastcopy-alphabet` 2 | 3 | Specify the letters used to generate labels for matched text. 4 | 5 | **Default**: 6 | 7 | set-option -g @fastcopy-alphabet abcdefghijklmnopqrstuvwxyz 8 | 9 | This must be a string containing at least two letters, and all of them must be 10 | unique. 11 | 12 | For example, if you want to only use the letters from the QWERTY home row, use 13 | the following. 14 | 15 | set-option -g @fastcopy-alphabet asdfghjkl 16 | -------------------------------------------------------------------------------- /doc/opt-key.md: -------------------------------------------------------------------------------- 1 | # `@fastcopy-key` 2 | 3 | Invoke tmux-fastcopy in tmux with this the `prefix` followed by this key. 4 | 5 | **Default**: 6 | 7 | set-option -g @fastcopy-key f 8 | -------------------------------------------------------------------------------- /doc/opt-regex.md: -------------------------------------------------------------------------------- 1 | # `@fastcopy-regex-*` 2 | 3 | These specify the regular expressions used to match text. 4 | 5 | **Default**: 6 | 7 | set-option -g @fastcopy-regex-ipv4 "\\b\\d{1,3}(?:\\.\\d{1,3}){3}\\b" 8 | set-option -g @fastcopy-regex-gitsha "\\b[0-9a-f]{7,40}\\b" 9 | set-option -g @fastcopy-regex-hexaddr "\\b(?i)0x[0-9a-f]{2,}\\b" 10 | set-option -g @fastcopy-regex-hexcolor "(?i)#(?:[0-9a-f]{3}|[0-9a-f]{6})\\b" 11 | set-option -g @fastcopy-regex-int "(?:-?|\\b)\\d{4,}\\b" 12 | set-option -g @fastcopy-regex-path "(?:[\\w\\-\\.]+|~)?(?:/[\\w\\-\\.]+){2,}\\b" 13 | set-option -g @fastcopy-regex-uuid "\\b(?i)[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\\b" 14 | set-option -g @fastcopy-regex-isodate "\\d{4}-\\d{2}-\\d{2}" 15 | 16 | Add new regular expressions by introducing new options with the prefix, 17 | `@fastcopy-regex-`. For example, the following will match Phabricator revision 18 | IDs if they're at least three letters long. 19 | 20 | set-option -g @fastcopy-regex-phab-diff "\\bD\\d{3,}\\b" 21 | 22 | **Note**: You must double all `\` symbols inside regular expressions to 23 | escape them properly. 24 | 25 | 31 | 32 | ## Copying substrings 33 | 34 | Use regex capturing groups if you wish to copy only a portion of the matched 35 | string. tmux-fastcopy will copy the contents of the first capturing group. For 36 | example, 37 | 38 | set-option -g @fastcopy-regex-python-import "import ([\\w\\.]+)" 39 | # From "import os.path", copy only "os.path" 40 | 41 | This also means that to use `(...)` in regular expressions that should copy the 42 | whole string, you should add the `?:` prefix to the start of the capturing 43 | group to ignore it. For example, 44 | 45 | # Matches commands suggested by 'git status' 46 | set-option -g @fastcopy-regex-git-rebase "git rebase --(?:continue|abort)" 47 | -------------------------------------------------------------------------------- /doc/opt-shift-action.md: -------------------------------------------------------------------------------- 1 | # `@fastcopy-shift-action` 2 | 3 | An alternative action when you select a label while pressing shift. 4 | Nothing happens if this is unset. 5 | 6 | **Default**: 7 | 8 | set-option -g @fastcopy-shift-action '' 9 | 10 | Similarly to [`@fastcopy-action`], the string specifies a command and its 11 | arguments, and the special argument `{}` (if any) is a placeholder for the 12 | selected text. 13 | 14 | set-option -g @fastcopy-shift-action "fastcopy-shift.sh {}" 15 | 16 | The `@fastcopy-shift-action` will run with the same 17 | [execution context](opt-action.md#execution-context) 18 | as the `@fastcopy-action`. 19 | -------------------------------------------------------------------------------- /doc/regex-names.md: -------------------------------------------------------------------------------- 1 | # Regex names 2 | 3 | The portion after the `@fastcopy-regex-` can be any name that uniquely 4 | identifies this regular expression. 5 | 6 | For example, the name of this regular expression is `phab-diff` 7 | 8 | set-option -g @fastcopy-regex-phab-diff "\\bD\\d{3,}\\b" 9 | 10 | You cannot have multiple regular expressions with the same name. New regular 11 | expressions with previously used names will overwrite them. For example, this 12 | overwrites the default `hexcolor` regular expression to copy only the color 13 | code, skipping the preceding `#`: 14 | 15 | set-option -g @fastcopy-regex-hexcolor "(?i)#([0-9a-f]{3}|[0-9a-f]{6})\\b" 16 | 17 | You can delete previously defined or default regular expressions by setting 18 | them to a blank string. 19 | 20 | set-option -g @fastcopy-regex-isodate "" 21 | 22 | The name of the regular expression that matched the selection is available to 23 | the [`@fastcopy-action`](opt-action.md) via the `FASTCOPY_REGEX_NAME` environment variable. 24 | See [Accessing the regex name](howto-regex-name.md) for more details. 25 | -------------------------------------------------------------------------------- /doc/similar.md: -------------------------------------------------------------------------------- 1 | # Similar Projects 2 | 3 | - [CrispyConductor/tmux-copy-toolkit](https://github.com/CrispyConductor/tmux-copy-toolkit) 4 | - [fcsonline/tmux-thumbs](https://github.com/fcsonline/tmux-thumbs) 5 | - [Morantron/tmux-fingers](https://github.com/Morantron/tmux-fingers) 6 | 7 | -------------------------------------------------------------------------------- /doc/static/README.md: -------------------------------------------------------------------------------- 1 | The logo for the project was designed by [@abhinav](https://github.com/abhinav/) 2 | and is made available under the [Creative Commons 4.0 Attribution License](https://creativecommons.org/licenses/by/4.0/). 3 | -------------------------------------------------------------------------------- /doc/static/files.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhinav/tmux-fastcopy/fa4748b6893d4a862b2aafa7a643fb57e5517a40/doc/static/files.gif -------------------------------------------------------------------------------- /doc/static/gitlog.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhinav/tmux-fastcopy/fa4748b6893d4a862b2aafa7a643fb57e5517a40/doc/static/gitlog.gif -------------------------------------------------------------------------------- /doc/static/ip.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhinav/tmux-fastcopy/fa4748b6893d4a862b2aafa7a643fb57e5517a40/doc/static/ip.gif -------------------------------------------------------------------------------- /doc/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhinav/tmux-fastcopy/fa4748b6893d4a862b2aafa7a643fb57e5517a40/doc/static/logo.png -------------------------------------------------------------------------------- /doc/static/uuid.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhinav/tmux-fastcopy/fa4748b6893d4a862b2aafa7a643fb57e5517a40/doc/static/uuid.gif -------------------------------------------------------------------------------- /doc/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | When there is text on the screen you'd like to copy: 4 | 5 | 1. Press ` + f` to invoke tmux-fastcopy. (You can change this key by 6 | setting the [`@fastcopy-key`](opt-key.md) option.) 7 | 2. Enter the label next to the highlighted text to copy that text. 8 | (You can also [select multiple items](multi-select.md).) 9 | 10 | For example, 11 | 12 | ![IP addresses demo](./static/ip.gif) 13 | 14 | By default, the copied text will be placed in your tmux buffer. Paste it by 15 | pressing ` + ]`. 16 | 17 | If you'd like to copy the text to your system clipboard, and you're using 18 | tmux >= 3.2, add the following to your .tmux.conf: 19 | 20 | set-option -g set-clipboard on 21 | set-option -g @fastcopy-action 'tmux load-buffer -w -' 22 | 23 | See [How to copy text to the clipboard?](howto-clipboard.md) for older versions of 24 | tmux. 25 | -------------------------------------------------------------------------------- /fastcopy.tmux: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | FASTCOPY_KEY="$(tmux show-option -gqv @fastcopy-key)" 4 | if [[ -z "$FASTCOPY_KEY" ]]; then 5 | FASTCOPY_KEY=f # default 6 | fi 7 | 8 | FASTCOPY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 9 | FASTCOPY_EXE="$FASTCOPY_ROOT/bin/tmux-fastcopy" 10 | if [ ! -x "$FASTCOPY_EXE" ]; then 11 | if command -v tmux-fastcopy >/dev/null; then 12 | # Fall back to a globally installed version if available. 13 | FASTCOPY_EXE=tmux-fastcopy 14 | else 15 | tmux display-message 'Installing tmux-fastcopy locally...' 16 | tmux split-window -c "$FASTCOPY_ROOT" "$FASTCOPY_ROOT/install.sh" 17 | fi 18 | fi 19 | 20 | tmux bind-key "$FASTCOPY_KEY" run-shell -b "$FASTCOPY_EXE" 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/abhinav/tmux-fastcopy 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/benbjohnson/clock v1.3.5 7 | github.com/gdamore/tcell/v2 v2.8.1 8 | github.com/mattn/go-runewidth v0.0.16 9 | github.com/mattn/go-shellwords v1.0.12 10 | github.com/rivo/uniseg v0.4.7 11 | github.com/stretchr/testify v1.10.0 12 | go.abhg.dev/algorithm/huffman v0.2.0 13 | go.abhg.dev/io/ioutil v0.1.0 14 | go.uber.org/mock v0.5.2 15 | go.uber.org/multierr v1.11.0 16 | pgregory.net/rapid v1.2.0 17 | ) 18 | 19 | require ( 20 | github.com/davecgh/go-spew v1.1.1 // indirect 21 | github.com/gdamore/encoding v1.0.1 // indirect 22 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 23 | github.com/pmezard/go-difflib v1.0.0 // indirect 24 | golang.org/x/mod v0.18.0 // indirect 25 | golang.org/x/sync v0.10.0 // indirect 26 | golang.org/x/sys v0.29.0 // indirect 27 | golang.org/x/term v0.28.0 // indirect 28 | golang.org/x/text v0.21.0 // indirect 29 | golang.org/x/tools v0.22.0 // indirect 30 | gopkg.in/yaml.v3 v3.0.1 // indirect 31 | ) 32 | 33 | tool go.uber.org/mock/mockgen 34 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | # Places a copy of tmux-fastcopy at bin/tmux-fastcopy. 6 | 7 | # Invoke with "-c $VERSION" to check if this would install "$VERSION". 8 | 9 | IMPORTPATH=github.com/abhinav/tmux-fastcopy 10 | NAME=tmux-fastcopy 11 | VERSION=0.14.1 12 | 13 | while getopts 'c:' opt; do 14 | case "$opt" in 15 | c) 16 | if [[ "$VERSION" == "$OPTARG" ]]; then 17 | echo >&2 "Versions match!" 18 | exit 0 19 | fi 20 | echo >&2 "Version mismatch:" 21 | echo >&2 " want: $VERSION" 22 | echo >&2 " got: $OPTARG" 23 | exit 1 24 | ;; 25 | '?') 26 | exit 1 27 | esac 28 | done 29 | shift "$((OPTIND-1))" 30 | 31 | OS=$(uname -s) 32 | ARCH=$(uname -m) 33 | 34 | PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 35 | BINDIR="$PROJECT_ROOT/bin" 36 | EXE="$BINDIR/$NAME" 37 | 38 | DOWNLOADS_URL="https://$IMPORTPATH/releases/download" 39 | 40 | # Build from source if go is available. 41 | # No arguments. 42 | try_build() { 43 | command -v go >/dev/null && 44 | echo >&2 "Building from source..." && 45 | (cd "$PROJECT_ROOT" && go build -o "$EXE") 46 | } 47 | 48 | # Downoad with curl. Takes the URL as an argument. 49 | try_curl() { 50 | command -v curl >/dev/null && 51 | echo >&2 "Downloading with curl..." && 52 | curl -L -o >(tar -xvz -C "$BINDIR" "$NAME") "$1" 53 | } 54 | 55 | # Download with wget. Takes the URL as an argument. 56 | try_wget() { 57 | command -v wget >/dev/null && 58 | echo >&2 "Downloading with wget..." && 59 | wget -O >(tar -xvz -C "$BINDIR" "$NAME") "$1" 60 | } 61 | 62 | # Downloads a pre-built binary. 63 | try_download() { 64 | tarball="${NAME}_${VERSION}_${OS}_${ARCH}.tar.gz" 65 | url="$DOWNLOADS_URL/v${VERSION}/$tarball" 66 | 67 | mkdir -p "$BINDIR" 68 | if (try_curl "$url") || (try_wget "$url"); then 69 | chmod +x "$EXE" 70 | fi 71 | } 72 | 73 | if ! (try_build || try_download); then 74 | echo >&2 "Unable to build or download $NAME." 75 | echo >&2 "This means," 76 | echo >&2 " 1. You do not have Go installed; and" 77 | echo >&2 " 2. You are using an OS, architecutre, or version for which we" 78 | echo >&2 " do not distribute pre-built binaries." 79 | echo >&2 "Please resolve one of these issues and try again." 80 | echo >&2 81 | echo >&2 "Press any key to continue:" 82 | read -rk1 83 | exit 1 84 | fi 85 | -------------------------------------------------------------------------------- /integration/doc.go: -------------------------------------------------------------------------------- 1 | // Package integration holds integration tests for tmux-fastcopy. 2 | // These run against a real tmux server, and are not run by default. 3 | package integration 4 | -------------------------------------------------------------------------------- /integration/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/abhinav/tmux-fastcopy/integration 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/abhinav/tmux-fastcopy v0.14.1 7 | github.com/creack/pty v1.1.24 8 | github.com/stretchr/testify v1.10.0 9 | go.abhg.dev/io/ioutil v0.1.0 10 | go.uber.org/multierr v1.11.0 11 | ) 12 | 13 | require ( 14 | github.com/davecgh/go-spew v1.1.1 // indirect 15 | github.com/pmezard/go-difflib v1.0.0 // indirect 16 | gopkg.in/yaml.v3 v3.0.1 // indirect 17 | ) 18 | 19 | replace github.com/abhinav/tmux-fastcopy => ../ 20 | -------------------------------------------------------------------------------- /integration/go.sum: -------------------------------------------------------------------------------- 1 | github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 2 | github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 8 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 9 | go.abhg.dev/io/ioutil v0.1.0 h1:YGGMzh9HT52JYuVWbnr/E5GkYHbL3yRNDjcxFDaUHNk= 10 | go.abhg.dev/io/ioutil v0.1.0/go.mod h1:79IIyZVWxNZE8hBxMtubM8zJFPs+2NCqHYfWeH3X6hM= 11 | go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= 12 | go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= 13 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 14 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 18 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 19 | -------------------------------------------------------------------------------- /integration/unquote_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/abhinav/tmux-fastcopy/internal/tmux/tmuxopt" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | "go.abhg.dev/io/ioutil" 15 | ) 16 | 17 | func TestUnquoteTmuxOptions(t *testing.T) { 18 | t.Parallel() 19 | 20 | tmuxExe, err := exec.LookPath("tmux") 21 | if err != nil { 22 | t.Skip("tmux not found in PATH") 23 | } 24 | 25 | root := mkdirTempGlobal(t, "tmux-fastcopy-unquote-test") 26 | 27 | home := filepath.Join(root, "home") 28 | require.NoError(t, os.Mkdir(home, 0o755)) 29 | 30 | tmpDir := filepath.Join(root, "tmp") 31 | require.NoError(t, os.Mkdir(tmpDir, 0o755)) 32 | 33 | cfgFile := filepath.Join(home, ".tmux.conf") 34 | require.NoError(t, os.WriteFile(cfgFile, []byte(`set -g exit-empty off`), 0o644)) 35 | 36 | env := []string{ 37 | "HOME=" + home, 38 | "TERM=screen", 39 | "SHELL=/bin/sh", 40 | "TMUX_TMPDIR=" + tmpDir, 41 | } 42 | cmdout := ioutil.TestLogWriter(t, "") 43 | 44 | cmd := exec.Command(tmuxExe, "start-server", ";", "new-session", "-d") 45 | cmd.Dir = root 46 | cmd.Env = env 47 | cmd.Stdout = cmdout 48 | cmd.Stderr = cmdout 49 | require.NoError(t, cmd.Run()) 50 | t.Cleanup(func() { 51 | cmd := exec.Command(tmuxExe, "kill-server") 52 | cmd.Dir = root 53 | cmd.Env = env 54 | cmd.Stdout = cmdout 55 | cmd.Stderr = cmdout 56 | assert.NoError(t, cmd.Run()) 57 | }) 58 | 59 | tests := []struct { 60 | name string 61 | give string // string to set and expect back 62 | }{ 63 | {"empty", ""}, 64 | {"simple", "foo bar"}, 65 | {"single quote", "foo 'bar'"}, 66 | {"double quote", `foo "bar"`}, 67 | {"escape", `foo "bar\"baz"`}, 68 | {"escape/single quote", `foo 'bar\"baz'`}, 69 | {"escape/double quote", `foo "bar\"baz"`}, 70 | {"escape/escape", `foo "bar\\baz"`}, 71 | {"regex", `(\b([\w.-]+|~)?(/[\w.-]+)+\b)`}, 72 | } 73 | 74 | for _, tt := range tests { 75 | tt := tt 76 | t.Run(tt.name, func(t *testing.T) { 77 | t.Parallel() 78 | 79 | optName := "@" + strings.Map(func(r rune) rune { 80 | switch r { 81 | case ' ', '/': 82 | return '-' 83 | } 84 | return r 85 | }, tt.name) 86 | 87 | cmd := exec.Command(tmuxExe, "set-option", optName, tt.give) 88 | cmd.Dir = root 89 | cmd.Env = env 90 | cmd.Stdout = cmdout 91 | cmd.Stderr = cmdout 92 | require.NoError(t, cmd.Run()) 93 | 94 | cmd = exec.Command(tmuxExe, "show-option", optName) 95 | cmd.Dir = root 96 | cmd.Env = env 97 | cmd.Stderr = cmdout 98 | bs, err := cmd.Output() 99 | require.NoError(t, err) 100 | bs = bytes.TrimSuffix(bs, []byte{'\n'}) // tmux appends a newline 101 | 102 | name, value, ok := bytes.Cut(bs, []byte{' '}) 103 | require.True(t, ok, "invalid output from tmux show-options: %q", bs) 104 | 105 | assert.Equal(t, optName, string(name), "got back wrong optoin") 106 | 107 | got := tmuxopt.Unquote(value) 108 | assert.Equal(t, tt.give, string(got), "parsed wrong value") 109 | }) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /internal/envtest/env.go: -------------------------------------------------------------------------------- 1 | // Package envtest provides a fake environment variable backend 2 | // for testing purposes. 3 | package envtest 4 | 5 | import ( 6 | "fmt" 7 | ) 8 | 9 | // Empty returns an empty environment. 10 | var Empty = Env{} 11 | 12 | // Env represents a fake environment. 13 | type Env struct { 14 | items map[string]string 15 | } 16 | 17 | // Pairs builds a new fake environment with the provided pairs of items. There 18 | // must be exactly an even number of items in the list. 19 | func Pairs(pairs ...string) (*Env, error) { 20 | if len(pairs)%2 != 0 { 21 | return nil, fmt.Errorf("%d items in environment are not even", len(pairs)) 22 | } 23 | 24 | m := make(map[string]string, len(pairs)/2) 25 | for i := 0; i < len(pairs); i += 2 { 26 | k, v := pairs[i], pairs[i+1] 27 | m[k] = v 28 | } 29 | return &Env{m}, nil 30 | } 31 | 32 | // MustPairs builds an Env with the provided items, panicking if it fails. 33 | func MustPairs(items ...string) *Env { 34 | e, err := Pairs(items...) 35 | if err != nil { 36 | panic(err) 37 | } 38 | return e 39 | } 40 | 41 | // Getenv is an analog for the os.Getenv operation. 42 | func (e *Env) Getenv(k string) string { 43 | if e == nil { 44 | return "" 45 | } 46 | 47 | return e.items[k] 48 | } 49 | -------------------------------------------------------------------------------- /internal/envtest/env_test.go: -------------------------------------------------------------------------------- 1 | package envtest 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestGetenv(t *testing.T) { 11 | t.Parallel() 12 | 13 | env := MustPairs( 14 | "FOO", "bar", 15 | "BAZ", "", 16 | ) 17 | 18 | t.Run("match", func(t *testing.T) { 19 | t.Parallel() 20 | 21 | assert.Equal(t, "bar", env.Getenv("FOO")) 22 | }) 23 | 24 | t.Run("empty match", func(t *testing.T) { 25 | t.Parallel() 26 | 27 | assert.Empty(t, env.Getenv("BAZ")) 28 | }) 29 | 30 | t.Run("empty no match", func(t *testing.T) { 31 | t.Parallel() 32 | 33 | assert.Empty(t, env.Getenv("QUX")) 34 | }) 35 | } 36 | 37 | func TestGetenvNil(t *testing.T) { 38 | t.Parallel() 39 | 40 | var env *Env 41 | assert.Empty(t, env.Getenv("QUX")) 42 | } 43 | 44 | func TestMustPairsOddArguments(t *testing.T) { 45 | t.Parallel() 46 | 47 | assert.Panics(t, func() { 48 | MustPairs("foo", "bar", "baz") 49 | }) 50 | } 51 | 52 | func TestPairsOddArguments(t *testing.T) { 53 | t.Parallel() 54 | 55 | _, err := Pairs("foo", "bar", "baz") 56 | require.Error(t, err) 57 | assert.Contains(t, err.Error(), "not even") 58 | } 59 | -------------------------------------------------------------------------------- /internal/fastcopy/hint.go: -------------------------------------------------------------------------------- 1 | package fastcopy 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | 7 | "github.com/abhinav/tmux-fastcopy/internal/ui" 8 | tcell "github.com/gdamore/tcell/v2" 9 | "go.abhg.dev/algorithm/huffman" 10 | ) 11 | 12 | type hint struct { 13 | // Label to select this hint. 14 | Label string 15 | 16 | // Text that will be copied if this hint is selected. 17 | Text string 18 | 19 | // List ot matches identified by this hint. 20 | // 21 | // Note that a hint may have multiple matches 22 | // if the same text appears on the screen multiple times, 23 | // or if the same text matches multiple regexes. 24 | Matches []Match 25 | 26 | // Selected reports whether this hint is selected. 27 | // 28 | // This is only used in multi-selection mode. 29 | Selected bool 30 | } 31 | 32 | // generateHints generates a list of hints for the given text. It uses alphabet 33 | // to generate unique prefix-free labels for matche sin the text, where matches 34 | // are defined by the provided ranges. 35 | func generateHints(alphabet []rune, text string, matches []Match) []hint { 36 | labelFrom := func(indexes []int) string { 37 | label := make([]rune, len(indexes)) 38 | for i, idx := range indexes { 39 | label[i] = alphabet[idx] 40 | } 41 | return string(label) 42 | } 43 | 44 | // Grouping of match ranges by their matched text. 45 | byText := make(map[string][]Match) 46 | for _, m := range matches { 47 | r := m.Range 48 | match := text[r.Start:r.End] 49 | byText[match] = append(byText[match], m) 50 | } 51 | 52 | uniqueMatches := make([]string, 0, len(byText)) 53 | for t := range byText { 54 | uniqueMatches = append(uniqueMatches, t) 55 | } 56 | sort.Strings(uniqueMatches) 57 | 58 | freqs := make([]int, len(uniqueMatches)) 59 | for i, t := range uniqueMatches { 60 | freqs[i] = len(byText[t]) 61 | } 62 | 63 | hints := make([]hint, len(uniqueMatches)) 64 | for i, labelIxes := range huffman.Label(len(alphabet), freqs) { 65 | t := uniqueMatches[i] 66 | hints[i] = hint{ 67 | Label: labelFrom(labelIxes), 68 | Text: t, 69 | Matches: byText[t], 70 | } 71 | } 72 | 73 | return hints 74 | } 75 | 76 | // AnnotationStyle is the style of annotations for hints and matched text. 77 | type AnnotationStyle struct { 78 | // Matched text that is still a candidate for selection. 79 | Match tcell.Style 80 | 81 | // Matched text that is no longer a candidate for selection. 82 | Skipped tcell.Style 83 | 84 | // Label that the user must type to select the hint. 85 | Label tcell.Style 86 | 87 | // Part of a multi-character label that the user has already typed. 88 | LabelTyped tcell.Style 89 | } 90 | 91 | func (h *hint) Annotations(input string, style AnnotationStyle) (anns []ui.TextAnnotation) { 92 | matched := strings.HasPrefix(h.Label, input) 93 | 94 | // If the hint matches the input, overlay the hint (both, typed 95 | // and non-typed portions) over the string. Otherwise, grey out 96 | // the match. 97 | matchStyle := style.Skipped 98 | if matched { 99 | matchStyle = style.Match 100 | } 101 | 102 | for _, match := range h.Matches { 103 | pos := match.Range 104 | // Show the label only if there's no input, or if the input 105 | // matches all or part of the label. 106 | if matched { 107 | i := 0 108 | 109 | // Highlight the portion of the label already typed by 110 | // the user. 111 | if len(input) > 0 { 112 | anns = append(anns, ui.OverlayTextAnnotation{ 113 | Offset: pos.Start, 114 | Overlay: input, 115 | Style: style.LabelTyped, 116 | }) 117 | i += len(input) 118 | } 119 | 120 | // Highlight the portion of the label yet to be typed. 121 | if i < len(h.Label) { 122 | anns = append(anns, ui.OverlayTextAnnotation{ 123 | Offset: pos.Start + len(input), 124 | Overlay: h.Label[i:], 125 | Style: style.Label, 126 | }) 127 | } 128 | 129 | pos.Start += len(h.Label) 130 | } 131 | 132 | // Don't show the rest of the matched text if the label is 133 | // longer than the text. 134 | if pos.End > pos.Start { 135 | anns = append(anns, ui.StyleTextAnnotation{ 136 | Offset: pos.Start, 137 | Length: pos.End - pos.Start, 138 | Style: matchStyle, 139 | }) 140 | } 141 | } 142 | 143 | return anns 144 | } 145 | -------------------------------------------------------------------------------- /internal/fastcopy/hint_test.go: -------------------------------------------------------------------------------- 1 | package fastcopy 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/abhinav/tmux-fastcopy/internal/ui" 7 | tcell "github.com/gdamore/tcell/v2" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestGenerateHints(t *testing.T) { 12 | t.Parallel() 13 | 14 | alphabet := []rune("abc") 15 | 16 | tests := []struct { 17 | desc string 18 | text string 19 | matches []Match 20 | want []hint 21 | }{ 22 | { 23 | desc: "no matches", 24 | text: "foo", 25 | want: []hint{}, 26 | }, 27 | { 28 | desc: "single match", 29 | text: "foo bar", 30 | matches: []Match{ 31 | {"name", Range{1, 3}}, // f(oo) 32 | }, 33 | want: []hint{ 34 | { 35 | Label: "a", 36 | Text: "oo", 37 | Matches: []Match{ 38 | {"name", Range{1, 3}}, // f(oo) 39 | }, 40 | }, 41 | }, 42 | }, 43 | { 44 | desc: "duplicated match", 45 | text: "foo bar baz qux", 46 | matches: []Match{ 47 | {"name1", Range{4, 6}}, // (ba)r 48 | {"name2", Range{8, 10}}, // (ba)z 49 | }, 50 | want: []hint{ 51 | { 52 | Label: "a", 53 | Text: "ba", 54 | Matches: []Match{ 55 | {"name1", Range{4, 6}}, // (ba)r 56 | {"name2", Range{8, 10}}, // (ba)z 57 | }, 58 | }, 59 | }, 60 | }, 61 | { 62 | desc: "multiple matches", 63 | text: "foo bar baz qux", 64 | matches: []Match{ 65 | {"p", Range{0, 3}}, // (foo) 66 | {"q", Range{4, 6}}, // (ba)r 67 | {"r", Range{8, 10}}, // (ba)z 68 | {"s", Range{13, 15}}, // q(ux) 69 | }, 70 | want: []hint{ 71 | { 72 | Label: "c", 73 | Text: "ba", 74 | Matches: []Match{ 75 | {"q", Range{4, 6}}, // (ba)r 76 | {"r", Range{8, 10}}, // (ba)z 77 | }, 78 | }, 79 | { 80 | Label: "a", 81 | Text: "foo", 82 | Matches: []Match{ 83 | {"p", Range{0, 3}}, // (foo) 84 | }, 85 | }, 86 | { 87 | Label: "b", 88 | Text: "ux", 89 | Matches: []Match{ 90 | {"s", Range{13, 15}}, // q(ux) 91 | }, 92 | }, 93 | }, 94 | }, 95 | } 96 | 97 | for _, tt := range tests { 98 | tt := tt 99 | t.Run(tt.desc, func(t *testing.T) { 100 | t.Parallel() 101 | 102 | got := generateHints(alphabet, tt.text, tt.matches) 103 | assert.Equal(t, tt.want, got) 104 | }) 105 | } 106 | } 107 | 108 | func TestHintAnnotations(t *testing.T) { 109 | t.Parallel() 110 | 111 | style := AnnotationStyle{ 112 | Match: tcell.StyleDefault.Foreground(tcell.ColorGreen), 113 | Skipped: tcell.StyleDefault.Foreground(tcell.ColorGray), 114 | Label: tcell.StyleDefault.Foreground(tcell.ColorRed), 115 | LabelTyped: tcell.StyleDefault.Foreground(tcell.ColorYellow), 116 | } 117 | 118 | tests := []struct { 119 | desc string 120 | give hint 121 | input string 122 | want []ui.TextAnnotation 123 | }{ 124 | { 125 | desc: "multiple matches", 126 | give: hint{ 127 | Label: "a", 128 | Text: "foo", 129 | Matches: []Match{ 130 | {"x", Range{0, 3}}, 131 | {"y", Range{7, 10}}, 132 | }, 133 | }, 134 | // [a]oo 135 | want: []ui.TextAnnotation{ 136 | ui.OverlayTextAnnotation{ 137 | Offset: 0, 138 | Overlay: "a", 139 | Style: style.Label, 140 | }, 141 | ui.StyleTextAnnotation{ 142 | Offset: 1, 143 | Length: 2, 144 | Style: style.Match, 145 | }, 146 | ui.OverlayTextAnnotation{ 147 | Offset: 7, 148 | Overlay: "a", 149 | Style: style.Label, 150 | }, 151 | ui.StyleTextAnnotation{ 152 | Offset: 8, 153 | Length: 2, 154 | Style: style.Match, 155 | }, 156 | }, 157 | }, 158 | { 159 | desc: "full input match", 160 | give: hint{ 161 | Label: "a", 162 | Text: "foo", 163 | Matches: []Match{ 164 | {"x", Range{0, 3}}, 165 | }, 166 | }, 167 | input: "a", 168 | want: []ui.TextAnnotation{ 169 | ui.OverlayTextAnnotation{ 170 | Offset: 0, 171 | Overlay: "a", 172 | Style: style.LabelTyped, 173 | }, 174 | ui.StyleTextAnnotation{ 175 | Offset: 1, 176 | Length: 2, 177 | Style: style.Match, 178 | }, 179 | }, 180 | }, 181 | { 182 | desc: "multi character label", 183 | give: hint{ 184 | Label: "ab", 185 | Text: "foobar", 186 | Matches: []Match{ 187 | {"x", Range{1, 7}}, 188 | }, 189 | }, 190 | want: []ui.TextAnnotation{ 191 | ui.OverlayTextAnnotation{ 192 | Offset: 1, 193 | Overlay: "ab", 194 | Style: style.Label, 195 | }, 196 | ui.StyleTextAnnotation{ 197 | Offset: 3, 198 | Length: 4, 199 | Style: style.Match, 200 | }, 201 | }, 202 | }, 203 | { 204 | desc: "multi character label/input match", 205 | give: hint{ 206 | Label: "ab", 207 | Text: "foobar", 208 | Matches: []Match{ 209 | {"x", Range{1, 7}}, 210 | }, 211 | }, 212 | input: "a", 213 | want: []ui.TextAnnotation{ 214 | ui.OverlayTextAnnotation{ 215 | Offset: 1, 216 | Overlay: "a", 217 | Style: style.LabelTyped, 218 | }, 219 | ui.OverlayTextAnnotation{ 220 | Offset: 2, 221 | Overlay: "b", 222 | Style: style.Label, 223 | }, 224 | ui.StyleTextAnnotation{ 225 | Offset: 3, 226 | Length: 4, 227 | Style: style.Match, 228 | }, 229 | }, 230 | }, 231 | { 232 | desc: "multi character label/input mismatch", 233 | give: hint{ 234 | Label: "ab", 235 | Text: "foobar", 236 | Matches: []Match{ 237 | {"x", Range{1, 7}}, 238 | }, 239 | }, 240 | input: "x", 241 | want: []ui.TextAnnotation{ 242 | ui.StyleTextAnnotation{ 243 | Offset: 1, 244 | Length: 6, 245 | Style: style.Skipped, 246 | }, 247 | }, 248 | }, 249 | { 250 | desc: "long label", 251 | give: hint{ 252 | Label: "abcd", 253 | Text: "foo", 254 | Matches: []Match{ 255 | {"x", Range{0, 3}}, 256 | }, 257 | }, 258 | want: []ui.TextAnnotation{ 259 | ui.OverlayTextAnnotation{ 260 | Offset: 0, 261 | Overlay: "abcd", 262 | Style: style.Label, 263 | }, 264 | }, 265 | }, 266 | } 267 | 268 | for _, tt := range tests { 269 | tt := tt 270 | t.Run(tt.desc, func(t *testing.T) { 271 | t.Parallel() 272 | 273 | got := tt.give.Annotations(tt.input, style) 274 | assert.Equal(t, tt.want, got) 275 | }) 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /internal/fastcopy/mock_handler_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/abhinav/tmux-fastcopy/internal/fastcopy (interfaces: Handler) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination mock_handler_test.go -package fastcopy github.com/abhinav/tmux-fastcopy/internal/fastcopy Handler 7 | // 8 | 9 | // Package fastcopy is a generated GoMock package. 10 | package fastcopy 11 | 12 | import ( 13 | reflect "reflect" 14 | 15 | gomock "go.uber.org/mock/gomock" 16 | ) 17 | 18 | // MockHandler is a mock of Handler interface. 19 | type MockHandler struct { 20 | ctrl *gomock.Controller 21 | recorder *MockHandlerMockRecorder 22 | isgomock struct{} 23 | } 24 | 25 | // MockHandlerMockRecorder is the mock recorder for MockHandler. 26 | type MockHandlerMockRecorder struct { 27 | mock *MockHandler 28 | } 29 | 30 | // NewMockHandler creates a new mock instance. 31 | func NewMockHandler(ctrl *gomock.Controller) *MockHandler { 32 | mock := &MockHandler{ctrl: ctrl} 33 | mock.recorder = &MockHandlerMockRecorder{mock} 34 | return mock 35 | } 36 | 37 | // EXPECT returns an object that allows the caller to indicate expected use. 38 | func (m *MockHandler) EXPECT() *MockHandlerMockRecorder { 39 | return m.recorder 40 | } 41 | 42 | // HandleSelection mocks base method. 43 | func (m *MockHandler) HandleSelection(arg0 Selection) { 44 | m.ctrl.T.Helper() 45 | m.ctrl.Call(m, "HandleSelection", arg0) 46 | } 47 | 48 | // HandleSelection indicates an expected call of HandleSelection. 49 | func (mr *MockHandlerMockRecorder) HandleSelection(arg0 any) *gomock.Call { 50 | mr.mock.ctrl.T.Helper() 51 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleSelection", reflect.TypeOf((*MockHandler)(nil).HandleSelection), arg0) 52 | } 53 | -------------------------------------------------------------------------------- /internal/fastcopy/widget_test.go: -------------------------------------------------------------------------------- 1 | package fastcopy 2 | 3 | import ( 4 | "testing" 5 | 6 | tcell "github.com/gdamore/tcell/v2" 7 | "github.com/stretchr/testify/assert" 8 | gomock "go.uber.org/mock/gomock" 9 | ) 10 | 11 | func TestRange(t *testing.T) { 12 | t.Parallel() 13 | 14 | tests := []struct { 15 | desc string 16 | give Range 17 | length int 18 | str string 19 | }{ 20 | {desc: "zero", str: "[0, 0)"}, 21 | { 22 | desc: "one", 23 | give: Range{0, 5}, 24 | length: 5, 25 | str: "[0, 5)", 26 | }, 27 | } 28 | 29 | for _, tt := range tests { 30 | tt := tt 31 | t.Run(tt.desc, func(t *testing.T) { 32 | t.Parallel() 33 | 34 | assert.Equal(t, tt.length, tt.give.Len(), "length") 35 | assert.Equal(t, tt.str, tt.give.String(), "string") 36 | }) 37 | } 38 | } 39 | 40 | func sampleStyle() Style { 41 | return Style{ 42 | Normal: tcell.StyleDefault, 43 | Match: tcell.StyleDefault.Foreground(tcell.ColorGreen), 44 | SkippedMatch: tcell.StyleDefault.Foreground(tcell.ColorGray), 45 | HintLabel: tcell.StyleDefault.Foreground(tcell.ColorRed), 46 | HintLabelInput: tcell.StyleDefault.Foreground(tcell.ColorYellow), 47 | } 48 | } 49 | 50 | //nolint:paralleltest // shared state between subtests 51 | func TestWidget(t *testing.T) { 52 | t.Parallel() 53 | 54 | mockCtrl := gomock.NewController(t) 55 | handler := NewMockHandler(mockCtrl) 56 | style := sampleStyle() 57 | 58 | // 0 1 2 3 59 | // [(f o)o ] \n 60 | // 4 [ b(a r)] \n 61 | // 8 [ b(a z)] \n 62 | // 12 [(q u)x ] \n 63 | w := (&WidgetConfig{ 64 | Text: "foo\nbar\nbaz\nqux", 65 | Matches: []Match{ 66 | {"p", Range{0, 2}}, // (fo) 67 | {"q", Range{5, 7}}, // (ar) 68 | {"r", Range{9, 11}}, // (az) 69 | {"s", Range{12, 14}}, // (qu) 70 | }, 71 | HintAlphabet: []rune("ab"), 72 | Handler: handler, 73 | Style: style, 74 | generateHints: func([]rune, string, []Match) []hint { 75 | return []hint{ 76 | {Label: "aa", Text: "fo", Matches: []Match{{"p", Range{0, 2}}}}, // (fo) 77 | {Label: "bb", Text: "ar", Matches: []Match{{"q", Range{5, 7}}}}, // (ar) 78 | {Label: "ba", Text: "az", Matches: []Match{{"r", Range{9, 11}}}}, // (az) 79 | {Label: "ab", Text: "qu", Matches: []Match{{"p", Range{12, 14}}}}, // (qu) 80 | } 81 | }, 82 | }).Build() 83 | 84 | screen := tcell.NewSimulationScreen("") 85 | screen.SetSize(3, 3) 86 | screen.Clear() 87 | w.Draw(screen) 88 | 89 | t.Run("mouse event", func(t *testing.T) { 90 | ev := tcell.NewEventMouse(1, 1, tcell.Button1, 0) 91 | assert.False(t, w.HandleEvent(ev), 92 | "widget cannot handle mouse events yet") 93 | }) 94 | 95 | t.Run("partial input", func(t *testing.T) { 96 | assert.True(t, 97 | w.HandleEvent(tcell.NewEventKey(tcell.KeyRune, 'a', 0)), 98 | "widget must handle key event") 99 | 100 | assert.Equal(t, "a", w.Input()) 101 | 102 | assert.True(t, 103 | w.HandleEvent(tcell.NewEventKey(tcell.KeyBackspace, 0, 0)), 104 | "widget must handle backspace event") 105 | 106 | assert.Empty(t, w.Input()) 107 | }) 108 | 109 | t.Run("select", func(t *testing.T) { 110 | handler.EXPECT(). 111 | HandleSelection(Selection{Text: "az", Matchers: []string{"r"}}) 112 | 113 | assert.True(t, 114 | w.HandleEvent(tcell.NewEventKey(tcell.KeyRune, 'b', 0))) 115 | assert.True(t, 116 | w.HandleEvent(tcell.NewEventKey(tcell.KeyRune, 'a', 0))) 117 | }) 118 | 119 | t.Run("shift select", func(t *testing.T) { 120 | handler.EXPECT(). 121 | HandleSelection(Selection{ 122 | Text: "qu", 123 | Matchers: []string{"p"}, 124 | Shift: true, 125 | }) 126 | 127 | assert.True(t, 128 | w.HandleEvent(tcell.NewEventKey(tcell.KeyRune, 'a', 0))) 129 | assert.True(t, 130 | w.HandleEvent(tcell.NewEventKey(tcell.KeyRune, 'B', 0))) 131 | }) 132 | 133 | t.Run("multi-select", func(t *testing.T) { 134 | handler.EXPECT(). 135 | HandleSelection(Selection{ 136 | Text: "fo ar", 137 | Matchers: []string{"p", "q"}, 138 | }) 139 | 140 | // enter multi-select mode 141 | assert.True(t, 142 | w.HandleEvent(tcell.NewEventKey(tcell.KeyTab, 0, 0))) 143 | 144 | // Select one. 145 | assert.True(t, 146 | w.HandleEvent(tcell.NewEventKey(tcell.KeyRune, 'a', 0))) 147 | assert.True(t, 148 | w.HandleEvent(tcell.NewEventKey(tcell.KeyRune, 'a', 0))) 149 | 150 | // Select another. 151 | assert.True(t, 152 | w.HandleEvent(tcell.NewEventKey(tcell.KeyRune, 'b', 0))) 153 | assert.True(t, 154 | w.HandleEvent(tcell.NewEventKey(tcell.KeyRune, 'b', 0))) 155 | 156 | // Accept selection. 157 | assert.True(t, 158 | w.HandleEvent(tcell.NewEventKey(tcell.KeyTab, 0, 0))) 159 | }) 160 | 161 | t.Run("multi-select no match", func(t *testing.T) { 162 | // Enter multi-select and accept right away. 163 | assert.True(t, 164 | w.HandleEvent(tcell.NewEventKey(tcell.KeyTab, 0, 0))) 165 | assert.True(t, 166 | w.HandleEvent(tcell.NewEventKey(tcell.KeyEnter, 0, 0))) 167 | }) 168 | } 169 | -------------------------------------------------------------------------------- /internal/log/log.go: -------------------------------------------------------------------------------- 1 | // Package log provides leveled logging interface. 2 | // The log messages are intended to be user-facing 3 | // similar to the standard library's log package. 4 | package log 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "strings" 10 | "sync" 11 | "unicode" 12 | ) 13 | 14 | // Discard is a logger that discards all its operations. 15 | var Discard = New(io.Discard).WithLevel(discard) 16 | 17 | // Level specifies the level of logging. 18 | type Level int 19 | 20 | // Supported log levels. 21 | const ( 22 | Debug Level = iota - 1 23 | Info 24 | Error 25 | discard 26 | ) 27 | 28 | func (l Level) String() string { 29 | switch l { 30 | case Debug: 31 | return "debug" 32 | case Info: 33 | return "info" 34 | case Error: 35 | return "error" 36 | default: 37 | return fmt.Sprintf("%d", int(l)) 38 | } 39 | } 40 | 41 | // Logger is a thread-safe logger safe for concurrent use. 42 | type Logger struct { 43 | w *lockedWriter 44 | name string 45 | lvl Level 46 | } 47 | 48 | // New builds a logger that writes to the given writer. 49 | // The logger defaults to level Info. 50 | func New(w io.Writer) *Logger { 51 | return &Logger{w: &lockedWriter{W: w}} 52 | } 53 | 54 | // WithName builds a new logger with the provided name. The returned logger is 55 | // safe to use concurrently with this logger. 56 | func (l *Logger) WithName(name string) *Logger { 57 | out := *l 58 | out.name = name 59 | return &out 60 | } 61 | 62 | // WithLevel buils a new logger that will log messages of the given level or 63 | // higher. The returned logger is safe to use concurrently with this logger. 64 | func (l *Logger) WithLevel(lvl Level) *Logger { 65 | out := *l 66 | out.lvl = lvl 67 | return &out 68 | } 69 | 70 | // Level reports the level of the logger. The logger will only log messages of 71 | // this level or higher. 72 | func (l *Logger) Level() Level { return l.lvl } 73 | 74 | // Debugf logs messages at the debug level. 75 | func (l *Logger) Debugf(msg string, args ...interface{}) { 76 | l.Log(Debug, msg, args...) 77 | } 78 | 79 | // Infof logs messages at the info level. 80 | func (l *Logger) Infof(msg string, args ...interface{}) { 81 | l.Log(Info, msg, args...) 82 | } 83 | 84 | // Errorf logs messages at the error level. 85 | func (l *Logger) Errorf(msg string, args ...interface{}) { 86 | l.Log(Error, msg, args...) 87 | } 88 | 89 | // Log logs messages at the provided level. 90 | func (l *Logger) Log(level Level, msg string, args ...interface{}) { 91 | if level < l.lvl { 92 | return 93 | } 94 | 95 | var out strings.Builder 96 | if len(l.name) > 0 { 97 | out.WriteRune('[') 98 | out.WriteString(l.name) 99 | out.WriteString("] ") 100 | } 101 | 102 | // Ensure a single trailing newline. 103 | msg = strings.TrimRightFunc(msg, unicode.IsSpace) 104 | if len(args) > 0 { 105 | fmt.Fprintf(&out, msg, args...) 106 | } else { 107 | out.WriteString(msg) 108 | } 109 | out.WriteString("\n") 110 | 111 | _, _ = l.w.WriteString(out.String()) // ignore error 112 | } 113 | 114 | type lockedWriter struct { 115 | mu sync.Mutex 116 | W io.Writer 117 | } 118 | 119 | func (w *lockedWriter) WriteString(s string) (int, error) { 120 | w.mu.Lock() 121 | n, err := io.WriteString(w.W, s) 122 | w.mu.Unlock() 123 | return n, err 124 | } 125 | -------------------------------------------------------------------------------- /internal/log/log_test.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestLevels(t *testing.T) { 13 | t.Parallel() 14 | 15 | t.Run("default level", func(t *testing.T) { 16 | t.Parallel() 17 | 18 | assert.Equal(t, Info, New(io.Discard).Level()) 19 | }) 20 | 21 | t.Run("info", func(t *testing.T) { 22 | t.Parallel() 23 | 24 | var buff bytes.Buffer 25 | log := New(&buff).WithLevel(Debug) 26 | 27 | log.Debugf("debug") 28 | log.Infof("info") 29 | log.Errorf("error") 30 | 31 | assert.Equal(t, unlines("debug", "info", "error"), buff.String()) 32 | }) 33 | 34 | t.Run("info", func(t *testing.T) { 35 | t.Parallel() 36 | 37 | var buff bytes.Buffer 38 | log := New(&buff).WithLevel(Info) 39 | 40 | log.Debugf("debug") 41 | log.Infof("info") 42 | log.Errorf("error") 43 | 44 | assert.Equal(t, unlines("info", "error"), buff.String()) 45 | }) 46 | 47 | t.Run("error", func(t *testing.T) { 48 | t.Parallel() 49 | 50 | var buff bytes.Buffer 51 | log := New(&buff).WithLevel(Error) 52 | 53 | log.Debugf("debug") 54 | log.Infof("info") 55 | log.Errorf("error") 56 | 57 | assert.Equal(t, unlines("error"), buff.String()) 58 | }) 59 | 60 | t.Run("discard", func(t *testing.T) { 61 | t.Parallel() 62 | 63 | var buff bytes.Buffer 64 | log := New(&buff).WithLevel(discard) 65 | 66 | log.Debugf("debug") 67 | log.Infof("info") 68 | log.Errorf("error") 69 | 70 | assert.Empty(t, buff.String()) 71 | }) 72 | } 73 | 74 | func TestName(t *testing.T) { 75 | t.Parallel() 76 | 77 | var buff bytes.Buffer 78 | log := New(&buff).WithName("foo") 79 | 80 | log.Infof("info") 81 | log.Errorf("error") 82 | 83 | assert.Equal(t, unlines( 84 | "[foo] info", 85 | "[foo] error", 86 | ), buff.String()) 87 | } 88 | 89 | func TestFormatting(t *testing.T) { 90 | t.Parallel() 91 | 92 | var buff bytes.Buffer 93 | log := New(&buff).WithLevel(Debug) 94 | 95 | log.Debugf("level = %v", Debug) 96 | log.Infof("level = %v", Info) 97 | log.Errorf("level = %v", Error) 98 | log.Errorf("level = %v", discard) 99 | 100 | assert.Equal(t, unlines( 101 | "level = debug", 102 | "level = info", 103 | "level = error", 104 | "level = 2", 105 | ), buff.String()) 106 | } 107 | 108 | func TestTrailingNewline(t *testing.T) { 109 | t.Parallel() 110 | 111 | var buff bytes.Buffer 112 | log := New(&buff) 113 | 114 | log.Infof("foo\n\n") 115 | 116 | assert.Equal(t, unlines("foo"), buff.String()) 117 | } 118 | 119 | func unlines(lines ...string) string { 120 | return strings.Join(lines, "\n") + "\n" 121 | } 122 | -------------------------------------------------------------------------------- /internal/log/logtest/logger.go: -------------------------------------------------------------------------------- 1 | // Package logtest provides a logger that can write to a testing.T. 2 | package logtest 3 | 4 | import ( 5 | "testing" 6 | 7 | "github.com/abhinav/tmux-fastcopy/internal/log" 8 | "go.abhg.dev/io/ioutil" 9 | ) 10 | 11 | // NewLogger builds a logger at debug level that writes to a testing.T. 12 | func NewLogger(t testing.TB) *log.Logger { 13 | return log.New(ioutil.TestLogWriter(t, "")).WithLevel(log.Debug) 14 | } 15 | -------------------------------------------------------------------------------- /internal/log/writer.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import "bytes" 4 | 5 | // Writer is an io.Writer that writes to the provided logger, splitting 6 | // messages across newlines into new log entries. 7 | type Writer struct { 8 | Log *Logger 9 | Level Level 10 | 11 | buff bytes.Buffer 12 | } 13 | 14 | func (w *Writer) Write(bs []byte) (int, error) { 15 | n := len(bs) 16 | for len(bs) > 0 { 17 | bs = w.takeNextLine(bs) 18 | } 19 | return n, nil 20 | } 21 | 22 | func (w *Writer) takeNextLine(line []byte) (remaining []byte) { 23 | idx := bytes.IndexByte(line, '\n') 24 | if idx < 0 { 25 | // If there are no newlines, buffer the entire string. 26 | w.buff.Write(line) 27 | return nil 28 | } 29 | 30 | // Split on the newline, buffer and flush the left. 31 | line, remaining = line[:idx], line[idx+1:] 32 | 33 | // Fast path: if we don't have a partial message from a previous write 34 | // in the buffer, skip the buffer and log directly. 35 | if w.buff.Len() == 0 { 36 | w.logLine(line) 37 | return 38 | } 39 | 40 | w.buff.Write(line) 41 | 42 | // Log empty messages in the middle of the stream so that we don't lose 43 | // information when the user writes "foo\n\nbar". 44 | w.flush(true /* allowEmpty */) 45 | 46 | return remaining 47 | } 48 | 49 | // Close closes the Writer, flushing any buffered data to the underlying log. 50 | func (w *Writer) Close() error { 51 | // Don't allow empty messages on Close because we don't want an 52 | // extraneous empty message at the end of the stream -- it's common for 53 | // files to end with a newline. 54 | w.flush(false /* allowEmpty */) 55 | return nil 56 | } 57 | 58 | func (w *Writer) flush(allowEmpty bool) { 59 | if allowEmpty || w.buff.Len() > 0 { 60 | w.logLine(w.buff.Bytes()) 61 | } 62 | w.buff.Reset() 63 | } 64 | 65 | func (w *Writer) logLine(b []byte) { 66 | w.Log.Log(w.Level, "%s", b) 67 | } 68 | -------------------------------------------------------------------------------- /internal/log/writer_test.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestWriter(t *testing.T) { 13 | t.Parallel() 14 | 15 | tests := []struct { 16 | desc string 17 | give []string 18 | want string 19 | }{ 20 | {desc: "empty"}, 21 | { 22 | desc: "split message", 23 | give: []string{"foo\nbar"}, 24 | want: unlines( 25 | "[x] foo", 26 | "[x] bar", 27 | ), 28 | }, 29 | { 30 | desc: "ends with a newline", 31 | give: []string{"foo\n", "bar\n"}, 32 | want: unlines( 33 | "[x] foo", 34 | "[x] bar", 35 | ), 36 | }, 37 | { 38 | desc: "no newlines", 39 | give: []string{"foo", "bar"}, 40 | want: unlines( 41 | "[x] foobar", 42 | ), 43 | }, 44 | { 45 | desc: "newline late", 46 | give: []string{"foo", "b\nar"}, 47 | want: unlines( 48 | "[x] foob", 49 | "[x] ar", 50 | ), 51 | }, 52 | } 53 | 54 | for _, tt := range tests { 55 | tt := tt 56 | t.Run(tt.desc, func(t *testing.T) { 57 | t.Parallel() 58 | 59 | var buff bytes.Buffer 60 | log := New(&buff).WithName("x") 61 | 62 | w := Writer{Log: log} 63 | for _, s := range tt.give { 64 | _, err := io.WriteString(&w, s) 65 | require.NoError(t, err) 66 | } 67 | require.NoError(t, w.Close()) 68 | 69 | assert.Equal(t, tt.want, buff.String()) 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /internal/must/must.go: -------------------------------------------------------------------------------- 1 | // Package must provides helper functions to assert program invariants. 2 | // The program will panic if an invariant is violated. 3 | package must 4 | 5 | import "fmt" 6 | 7 | // panicf panics with the printf-style message. 8 | func panicf(format string, args ...interface{}) { 9 | panic(fmt.Sprintf(format, args...)) 10 | } 11 | 12 | // NotErrorf panics with the given message if the error is not nil. 13 | func NotErrorf(err error, format string, args ...interface{}) { 14 | if err != nil { 15 | panicf("unexpected error: %v\n%v", err, fmt.Sprintf(format, args...)) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /internal/must/must_test.go: -------------------------------------------------------------------------------- 1 | package must 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNotErrorf(t *testing.T) { 11 | t.Parallel() 12 | 13 | t.Run("nil", func(t *testing.T) { 14 | t.Parallel() 15 | 16 | NotErrorf(nil, "should not panic") 17 | }) 18 | 19 | t.Run("not-nil", func(t *testing.T) { 20 | t.Parallel() 21 | 22 | assert.Panics(t, func() { 23 | NotErrorf(errors.New("error"), "should panic") 24 | }) 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /internal/paniclog/handle.go: -------------------------------------------------------------------------------- 1 | // Package paniclog provides a handler for panicking code 2 | // that logs the panic to an io.Writer. 3 | package paniclog 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "io" 9 | "runtime/debug" 10 | ) 11 | 12 | // Handle handles a panic value, logging it to the given io.Writer. Returns the 13 | // error version of the panic, if any. 14 | func Handle(pval interface{}, w io.Writer) error { 15 | if pval == nil { 16 | return nil 17 | } 18 | 19 | fmt.Fprintf(w, "panic: %v\n%s", pval, debug.Stack()) 20 | 21 | var err error 22 | switch pval := pval.(type) { 23 | case string: 24 | err = errors.New(pval) 25 | case error: 26 | err = pval 27 | default: 28 | err = fmt.Errorf("panic: %v", pval) 29 | } 30 | return err 31 | } 32 | 33 | // Recover recovers a panic and appends it into the given error pointer. 34 | func Recover(err *error, w io.Writer) { 35 | if pval := recover(); pval != nil { 36 | *err = Handle(pval, w) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/paniclog/handle_test.go: -------------------------------------------------------------------------------- 1 | package paniclog 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestHandle(t *testing.T) { 13 | t.Parallel() 14 | 15 | tests := []struct { 16 | desc string 17 | give interface{} 18 | 19 | wantMsg string // contains check 20 | wantErr string // equals check 21 | }{ 22 | {desc: "nil"}, 23 | { 24 | desc: "string", 25 | give: "foo", 26 | wantMsg: "panic: foo\n", 27 | wantErr: "foo", 28 | }, 29 | { 30 | desc: "error", 31 | give: errors.New("great sadness"), 32 | wantMsg: "panic: great sadness\n", 33 | wantErr: "great sadness", 34 | }, 35 | { 36 | desc: "int", 37 | give: 42, 38 | wantMsg: "panic: 42", 39 | wantErr: "panic: 42", 40 | }, 41 | } 42 | 43 | for _, tt := range tests { 44 | tt := tt 45 | t.Run(tt.desc, func(t *testing.T) { 46 | t.Parallel() 47 | 48 | var buff bytes.Buffer 49 | got := Handle(tt.give, &buff) 50 | assert.Contains(t, buff.String(), tt.wantMsg) 51 | 52 | if len(tt.wantErr) == 0 { 53 | assert.NoError(t, got) 54 | } else { 55 | assert.Error(t, got) 56 | assert.Equal(t, tt.wantErr, got.Error()) 57 | } 58 | }) 59 | } 60 | } 61 | 62 | func TestRecover(t *testing.T) { 63 | t.Parallel() 64 | 65 | t.Run("panic", func(t *testing.T) { 66 | t.Parallel() 67 | 68 | var ( 69 | err error 70 | buff bytes.Buffer 71 | ) 72 | defer func() { 73 | assert.Error(t, err) 74 | assert.Equal(t, "great sadness", err.Error()) 75 | assert.Contains(t, buff.String(), "panic: great sadness\n") 76 | }() 77 | 78 | defer Recover(&err, &buff) 79 | 80 | panic("great sadness") 81 | }) 82 | 83 | t.Run("no panic", func(t *testing.T) { 84 | t.Parallel() 85 | 86 | var ( 87 | err error 88 | buff bytes.Buffer 89 | ) 90 | defer func() { 91 | require.NoError(t, err) 92 | assert.Empty(t, buff.String()) 93 | }() 94 | 95 | defer Recover(&err, &buff) 96 | }) 97 | 98 | t.Run("no panic with error", func(t *testing.T) { 99 | t.Parallel() 100 | 101 | err := errors.New("great sadness") 102 | var buff bytes.Buffer 103 | 104 | defer func() { 105 | require.Error(t, err) 106 | assert.Contains(t, err.Error(), "great sadness") 107 | assert.Empty(t, buff.String()) 108 | }() 109 | 110 | defer Recover(&err, &buff) 111 | }) 112 | } 113 | -------------------------------------------------------------------------------- /internal/stringobj/stringobj.go: -------------------------------------------------------------------------------- 1 | // Package stringobj aids in writing String methods for objects 2 | // with a JSON-like output. 3 | package stringobj 4 | 5 | import ( 6 | "fmt" 7 | "reflect" 8 | "sort" 9 | "strings" 10 | ) 11 | 12 | // Builder helps build String functions for objects that skip zero-value 13 | // attributes. 14 | type Builder struct { 15 | attrs []string 16 | } 17 | 18 | // Put adds the given attribute-value pair to the builder, skipping it if the 19 | // value is a zero value. 20 | func (b *Builder) Put(name string, value interface{}) { 21 | // This whole module is icky; we can do something like 22 | // zap.ObjectEncoders later. 23 | if value == nil { 24 | return 25 | } 26 | if v := reflect.ValueOf(value); v.IsZero() { 27 | return 28 | } 29 | b.attrs = append(b.attrs, fmt.Sprintf("%s: %v", name, value)) 30 | } 31 | 32 | // String returns the final string representation. 33 | func (b *Builder) String() string { 34 | sort.Strings(b.attrs) 35 | 36 | var out strings.Builder 37 | out.WriteRune('{') 38 | for i, attr := range b.attrs { 39 | if i > 0 { 40 | out.WriteString(", ") 41 | } 42 | out.WriteString(attr) 43 | } 44 | out.WriteRune('}') 45 | return out.String() 46 | } 47 | -------------------------------------------------------------------------------- /internal/stringobj/stringobj_test.go: -------------------------------------------------------------------------------- 1 | package stringobj 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestBuilder(t *testing.T) { 10 | t.Parallel() 11 | 12 | type put struct { 13 | key string 14 | value interface{} 15 | } 16 | 17 | tests := []struct { 18 | desc string 19 | puts []put 20 | want string 21 | }{ 22 | { 23 | desc: "empty", 24 | want: "{}", 25 | }, 26 | { 27 | desc: "non-empty", 28 | puts: []put{ 29 | {"string", "bar"}, 30 | {"int", 42}, 31 | {"list", []string{}}, 32 | }, 33 | want: `{int: 42, list: [], string: bar}`, 34 | }, 35 | { 36 | desc: "skip zero", 37 | puts: []put{ 38 | {"string", ""}, 39 | {"int", 0}, 40 | {"list", nil}, 41 | }, 42 | want: "{}", 43 | }, 44 | } 45 | 46 | for _, tt := range tests { 47 | tt := tt 48 | t.Run(tt.desc, func(t *testing.T) { 49 | t.Parallel() 50 | 51 | var b Builder 52 | for _, i := range tt.puts { 53 | b.Put(i.key, i.value) 54 | } 55 | 56 | assert.Equal(t, tt.want, b.String()) 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/tail/tail.go: -------------------------------------------------------------------------------- 1 | // Package tail provides support for tailing an io.Reader that isn't yet done 2 | // filling up. 3 | package tail 4 | 5 | import ( 6 | "errors" 7 | "io" 8 | "io/fs" 9 | "time" 10 | 11 | "github.com/benbjohnson/clock" 12 | ) 13 | 14 | const ( 15 | _defaultDelay = 100 * time.Millisecond 16 | _defaultBufferSize = 32 * 1024 // 32kB 17 | ) 18 | 19 | // Tee copies text from source to destination until the user closes the source 20 | // or calls Tee.Stop. 21 | type Tee struct { 22 | W io.Writer // destination (required) 23 | R io.Reader // source (required) 24 | 25 | // Maximum delay between retries. If the end of the source is reached, 26 | // we'll wait up to this much time before trying again. Defaults to 100 27 | // milliseconds. 28 | Delay time.Duration 29 | 30 | // Size of the copy buffer. Defaults to 32kB. 31 | BufferSize int 32 | 33 | Clock clock.Clock 34 | 35 | err error 36 | buffer []byte 37 | quit, done chan struct{} 38 | } 39 | 40 | // Start begins tailing the source and copying blobs into destination until an 41 | // error is encountered, source runs out, or Stop is called. If source reaches 42 | // EOF but is not yet closed, Tee will try again after some delay (configurable 43 | // via the Delay parameter). 44 | // 45 | // Start returns immediately. 46 | func (t *Tee) Start() { 47 | if t.Delay == 0 { 48 | t.Delay = _defaultDelay 49 | } 50 | if t.BufferSize == 0 { 51 | t.BufferSize = _defaultBufferSize 52 | } 53 | if t.Clock == nil { 54 | t.Clock = clock.New() 55 | } 56 | 57 | t.buffer = make([]byte, t.BufferSize) 58 | t.quit = make(chan struct{}) 59 | t.done = make(chan struct{}) 60 | 61 | go t.run() 62 | } 63 | 64 | // Stop tells Tee to stop copying text. It blocks until it has cleaned up the 65 | // background job. Returns errors encountered during run, if any. 66 | // 67 | // If this freezes, make sure you closed the underlying file. 68 | func (t *Tee) Stop() error { 69 | close(t.quit) 70 | 71 | return t.Wait() 72 | } 73 | 74 | // Wait waits until the tee stops from an error or from Stop being called. 75 | // Returns the error, if any. 76 | func (t *Tee) Wait() error { 77 | <-t.done 78 | return t.err 79 | } 80 | 81 | func (t *Tee) run() { 82 | defer close(t.done) 83 | 84 | ticker := t.Clock.Ticker(t.Delay) 85 | defer ticker.Stop() 86 | 87 | for { 88 | n, err := io.CopyBuffer(t.W, t.R, t.buffer) 89 | if err == nil && n > 0 { 90 | // There are more bytes still to read. 91 | continue 92 | } 93 | 94 | switch { 95 | case errors.Is(err, fs.ErrClosed): 96 | // File is closed. No new logs are expected. 97 | return 98 | 99 | case err == nil || errors.Is(err, io.EOF): 100 | // There were no more bytes left to copy. Wait for quit 101 | // or up to the specified delay and try again. 102 | select { 103 | case <-t.quit: 104 | return 105 | case <-ticker.C: 106 | } 107 | 108 | default: 109 | // Something went wrong. Record and die. 110 | t.err = err 111 | return 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /internal/tail/tail_test.go: -------------------------------------------------------------------------------- 1 | package tail 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "os" 8 | "sync" 9 | "testing" 10 | "testing/iotest" 11 | 12 | "github.com/benbjohnson/clock" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | type lockedBuffer struct { 18 | mu sync.RWMutex 19 | buff bytes.Buffer 20 | } 21 | 22 | func (b *lockedBuffer) Write(data []byte) (int, error) { 23 | b.mu.Lock() 24 | defer b.mu.Unlock() 25 | return b.buff.Write(data) 26 | } 27 | 28 | func (b *lockedBuffer) Reset() { 29 | b.mu.Lock() 30 | defer b.mu.Unlock() 31 | b.buff.Reset() 32 | } 33 | 34 | func (b *lockedBuffer) String() string { 35 | b.mu.RLock() 36 | defer b.mu.RUnlock() 37 | return b.buff.String() 38 | } 39 | 40 | //nolint:paralleltest // shared state between subtests 41 | func TestTee(t *testing.T) { 42 | t.Parallel() 43 | 44 | clock := clock.NewMock() 45 | 46 | var buff lockedBuffer 47 | r, err := os.CreateTemp(t.TempDir(), "file") 48 | require.NoError(t, err) 49 | 50 | tee := Tee{ 51 | W: &buff, 52 | R: r, 53 | Clock: clock, 54 | } 55 | tee.Start() 56 | defer func() { 57 | assert.NoError(t, r.Close()) 58 | assert.NoError(t, tee.Stop()) 59 | }() 60 | 61 | w, err := os.OpenFile(r.Name(), os.O_WRONLY, 0o644) 62 | if !assert.NoError(t, err) { 63 | return 64 | } 65 | defer func() { assert.NoError(t, w.Close()) }() 66 | 67 | t.Run("empty", func(t *testing.T) { 68 | assert.Empty(t, buff.String()) 69 | }) 70 | 71 | t.Run("write", func(t *testing.T) { 72 | defer buff.Reset() 73 | 74 | _, err := io.WriteString(w, "hello") 75 | require.NoError(t, err) 76 | 77 | clock.Add(_defaultDelay) 78 | assert.Equal(t, "hello", buff.String()) 79 | }) 80 | 81 | t.Run("write delayed", func(t *testing.T) { 82 | defer buff.Reset() 83 | 84 | for i := 0; i < 10; i++ { 85 | clock.Add(_defaultDelay * 10) 86 | assert.Empty(t, buff.String()) 87 | } 88 | 89 | _, err := io.WriteString(w, "world") 90 | require.NoError(t, err) 91 | 92 | clock.Add(_defaultDelay) 93 | assert.Equal(t, "world", buff.String()) 94 | }) 95 | } 96 | 97 | func TestTeeError(t *testing.T) { 98 | t.Parallel() 99 | 100 | var buff lockedBuffer 101 | defer func() { assert.Empty(t, buff.String()) }() 102 | 103 | r := iotest.ErrReader(errors.New("great sadness")) 104 | tee := Tee{ 105 | W: &buff, 106 | R: io.NopCloser(r), 107 | } 108 | tee.Start() 109 | 110 | err := tee.Stop() 111 | require.Error(t, err) 112 | assert.Contains(t, err.Error(), "great sadness") 113 | } 114 | 115 | func TestTeeClosed(t *testing.T) { 116 | t.Parallel() 117 | 118 | var buff lockedBuffer 119 | defer func() { assert.Empty(t, buff.String()) }() 120 | 121 | r, err := os.CreateTemp(t.TempDir(), "file") 122 | require.NoError(t, err) 123 | 124 | tee := Tee{ 125 | W: &buff, 126 | R: r, 127 | } 128 | tee.Start() 129 | defer func() { 130 | assert.NoError(t, tee.Stop()) 131 | }() 132 | 133 | assert.NoError(t, r.Close()) 134 | } 135 | -------------------------------------------------------------------------------- /internal/tmux/doc.go: -------------------------------------------------------------------------------- 1 | // Package tmux provides APIs to interact with the tmux(1) terminal multiplexer. 2 | // 3 | // It provides a [Driver] interface and a [ShellDriver] implementation. 4 | // These provides direct, low-level interaction with tmux operations. 5 | package tmux 6 | -------------------------------------------------------------------------------- /internal/tmux/driver.go: -------------------------------------------------------------------------------- 1 | package tmux 2 | 3 | import "github.com/abhinav/tmux-fastcopy/internal/stringobj" 4 | 5 | // Driver is a low-level API to access tmux. This maps directly to tmux 6 | // commands. 7 | type Driver interface { 8 | // NewSession runs the tmux new-session command and returns its output. 9 | NewSession(NewSessionRequest) ([]byte, error) 10 | 11 | // DisplayMessage runs the tmux display-message command and returns its 12 | // output. 13 | DisplayMessage(DisplayMessageRequest) ([]byte, error) 14 | 15 | // CapturePane runs the tmux capture-pane command and returns its 16 | // output. 17 | CapturePane(CapturePaneRequest) ([]byte, error) 18 | 19 | // SwapPane runs the tmux swap-pane command. 20 | SwapPane(SwapPaneRequest) error 21 | 22 | // ResizePane runs the tmux resize-pane command. 23 | ResizePane(ResizePaneRequest) error 24 | 25 | // ResizeWindow runs the tmux resize-window command. 26 | ResizeWindow(ResizeWindowRequest) error 27 | 28 | // WaitForSignal runs the tmux wait-for command, waiting for a 29 | // corresponding SendSignal command. 30 | WaitForSignal(string) error 31 | 32 | // SendSignal runs the tmux wait-for command, activating anyone waiting 33 | // for this signal. 34 | SendSignal(string) error 35 | 36 | // ShowOptions runs the tmux show-options command and returns its 37 | // output. 38 | ShowOptions(ShowOptionsRequest) ([]byte, error) 39 | 40 | // SetOption runs the tmux set-option command. 41 | SetOption(SetOptionRequest) error 42 | } 43 | 44 | // SetOptionRequest specifies the parameters for the set-option command. 45 | type SetOptionRequest struct { 46 | // Name of the option to set. 47 | Name string 48 | 49 | // Value to set the option to. 50 | Value string 51 | 52 | // Whether this option should be changed globally. 53 | Global bool 54 | } 55 | 56 | // NewSessionRequest specifies the parameter for a new-session command. 57 | type NewSessionRequest struct { 58 | // Name of the session, if any. 59 | Name string 60 | 61 | // Output format, if any. Without this, NewSession will not return any 62 | // output. 63 | Format string 64 | 65 | // Size of the new window. 66 | Width, Height int 67 | 68 | // Whether the new session should be detached from this client. 69 | Detached bool 70 | 71 | // Additional environment variables to pass to the command in the new 72 | // session. 73 | Env []string 74 | 75 | // Command to run in this new window. Must have at least one element. 76 | Command []string 77 | } 78 | 79 | func (r NewSessionRequest) String() string { 80 | var b stringobj.Builder 81 | b.Put("name", r.Name) 82 | b.Put("format", r.Format) 83 | b.Put("width", r.Width) 84 | b.Put("height", r.Height) 85 | b.Put("detached", r.Detached) 86 | b.Put("env", r.Env) 87 | b.Put("command", r.Command) 88 | return b.String() 89 | } 90 | 91 | // CapturePaneRequest specifies the parameters for a capture-pane command. 92 | type CapturePaneRequest struct { 93 | // Pane to capture. Defaults to current. 94 | Pane string 95 | 96 | // Start and end positions of the captured text. Negative lines are 97 | // positions in history. 98 | StartLine, EndLine int 99 | } 100 | 101 | func (r CapturePaneRequest) String() string { 102 | var b stringobj.Builder 103 | b.Put("pane", r.Pane) 104 | b.Put("startLine", r.StartLine) 105 | b.Put("endLine", r.EndLine) 106 | return b.String() 107 | } 108 | 109 | // DisplayMessageRequest specifies the parameters for a display-message 110 | // command. 111 | type DisplayMessageRequest struct { 112 | // Pane to capture. Defaults to current. 113 | Pane string 114 | 115 | // Message to display. 116 | Message string 117 | } 118 | 119 | func (r DisplayMessageRequest) String() string { 120 | var b stringobj.Builder 121 | b.Put("pane", r.Pane) 122 | b.Put("message", r.Message) 123 | return b.String() 124 | } 125 | 126 | // SwapPaneRequest specifies the parameters for a swap-pane command. 127 | type SwapPaneRequest struct { 128 | // Source pane. Defaults to current. 129 | Source string 130 | 131 | // Destination pane to swap the source with. 132 | Destination string 133 | } 134 | 135 | func (r SwapPaneRequest) String() string { 136 | var b stringobj.Builder 137 | b.Put("source", r.Source) 138 | b.Put("destination", r.Destination) 139 | return b.String() 140 | } 141 | 142 | // ResizeWindowRequest specifies the parameters for a resize-window command. 143 | type ResizeWindowRequest struct { 144 | Window string 145 | Width, Height int 146 | } 147 | 148 | func (r ResizeWindowRequest) String() string { 149 | var b stringobj.Builder 150 | b.Put("window", r.Window) 151 | b.Put("width", r.Width) 152 | b.Put("height", r.Height) 153 | return b.String() 154 | } 155 | 156 | // ShowOptionsRequest specifies the parameters for a show-options command. 157 | type ShowOptionsRequest struct { 158 | Global bool // show global options 159 | } 160 | 161 | func (r ShowOptionsRequest) String() string { 162 | var b stringobj.Builder 163 | b.Put("global", r.Global) 164 | return b.String() 165 | } 166 | 167 | // ResizePaneRequest specifies the parameters for a resize-pane command. 168 | type ResizePaneRequest struct { 169 | Target string // target pane 170 | ToggleZoom bool // whether to toggle zoom 171 | } 172 | 173 | func (r ResizePaneRequest) String() string { 174 | var b stringobj.Builder 175 | b.Put("target", r.Target) 176 | b.Put("toggleZoom", r.ToggleZoom) 177 | return b.String() 178 | } 179 | -------------------------------------------------------------------------------- /internal/tmux/gen.go: -------------------------------------------------------------------------------- 1 | package tmux 2 | 3 | //go:generate mockgen -destination tmuxtest/mock_driver.go -package tmuxtest github.com/abhinav/tmux-fastcopy/internal/tmux Driver 4 | -------------------------------------------------------------------------------- /internal/tmux/inspect.go: -------------------------------------------------------------------------------- 1 | package tmux 2 | 3 | import ( 4 | "github.com/abhinav/tmux-fastcopy/internal/stringobj" 5 | "github.com/abhinav/tmux-fastcopy/internal/tmux/tmuxfmt" 6 | ) 7 | 8 | // PaneMode specifies the mode in which the pane is. 9 | type PaneMode string 10 | 11 | const ( 12 | // NormalMode specifies that the tmux pane is in normal mode, at the 13 | // bottom of the screen. 14 | NormalMode PaneMode = "normal-mode" 15 | 16 | // CopyMode indicates that the tmux pane is in copy mode, and may be 17 | // scrolled up. 18 | CopyMode PaneMode = "copy-mode" 19 | ) 20 | 21 | // PaneInfo reports information about a tmux pane. 22 | type PaneInfo struct { 23 | ID string 24 | WindowID string 25 | Width, Height int 26 | Mode PaneMode 27 | ScrollPosition int 28 | WindowZoomed bool 29 | 30 | // Current path of the pane, if available. 31 | CurrentPath string 32 | } 33 | 34 | func (i *PaneInfo) String() string { 35 | var b stringobj.Builder 36 | b.Put("id", i.ID) 37 | b.Put("windowID", i.WindowID) 38 | b.Put("width", i.Width) 39 | b.Put("height", i.Height) 40 | b.Put("mode", i.Mode) 41 | b.Put("scrollPosition", i.ScrollPosition) 42 | b.Put("currentPath", i.CurrentPath) 43 | return b.String() 44 | } 45 | 46 | var ( 47 | _paneCurrentPath = tmuxfmt.Var("pane_current_path") 48 | _paneID = tmuxfmt.Var("pane_id") 49 | _paneWidth = tmuxfmt.Var("pane_width") 50 | _paneHeight = tmuxfmt.Var("pane_height") 51 | _paneMode = tmuxfmt.Ternary{ 52 | Cond: tmuxfmt.Var("pane_in_mode"), 53 | Then: tmuxfmt.Var("pane_mode"), 54 | Else: tmuxfmt.String("normal-mode"), 55 | } 56 | _paneInCopyMode = tmuxfmt.Binary{ 57 | LHS: tmuxfmt.Var("pane_mode"), 58 | Op: tmuxfmt.Equals, 59 | RHS: tmuxfmt.String("copy-mode"), 60 | } 61 | _paneScrollPosition = tmuxfmt.Ternary{ 62 | Cond: _paneInCopyMode, 63 | Then: tmuxfmt.Var("scroll_position"), 64 | Else: tmuxfmt.Int(0), 65 | } 66 | _windowID = tmuxfmt.Var("window_id") 67 | _windowZoomed = tmuxfmt.Var("window_zoomed_flag") 68 | ) 69 | 70 | // InspectPane inspects a tmux pane and reports information about it. The 71 | // argument identifies the pane we want to inspect, defaulting to the current 72 | // pane if none is specified. 73 | func InspectPane(driver Driver, identifier string) (*PaneInfo, error) { 74 | var ( 75 | info PaneInfo 76 | fc tmuxfmt.Capturer 77 | ) 78 | fc.StringVar(&info.ID, _paneID) 79 | fc.StringVar(&info.WindowID, _windowID) 80 | fc.IntVar(&info.Width, _paneWidth) 81 | fc.IntVar(&info.Height, _paneHeight) 82 | fc.StringVar((*string)(&info.Mode), _paneMode) 83 | fc.IntVar(&info.ScrollPosition, _paneScrollPosition) 84 | fc.BoolVar(&info.WindowZoomed, _windowZoomed) 85 | fc.StringVar(&info.CurrentPath, _paneCurrentPath) 86 | 87 | msg, parse := fc.Prepare() 88 | out, err := driver.DisplayMessage(DisplayMessageRequest{ 89 | Pane: identifier, 90 | Message: msg, 91 | }) 92 | if err == nil { 93 | err = parse(out) 94 | } 95 | return &info, err 96 | } 97 | -------------------------------------------------------------------------------- /internal/tmux/inspect_test.go: -------------------------------------------------------------------------------- 1 | package tmux_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/abhinav/tmux-fastcopy/internal/tmux" 7 | "github.com/abhinav/tmux-fastcopy/internal/tmux/tmuxtest" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "go.uber.org/mock/gomock" 11 | ) 12 | 13 | func TestInspectPane(t *testing.T) { 14 | t.Parallel() 15 | 16 | message := []byte("%42\t@123\t80\t40\tcopy-mode\t40\t0\t/home/user/dir") 17 | 18 | ctrl := gomock.NewController(t) 19 | mockTmux := tmuxtest.NewMockDriver(ctrl) 20 | 21 | mockTmux.EXPECT(). 22 | DisplayMessage(gomock.Any()). 23 | Return(message, nil) 24 | 25 | got, err := tmux.InspectPane(mockTmux, "foo") 26 | require.NoError(t, err) 27 | assert.Equal(t, &tmux.PaneInfo{ 28 | ID: "%42", 29 | WindowID: "@123", 30 | Width: 80, 31 | Height: 40, 32 | Mode: tmux.CopyMode, 33 | ScrollPosition: 40, 34 | CurrentPath: "/home/user/dir", 35 | }, got) 36 | 37 | t.Run("String", func(t *testing.T) { 38 | t.Parallel() 39 | 40 | s := got.String() 41 | assert.Contains(t, s, "id: %42") 42 | assert.Contains(t, s, "windowID: @123") 43 | assert.Contains(t, s, "width: 80") 44 | assert.Contains(t, s, "height: 40") 45 | assert.Contains(t, s, "mode: copy-mode") 46 | assert.Contains(t, s, "scrollPosition: 40") 47 | assert.Contains(t, s, "currentPath: /home/user/dir") 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /internal/tmux/shell.go: -------------------------------------------------------------------------------- 1 | package tmux 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "os/exec" 7 | "strconv" 8 | "sync" 9 | 10 | "github.com/abhinav/tmux-fastcopy/internal/log" 11 | ) 12 | 13 | const ( 14 | _defaultTmux = "tmux" 15 | _defaultEnv = "/usr/bin/env" 16 | ) 17 | 18 | // minimal hook to change how exec.Cmd are run. Tests will provide a different 19 | // implementation. 20 | type runner struct { 21 | Run func(*exec.Cmd) error 22 | Output func(*exec.Cmd) ([]byte, error) 23 | } 24 | 25 | var defaultRunner = runner{ 26 | Run: (*exec.Cmd).Run, 27 | Output: (*exec.Cmd).Output, 28 | } 29 | 30 | // ShellDriver is a Driver implementation that shells out to tmux to run 31 | // commands. 32 | type ShellDriver struct { 33 | // Path to the tmux executable. Defaults to "tmux". 34 | Path string 35 | 36 | // Path to the env command. Defaults to /usr/bin/env. 37 | Env string 38 | 39 | log *log.Logger 40 | run *runner 41 | once sync.Once 42 | } 43 | 44 | var _ Driver = (*ShellDriver)(nil) 45 | 46 | func (s *ShellDriver) init() { 47 | s.once.Do(func() { 48 | if s.log == nil { 49 | s.log = log.Discard 50 | } 51 | 52 | if s.Path == "" { 53 | s.Path = _defaultTmux 54 | } 55 | 56 | if s.Env == "" { 57 | s.Env = _defaultEnv 58 | } 59 | 60 | if s.run == nil { 61 | s.run = &defaultRunner 62 | } 63 | }) 64 | } 65 | 66 | // SetLogger specifies the logger for the ShellDriver. By default, the 67 | // ShellDriver does not log anything. 68 | func (s *ShellDriver) SetLogger(log *log.Logger) { 69 | s.log = log 70 | } 71 | 72 | func (s *ShellDriver) cmd(args ...string) *exec.Cmd { 73 | cmd := exec.Command(s.Path, args...) 74 | return cmd 75 | } 76 | 77 | // errorWriter sets the provided io.Writers to the same log.Writer and returns 78 | // a function to close them. 79 | // 80 | // cmd := s.cmd("some", "cmd") 81 | // defer s.errorWriter(&cmd.Stderr)() 82 | func (s *ShellDriver) errorWriter(ws ...*io.Writer) (cleanup func()) { 83 | writer := &log.Writer{Log: s.log, Level: log.Error} 84 | for _, w := range ws { 85 | *w = writer 86 | } 87 | return func() { _ = writer.Close() } 88 | } 89 | 90 | // NewSession runs the tmux new-session command. 91 | func (s *ShellDriver) NewSession(req NewSessionRequest) ([]byte, error) { 92 | s.init() 93 | 94 | args := []string{"new-session"} 95 | if n := req.Name; len(n) > 0 { 96 | args = append(args, "-s", n) 97 | } 98 | if fmt := req.Format; len(fmt) > 0 { 99 | args = append(args, "-P", "-F", fmt) 100 | } 101 | if w := req.Width; w > 0 { 102 | args = append(args, "-x", strconv.Itoa(w)) 103 | } 104 | if h := req.Height; h > 0 { 105 | args = append(args, "-y", strconv.Itoa(h)) 106 | } 107 | if req.Detached { 108 | args = append(args, "-d") 109 | } 110 | 111 | // We could use the -e flg to set the environment variables, but that 112 | // was added in tmux 3.2. Instead, use, 113 | // 114 | // /usr/bin/env K1=V1 K2=V2 cmd "$1" "$2" ... 115 | if len(req.Env) > 0 { 116 | if len(req.Command) == 0 { 117 | return nil, errors.New("env can be set only if command is set") 118 | } 119 | setenv := make([]string, len(req.Env)+1) 120 | setenv[0] = s.Env 121 | copy(setenv[1:], req.Env) 122 | args = append(args, setenv...) 123 | } 124 | 125 | args = append(args, req.Command...) 126 | cmd := s.cmd(args...) 127 | defer s.errorWriter(&cmd.Stderr)() 128 | 129 | s.log.Debugf("new session: %v", req) 130 | return s.run.Output(cmd) 131 | } 132 | 133 | // CapturePane runs the capture-pane command and returns its output. 134 | func (s *ShellDriver) CapturePane(req CapturePaneRequest) ([]byte, error) { 135 | s.init() 136 | 137 | args := []string{"capture-pane", "-p", "-J"} 138 | if len(req.Pane) > 0 { 139 | args = append(args, "-t", req.Pane) 140 | } 141 | if s := req.StartLine; s != 0 { 142 | args = append(args, "-S", strconv.Itoa(s)) 143 | } 144 | if e := req.EndLine; e != 0 { 145 | args = append(args, "-E", strconv.Itoa(e)) 146 | } 147 | cmd := s.cmd(args...) 148 | defer s.errorWriter(&cmd.Stderr)() 149 | 150 | s.log.Debugf("capture pane: %v", req) 151 | return s.run.Output(cmd) 152 | } 153 | 154 | // SetOption runs the set-option command with the given parameters. 155 | func (s *ShellDriver) SetOption(req SetOptionRequest) error { 156 | s.init() 157 | 158 | args := []string{"set-option"} 159 | if req.Global { 160 | args = append(args, "-g") 161 | } 162 | args = append(args, req.Name, req.Value) 163 | 164 | cmd := s.cmd(args...) 165 | defer s.errorWriter(&cmd.Stderr)() 166 | 167 | s.log.Debugf("set-option: %v", req) 168 | return s.run.Run(cmd) 169 | } 170 | 171 | // DisplayMessage displays the given message in tmux and returns its output. 172 | func (s *ShellDriver) DisplayMessage(req DisplayMessageRequest) ([]byte, error) { 173 | s.init() 174 | 175 | args := []string{"display-message", "-p"} 176 | if len(req.Pane) > 0 { 177 | args = append(args, "-t", req.Pane) 178 | } 179 | args = append(args, req.Message) 180 | 181 | cmd := s.cmd(args...) 182 | defer s.errorWriter(&cmd.Stderr)() 183 | 184 | s.log.Debugf("display message: %v", req) 185 | return s.run.Output(cmd) 186 | } 187 | 188 | // SwapPane runs the swap-pane command. 189 | func (s *ShellDriver) SwapPane(req SwapPaneRequest) error { 190 | s.init() 191 | 192 | args := []string{"swap-pane", "-t", req.Destination} 193 | if s := req.Source; len(s) > 0 { 194 | args = append(args, "-s", s) 195 | } 196 | 197 | cmd := s.cmd(args...) 198 | defer s.errorWriter(&cmd.Stdout, &cmd.Stderr)() 199 | 200 | s.log.Debugf("swap pane: %v", req) 201 | return s.run.Run(cmd) 202 | } 203 | 204 | // ResizePane runs the resize-pane command. 205 | func (s *ShellDriver) ResizePane(req ResizePaneRequest) error { 206 | s.init() 207 | 208 | args := []string{"resize-pane", "-t", req.Target} 209 | if req.ToggleZoom { 210 | args = append(args, "-Z") 211 | } 212 | 213 | cmd := s.cmd(args...) 214 | defer s.errorWriter(&cmd.Stdout, &cmd.Stderr)() 215 | 216 | s.log.Debugf("resize pane: %v", req) 217 | return s.run.Run(cmd) 218 | } 219 | 220 | // ResizeWindow runs the resize-window command. 221 | func (s *ShellDriver) ResizeWindow(req ResizeWindowRequest) error { 222 | s.init() 223 | 224 | args := []string{"resize-window"} 225 | if w := req.Window; len(w) > 0 { 226 | args = append(args, "-t", w) 227 | } 228 | 229 | if w := req.Width; w > 0 { 230 | args = append(args, "-x", strconv.Itoa(w)) 231 | } 232 | if h := req.Height; h > 0 { 233 | args = append(args, "-y", strconv.Itoa(h)) 234 | } 235 | 236 | cmd := s.cmd(args...) 237 | defer s.errorWriter(&cmd.Stdout, &cmd.Stderr)() 238 | 239 | s.log.Debugf("resize window: %v", req) 240 | return s.run.Run(cmd) 241 | } 242 | 243 | // WaitForSignal runs the wait-for command. 244 | func (s *ShellDriver) WaitForSignal(sig string) error { 245 | s.init() 246 | cmd := s.cmd("wait-for", sig) 247 | defer s.errorWriter(&cmd.Stdout, &cmd.Stderr)() 248 | 249 | s.log.Debugf("wait-for: %v", sig) 250 | return s.run.Run(cmd) 251 | } 252 | 253 | // SendSignal runs the wait-for -S command. 254 | func (s *ShellDriver) SendSignal(sig string) error { 255 | s.init() 256 | cmd := s.cmd("wait-for", "-S", sig) 257 | defer s.errorWriter(&cmd.Stdout, &cmd.Stderr)() 258 | 259 | s.log.Debugf("wait-for -S: %v", sig) 260 | return s.run.Run(cmd) 261 | } 262 | 263 | // ShowOptions runs the show-options command. 264 | func (s *ShellDriver) ShowOptions(req ShowOptionsRequest) ([]byte, error) { 265 | s.init() 266 | 267 | args := []string{"show-options"} 268 | if req.Global { 269 | args = append(args, "-g") 270 | } 271 | cmd := s.cmd(args...) 272 | defer s.errorWriter(&cmd.Stderr)() 273 | 274 | s.log.Debugf("show options: %v", req) 275 | return s.run.Output(cmd) 276 | } 277 | -------------------------------------------------------------------------------- /internal/tmux/tmuxfmt/capture.go: -------------------------------------------------------------------------------- 1 | package tmuxfmt 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // Value receives a value from the tmux output as a string and parses it. 10 | type Value interface { 11 | Set(string) error 12 | } 13 | 14 | type captureExpr struct { 15 | Expr Expr 16 | Value Value 17 | } 18 | 19 | // Capturer captures the output of tmuxfmt expressions into Go values. 20 | type Capturer struct { 21 | exprs []captureExpr 22 | } 23 | 24 | // Prepare prepares the specified expressions into a tmuxfmt message. The 25 | // returned capture function will parse the resultant text and fill the 26 | // previously recorded pointers. 27 | func (c *Capturer) Prepare() (msg string, capure func([]byte) error) { 28 | exprs := c.exprs 29 | rendered := make([]string, len(exprs)) 30 | for i, e := range c.exprs { 31 | rendered[i] = Render(e.Expr) 32 | } 33 | 34 | return strings.Join(rendered, "\t"), func(bs []byte) error { 35 | for i, s := range strings.Split(string(bs), "\t") { 36 | if i >= len(exprs) { 37 | break 38 | } 39 | 40 | s = strings.TrimSpace(s) 41 | if err := exprs[i].Value.Set(s); err != nil { 42 | return fmt.Errorf("capture %q: %w", rendered[i], err) 43 | } 44 | } 45 | 46 | return nil 47 | } 48 | } 49 | 50 | // Var records that the output of the given tmuxfmt expression should be loaded 51 | // into the specified value. 52 | func (c *Capturer) Var(v Value, e Expr) { 53 | c.exprs = append(c.exprs, captureExpr{Expr: e, Value: v}) 54 | } 55 | 56 | // StringVar specifies that the output of the provided expression should fill 57 | // this string pointer. 58 | func (c *Capturer) StringVar(ptr *string, e Expr) { 59 | c.Var((*stringValue)(ptr), e) 60 | } 61 | 62 | type stringValue string 63 | 64 | func (v *stringValue) Set(s string) error { 65 | *(*string)(v) = s 66 | return nil 67 | } 68 | 69 | // IntVar specifies that the output of the provided expression should be parsed 70 | // as an integer and fill this integer pointer. 71 | func (c *Capturer) IntVar(ptr *int, e Expr) { 72 | c.Var((*intValue)(ptr), e) 73 | } 74 | 75 | type intValue int 76 | 77 | func (v *intValue) Set(s string) error { 78 | i, err := strconv.Atoi(s) 79 | if err == nil { 80 | *(*int)(v) = i 81 | } 82 | return err 83 | } 84 | 85 | // BoolVar specifies that the output of the provided expression should be 86 | // parsed as a boolean and fill this boolean pointer. 87 | func (c *Capturer) BoolVar(ptr *bool, e Expr) { 88 | c.Var((*boolValue)(ptr), e) 89 | } 90 | 91 | type boolValue bool 92 | 93 | func (v *boolValue) Set(s string) error { 94 | *(*bool)(v) = len(s) > 0 && s != "0" 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /internal/tmux/tmuxfmt/capture_test.go: -------------------------------------------------------------------------------- 1 | package tmuxfmt 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestCapturer(t *testing.T) { 11 | t.Parallel() 12 | 13 | tests := []struct { 14 | desc string 15 | give []byte // output from tmux 16 | exprs []Expr // expressions to prepare 17 | want []interface{} // expected values in-order 18 | wantErr string // error (if set, want is used only to get types) 19 | }{ 20 | { 21 | desc: "string", 22 | exprs: []Expr{Var("pane_id")}, 23 | give: []byte("%42\n"), 24 | want: []interface{}{"%42"}, 25 | }, 26 | { 27 | desc: "int", 28 | exprs: []Expr{Var("height")}, 29 | give: []byte("42"), 30 | want: []interface{}{42}, 31 | }, 32 | { 33 | desc: "bool", 34 | exprs: []Expr{Var("window_zoomed")}, 35 | give: []byte("1\n"), 36 | want: []interface{}{true}, 37 | }, 38 | { 39 | desc: "multiple", 40 | exprs: []Expr{ 41 | Var("pane_id"), 42 | Var("height"), 43 | Var("window_zoomed"), 44 | }, 45 | give: []byte("%42 100 true\n"), 46 | want: []interface{}{"%42", 100, true}, 47 | }, 48 | { 49 | desc: "empty", 50 | exprs: []Expr{ 51 | Var("pane_in_state"), 52 | Var("pane_state"), 53 | }, 54 | give: []byte("0 \n"), 55 | want: []interface{}{false, ""}, 56 | }, 57 | { 58 | desc: "int/error", 59 | exprs: []Expr{Var("height")}, 60 | give: []byte("four\n"), 61 | want: []interface{}{0}, 62 | wantErr: `capture "#{height}": .*invalid syntax`, 63 | }, 64 | { 65 | desc: "too many results", 66 | exprs: []Expr{Var("pane_width")}, 67 | give: []byte("80 40 10\n"), 68 | want: []interface{}{80}, 69 | }, 70 | } 71 | 72 | for _, tt := range tests { 73 | tt := tt 74 | t.Run(tt.desc, func(t *testing.T) { 75 | t.Parallel() 76 | 77 | require.Len(t, tt.want, len(tt.exprs), "invalid test: "+ 78 | "number of expressions must match the"+ 79 | "number of expected values "+ 80 | "if an error is not expectedd") 81 | 82 | got := make([]interface{}, len(tt.exprs)) // list of pointers 83 | want := make([]interface{}, len(tt.exprs)) // list of pointers 84 | 85 | var c Capturer 86 | for i, expr := range tt.exprs { 87 | switch w := tt.want[i].(type) { 88 | case string: 89 | g := new(string) 90 | c.StringVar(g, expr) 91 | want[i] = &w 92 | got[i] = g 93 | 94 | case int: 95 | g := new(int) 96 | c.IntVar(g, expr) 97 | want[i] = &w 98 | got[i] = g 99 | 100 | case bool: 101 | g := new(bool) 102 | c.BoolVar(g, expr) 103 | want[i] = &w 104 | got[i] = g 105 | 106 | default: 107 | t.Fatalf("unsupported want: %v (%T)", w, w) 108 | } 109 | } 110 | 111 | _, capture := c.Prepare() 112 | err := capture(tt.give) 113 | if len(tt.wantErr) > 0 { 114 | assert.Error(t, err) 115 | assert.Regexp(t, tt.wantErr, err.Error()) 116 | return 117 | } 118 | 119 | require.NoError(t, err) 120 | assert.Equal(t, want, got) 121 | }) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /internal/tmux/tmuxfmt/doc.go: -------------------------------------------------------------------------------- 1 | // Package tmuxfmt constructs tmux FORMATS compatible strings. 2 | // 3 | // See http://man.openbsd.org/OpenBSD-current/man1/tmux.1#FORMATS for a 4 | // specification of the format of these strings. 5 | package tmuxfmt 6 | -------------------------------------------------------------------------------- /internal/tmux/tmuxfmt/expr.go: -------------------------------------------------------------------------------- 1 | package tmuxfmt 2 | 3 | import "fmt" 4 | 5 | // Expr is the base interface for expressions accepted by the tmux message 6 | // format. 7 | type Expr interface{ expr() } 8 | 9 | // String is a string literal in an expression. 10 | // 11 | // value 12 | type String string // must not contain tabs 13 | 14 | func (String) expr() {} 15 | 16 | // Int is an integer literal in an expression. 17 | // 18 | // 42 19 | type Int int 20 | 21 | func (Int) expr() {} 22 | 23 | // Var is a reference to a variable. 24 | // 25 | // #{name} 26 | type Var string 27 | 28 | func (Var) expr() {} 29 | 30 | // Ternary is a conditional operator that evaluates the first expression and 31 | // returns either the second or the third expression based on whether it's 32 | // true. 33 | // 34 | // #{?cond,then,else} 35 | type Ternary struct { 36 | Cond Expr 37 | Then Expr 38 | Else Expr 39 | } 40 | 41 | func (Ternary) expr() {} 42 | 43 | // BinaryOp is a binary operation. 44 | type BinaryOp int 45 | 46 | // Supported binary operations. 47 | const ( 48 | Equals BinaryOp = iota // == 49 | NotEquals // != 50 | LessThan // < 51 | GreaterThan // > 52 | LessThanEquals // <= 53 | GreaterThanEquals // >= 54 | ) 55 | 56 | func (op BinaryOp) String() string { 57 | switch op { 58 | case Equals: 59 | return "==" 60 | case NotEquals: 61 | return "!=" 62 | case LessThan: 63 | return "<" 64 | case GreaterThan: 65 | return ">" 66 | case LessThanEquals: 67 | return "<=" 68 | case GreaterThanEquals: 69 | return ">=" 70 | default: 71 | return fmt.Sprintf("BinaryOp(%d)", int(op)) 72 | } 73 | } 74 | 75 | // Binary is a binary expression. 76 | // 77 | // #{op:lhs,rhs} 78 | type Binary struct { 79 | Op BinaryOp 80 | LHS, RHS Expr 81 | } 82 | 83 | func (Binary) expr() {} 84 | -------------------------------------------------------------------------------- /internal/tmux/tmuxfmt/expr_test.go: -------------------------------------------------------------------------------- 1 | package tmuxfmt 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestBinaryOp_String(t *testing.T) { 10 | t.Parallel() 11 | 12 | tests := []struct { 13 | desc string 14 | give BinaryOp 15 | want string 16 | }{ 17 | {"equals", Equals, "=="}, 18 | {"not equals", NotEquals, "!="}, 19 | {"less than", LessThan, "<"}, 20 | {"greater than", GreaterThan, ">"}, 21 | {"less than equals", LessThanEquals, "<="}, 22 | {"greater than equals", GreaterThanEquals, ">="}, 23 | {"unrecognized", BinaryOp(-1), "BinaryOp(-1)"}, 24 | } 25 | 26 | for _, tt := range tests { 27 | tt := tt 28 | t.Run(tt.desc, func(t *testing.T) { 29 | t.Parallel() 30 | 31 | assert.Equal(t, tt.want, tt.give.String()) 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/tmux/tmuxfmt/render.go: -------------------------------------------------------------------------------- 1 | package tmuxfmt 2 | 3 | import ( 4 | "bytes" 5 | "strconv" 6 | "strings" 7 | "unicode/utf8" 8 | ) 9 | 10 | // Render renders the provided tmux expressions separated by the given 11 | // delimiter in a format compatible with tmux's FORMATS section. 12 | func Render(e Expr) string { 13 | var out strings.Builder 14 | render(&out, e, false) 15 | return out.String() 16 | } 17 | 18 | func render(w *strings.Builder, e Expr, escapeString bool) { 19 | switch e := e.(type) { 20 | case String: 21 | if escapeString { 22 | renderStringEscaped(w, []byte(e)) 23 | } else { 24 | w.WriteString(string(e)) 25 | } 26 | 27 | case Int: 28 | w.WriteString(strconv.Itoa(int(e))) 29 | 30 | case Var: 31 | w.WriteString("#{") 32 | w.WriteString(string(e)) 33 | w.WriteString("}") 34 | 35 | case Ternary: 36 | w.WriteString("#{?") 37 | render(w, e.Cond, true) 38 | w.WriteString(",") 39 | render(w, e.Then, true) 40 | w.WriteString(",") 41 | render(w, e.Else, true) 42 | w.WriteString("}") 43 | 44 | case Binary: 45 | w.WriteString("#{") 46 | w.WriteString(e.Op.String()) 47 | w.WriteString(":") 48 | render(w, e.LHS, true) 49 | w.WriteString(",") 50 | render(w, e.RHS, true) 51 | w.WriteString("}") 52 | } 53 | } 54 | 55 | const _escapedRunes = ",#}" 56 | 57 | func renderStringEscaped(w *strings.Builder, b []byte) { 58 | for len(b) > 0 { 59 | idx := bytes.IndexAny(b, _escapedRunes) 60 | if idx < 0 { 61 | w.Write(b) 62 | return 63 | } 64 | 65 | w.Write(b[:idx]) 66 | b = b[idx:] 67 | 68 | r, sz := utf8.DecodeRune(b) 69 | w.WriteRune('#') 70 | w.WriteRune(r) 71 | b = b[sz:] 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /internal/tmux/tmuxfmt/render_test.go: -------------------------------------------------------------------------------- 1 | package tmuxfmt 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestRender(t *testing.T) { 10 | t.Parallel() 11 | 12 | tests := []struct { 13 | desc string 14 | give Expr 15 | want string 16 | }{ 17 | { 18 | desc: "string", 19 | give: String("foo"), 20 | want: "foo", 21 | }, 22 | { 23 | desc: "int", 24 | give: Int(42), 25 | want: "42", 26 | }, 27 | { 28 | desc: "var", 29 | give: Var("pane_id"), 30 | want: "#{pane_id}", 31 | }, 32 | { 33 | desc: "ternary", 34 | give: Ternary{ 35 | Cond: Var("pane_in_mode"), 36 | Then: Var("pane_mode"), 37 | Else: String("normal-mode"), 38 | }, 39 | want: "#{?#{pane_in_mode},#{pane_mode},normal-mode}", 40 | }, 41 | { 42 | desc: "ternary/string escape", 43 | give: Ternary{ 44 | Cond: Var("pane_in_mode"), 45 | Then: String("a,b"), 46 | Else: String("x,y"), 47 | }, 48 | want: "#{?#{pane_in_mode},a#,b,x#,y}", 49 | }, 50 | { 51 | desc: "binary/eq", 52 | give: Binary{ 53 | Op: Equals, 54 | LHS: Var("cursor_x"), 55 | RHS: Var("copy_cursor_x"), 56 | }, 57 | want: "#{==:#{cursor_x},#{copy_cursor_x}}", 58 | }, 59 | { 60 | desc: "binary/ne", 61 | give: Binary{ 62 | Op: NotEquals, 63 | LHS: Var("cursor_x"), 64 | RHS: Var("cursor_y"), 65 | }, 66 | want: "#{!=:#{cursor_x},#{cursor_y}}", 67 | }, 68 | { 69 | desc: "binary/lt", 70 | give: Binary{ 71 | Op: LessThan, 72 | LHS: Var("cursor_x"), 73 | RHS: Int(42), 74 | }, 75 | want: "#{<:#{cursor_x},42}", 76 | }, 77 | { 78 | desc: "binary/gt", 79 | give: Binary{ 80 | Op: GreaterThan, 81 | LHS: Var("cursor_x"), 82 | RHS: Var("scroll_position"), 83 | }, 84 | want: "#{>:#{cursor_x},#{scroll_position}}", 85 | }, 86 | { 87 | desc: "binary/lte", 88 | give: Binary{ 89 | Op: LessThanEquals, 90 | LHS: Var("cursor_x"), 91 | RHS: Var("pane_width"), 92 | }, 93 | want: "#{<=:#{cursor_x},#{pane_width}}", 94 | }, 95 | { 96 | desc: "binary/gte", 97 | give: Binary{ 98 | Op: GreaterThanEquals, 99 | LHS: Var("cursor_x"), 100 | RHS: Int(0), 101 | }, 102 | want: "#{>=:#{cursor_x},0}", 103 | }, 104 | } 105 | 106 | for _, tt := range tests { 107 | tt := tt 108 | t.Run(tt.desc, func(t *testing.T) { 109 | t.Parallel() 110 | 111 | assert.Equal(t, tt.want, Render(tt.give)) 112 | }) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /internal/tmux/tmuxopt/tmuxopt.go: -------------------------------------------------------------------------------- 1 | // Package tmuxopt provides an API for loading and parsing tmux options 2 | // into Go variables. 3 | // 4 | // It provides an API similar to the flag package, but for tmux options. 5 | package tmuxopt 6 | 7 | import ( 8 | "bufio" 9 | "bytes" 10 | "flag" 11 | "fmt" 12 | "strconv" 13 | "strings" 14 | "sync" 15 | 16 | "github.com/abhinav/tmux-fastcopy/internal/tmux" 17 | "go.uber.org/multierr" 18 | ) 19 | 20 | // Value is a receiver for a tmux option value. 21 | type Value interface { 22 | Set(value string) error 23 | } 24 | 25 | // MapValue is a receiver for a tmux map value. 26 | type MapValue interface { 27 | Put(key, value string) error 28 | } 29 | 30 | var _ Value = flag.Value(nil) // interface matching 31 | 32 | // Loader loads tmux options into user-specified variables. 33 | type Loader struct { 34 | Tmux tmux.Driver 35 | 36 | once sync.Once 37 | values map[string]Value 38 | maps map[string]MapValue // prefix => MapValue 39 | } 40 | 41 | func (l *Loader) init() { 42 | l.once.Do(func() { 43 | l.values = make(map[string]Value) 44 | l.maps = make(map[string]MapValue) 45 | }) 46 | } 47 | 48 | // Var specifies that the given option should be loaded into the provided Value 49 | // object. 50 | func (l *Loader) Var(val Value, option string) { 51 | l.init() 52 | 53 | l.values[option] = val 54 | } 55 | 56 | // MapVar specifies that options with the given prefix should be loaded into 57 | // the provided MapValue. 58 | // 59 | // To support maps, tmuxopt loader works by matching the provided prefix 60 | // against options produced by tmux. If an option name matches the given 61 | // prefix, the rest of that name is used as the map key and the value for that 62 | // option as the value for that key. 63 | // 64 | // For example, if the prefix is, "foo-item-", then given the following 65 | // options, 66 | // 67 | // foo-item-a x 68 | // foo-item-b y 69 | // foo-item-c z 70 | // 71 | // We'll get the map, 72 | // 73 | // {a: x, b: y, c: z} 74 | func (l *Loader) MapVar(val MapValue, prefix string) { 75 | l.init() 76 | 77 | l.maps[prefix] = val 78 | } 79 | 80 | // Load loads tmux options using the underlying tmux.Driver with the provided 81 | // request. This will fill all previously specified values and vars. 82 | func (l *Loader) Load(req tmux.ShowOptionsRequest) (err error) { 83 | if len(l.values) == 0 && len(l.maps) == 0 { 84 | return nil 85 | } 86 | 87 | out, err := l.Tmux.ShowOptions(req) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | scan := bufio.NewScanner(bytes.NewReader(out)) 93 | for scan.Scan() { 94 | line := scan.Bytes() 95 | 96 | idx := bytes.IndexByte(line, ' ') 97 | if idx < 0 { 98 | continue 99 | } 100 | 101 | name, value := string(line[:idx]), line[idx+1:] 102 | 103 | var serr error 104 | if r := l.lookupValue(name); r != nil { 105 | serr = r.Set(Unquote(value)) 106 | } else if k, r := l.lookupMapValue(name); r != nil { 107 | serr = r.Put(k, Unquote(value)) 108 | } else { 109 | continue 110 | } 111 | 112 | if serr != nil { 113 | err = multierr.Append(err, fmt.Errorf("load option %q: %v", name, serr)) 114 | } 115 | } 116 | 117 | return multierr.Append(err, scan.Err()) 118 | } 119 | 120 | func (l *Loader) lookupValue(name string) Value { 121 | return l.values[name] 122 | } 123 | 124 | func (l *Loader) lookupMapValue(name string) (key string, v MapValue) { 125 | for prefix, val := range l.maps { 126 | if strings.HasPrefix(name, prefix) { 127 | return strings.TrimPrefix(name, prefix), val 128 | } 129 | } 130 | return name, nil 131 | } 132 | 133 | type stringValue string 134 | 135 | // StringVar specifies that the given option should be loaded as a string. 136 | func (l *Loader) StringVar(dest *string, option string) { 137 | l.init() 138 | 139 | l.Var((*stringValue)(dest), option) 140 | } 141 | 142 | func (v *stringValue) Set(s string) error { 143 | *(*string)(v) = s 144 | return nil 145 | } 146 | 147 | type boolValue bool 148 | 149 | // BoolVar specifies that the given option should be loaded as a boolean. 150 | func (l *Loader) BoolVar(dest *bool, option string) { 151 | l.init() 152 | 153 | l.Var((*boolValue)(dest), option) 154 | } 155 | 156 | func (v *boolValue) Set(s string) error { 157 | switch strings.ToLower(strings.TrimSpace(s)) { 158 | case "on", "yes", "true", "1": 159 | *(*bool)(v) = true 160 | case "off", "no", "false", "0": 161 | *(*bool)(v) = false 162 | default: 163 | return fmt.Errorf("invalid boolean value %q", s) 164 | } 165 | return nil 166 | } 167 | 168 | // Unquote unquotes a string returned by tmux show-option. 169 | func Unquote(v []byte) (value string) { 170 | if len(v) == 0 { 171 | return "" 172 | } 173 | 174 | value = string(v) 175 | if strings.HasPrefix(value, `'`) { 176 | // strconv.Unquote does not like single-quoted strings with 177 | // multiple characters. Invert the quotes to let 178 | // strconv.Unquote do the heavy-lifting and invert back. 179 | value = invertQuotes(value) 180 | defer func() { 181 | value = invertQuotes(value) 182 | }() 183 | } else if !strings.Contains(value, `"`) { 184 | // If a string is unquoted, 185 | // manually quote it to get the benefit of un-escaping characters 186 | // from `strconv.Unquote`. 187 | // This does not cover the case 188 | // where `value` has a double-quote anywhere in the string. 189 | // However, 190 | // since `value` comes from `tmux show-options`, 191 | // values containing double-quotes 192 | // come in single-quotes. 193 | value = `"` + value + `"` 194 | } 195 | // Try to unquote but don't fail if it doesn't work. 196 | if o, err := strconv.Unquote(value); err == nil { 197 | value = o 198 | } 199 | return value 200 | } 201 | 202 | var _quoteInverter = strings.NewReplacer("'", `"`, `"`, "'") 203 | 204 | func invertQuotes(s string) string { 205 | return _quoteInverter.Replace(s) 206 | } 207 | -------------------------------------------------------------------------------- /internal/tmux/tmuxtest/doc.go: -------------------------------------------------------------------------------- 1 | // Package tmuxtest includes utilities to test APIs defined 2 | // in the tmux package. 3 | package tmuxtest 4 | -------------------------------------------------------------------------------- /internal/tmux/tmuxtest/matchers.go: -------------------------------------------------------------------------------- 1 | package tmuxtest 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/abhinav/tmux-fastcopy/internal/tmux" 7 | "go.uber.org/mock/gomock" 8 | ) 9 | 10 | // DisplayMessageRequestMatcher is a gomock matcher that matches 11 | // tmux.DisplayMessageRequest objects by pane ID. 12 | type DisplayMessageRequestMatcher struct { 13 | Pane string 14 | } 15 | 16 | var _ gomock.Matcher = DisplayMessageRequestMatcher{} 17 | 18 | func (m DisplayMessageRequestMatcher) String() string { 19 | return fmt.Sprintf("DisplayMessageRequest{Pane: %q}", m.Pane) 20 | } 21 | 22 | // Matches reports whether the provided DisplayMessageRequest matches. 23 | func (m DisplayMessageRequestMatcher) Matches(x interface{}) bool { 24 | req, ok := x.(tmux.DisplayMessageRequest) 25 | if !ok { 26 | return false 27 | } 28 | 29 | return req.Pane == m.Pane 30 | } 31 | -------------------------------------------------------------------------------- /internal/tmux/tmuxtest/mock_driver.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/abhinav/tmux-fastcopy/internal/tmux (interfaces: Driver) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination tmuxtest/mock_driver.go -package tmuxtest github.com/abhinav/tmux-fastcopy/internal/tmux Driver 7 | // 8 | 9 | // Package tmuxtest is a generated GoMock package. 10 | package tmuxtest 11 | 12 | import ( 13 | reflect "reflect" 14 | 15 | tmux "github.com/abhinav/tmux-fastcopy/internal/tmux" 16 | gomock "go.uber.org/mock/gomock" 17 | ) 18 | 19 | // MockDriver is a mock of Driver interface. 20 | type MockDriver struct { 21 | ctrl *gomock.Controller 22 | recorder *MockDriverMockRecorder 23 | isgomock struct{} 24 | } 25 | 26 | // MockDriverMockRecorder is the mock recorder for MockDriver. 27 | type MockDriverMockRecorder struct { 28 | mock *MockDriver 29 | } 30 | 31 | // NewMockDriver creates a new mock instance. 32 | func NewMockDriver(ctrl *gomock.Controller) *MockDriver { 33 | mock := &MockDriver{ctrl: ctrl} 34 | mock.recorder = &MockDriverMockRecorder{mock} 35 | return mock 36 | } 37 | 38 | // EXPECT returns an object that allows the caller to indicate expected use. 39 | func (m *MockDriver) EXPECT() *MockDriverMockRecorder { 40 | return m.recorder 41 | } 42 | 43 | // CapturePane mocks base method. 44 | func (m *MockDriver) CapturePane(arg0 tmux.CapturePaneRequest) ([]byte, error) { 45 | m.ctrl.T.Helper() 46 | ret := m.ctrl.Call(m, "CapturePane", arg0) 47 | ret0, _ := ret[0].([]byte) 48 | ret1, _ := ret[1].(error) 49 | return ret0, ret1 50 | } 51 | 52 | // CapturePane indicates an expected call of CapturePane. 53 | func (mr *MockDriverMockRecorder) CapturePane(arg0 any) *gomock.Call { 54 | mr.mock.ctrl.T.Helper() 55 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CapturePane", reflect.TypeOf((*MockDriver)(nil).CapturePane), arg0) 56 | } 57 | 58 | // DisplayMessage mocks base method. 59 | func (m *MockDriver) DisplayMessage(arg0 tmux.DisplayMessageRequest) ([]byte, error) { 60 | m.ctrl.T.Helper() 61 | ret := m.ctrl.Call(m, "DisplayMessage", arg0) 62 | ret0, _ := ret[0].([]byte) 63 | ret1, _ := ret[1].(error) 64 | return ret0, ret1 65 | } 66 | 67 | // DisplayMessage indicates an expected call of DisplayMessage. 68 | func (mr *MockDriverMockRecorder) DisplayMessage(arg0 any) *gomock.Call { 69 | mr.mock.ctrl.T.Helper() 70 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisplayMessage", reflect.TypeOf((*MockDriver)(nil).DisplayMessage), arg0) 71 | } 72 | 73 | // NewSession mocks base method. 74 | func (m *MockDriver) NewSession(arg0 tmux.NewSessionRequest) ([]byte, error) { 75 | m.ctrl.T.Helper() 76 | ret := m.ctrl.Call(m, "NewSession", arg0) 77 | ret0, _ := ret[0].([]byte) 78 | ret1, _ := ret[1].(error) 79 | return ret0, ret1 80 | } 81 | 82 | // NewSession indicates an expected call of NewSession. 83 | func (mr *MockDriverMockRecorder) NewSession(arg0 any) *gomock.Call { 84 | mr.mock.ctrl.T.Helper() 85 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewSession", reflect.TypeOf((*MockDriver)(nil).NewSession), arg0) 86 | } 87 | 88 | // ResizePane mocks base method. 89 | func (m *MockDriver) ResizePane(arg0 tmux.ResizePaneRequest) error { 90 | m.ctrl.T.Helper() 91 | ret := m.ctrl.Call(m, "ResizePane", arg0) 92 | ret0, _ := ret[0].(error) 93 | return ret0 94 | } 95 | 96 | // ResizePane indicates an expected call of ResizePane. 97 | func (mr *MockDriverMockRecorder) ResizePane(arg0 any) *gomock.Call { 98 | mr.mock.ctrl.T.Helper() 99 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResizePane", reflect.TypeOf((*MockDriver)(nil).ResizePane), arg0) 100 | } 101 | 102 | // ResizeWindow mocks base method. 103 | func (m *MockDriver) ResizeWindow(arg0 tmux.ResizeWindowRequest) error { 104 | m.ctrl.T.Helper() 105 | ret := m.ctrl.Call(m, "ResizeWindow", arg0) 106 | ret0, _ := ret[0].(error) 107 | return ret0 108 | } 109 | 110 | // ResizeWindow indicates an expected call of ResizeWindow. 111 | func (mr *MockDriverMockRecorder) ResizeWindow(arg0 any) *gomock.Call { 112 | mr.mock.ctrl.T.Helper() 113 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResizeWindow", reflect.TypeOf((*MockDriver)(nil).ResizeWindow), arg0) 114 | } 115 | 116 | // SendSignal mocks base method. 117 | func (m *MockDriver) SendSignal(arg0 string) error { 118 | m.ctrl.T.Helper() 119 | ret := m.ctrl.Call(m, "SendSignal", arg0) 120 | ret0, _ := ret[0].(error) 121 | return ret0 122 | } 123 | 124 | // SendSignal indicates an expected call of SendSignal. 125 | func (mr *MockDriverMockRecorder) SendSignal(arg0 any) *gomock.Call { 126 | mr.mock.ctrl.T.Helper() 127 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendSignal", reflect.TypeOf((*MockDriver)(nil).SendSignal), arg0) 128 | } 129 | 130 | // SetOption mocks base method. 131 | func (m *MockDriver) SetOption(arg0 tmux.SetOptionRequest) error { 132 | m.ctrl.T.Helper() 133 | ret := m.ctrl.Call(m, "SetOption", arg0) 134 | ret0, _ := ret[0].(error) 135 | return ret0 136 | } 137 | 138 | // SetOption indicates an expected call of SetOption. 139 | func (mr *MockDriverMockRecorder) SetOption(arg0 any) *gomock.Call { 140 | mr.mock.ctrl.T.Helper() 141 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetOption", reflect.TypeOf((*MockDriver)(nil).SetOption), arg0) 142 | } 143 | 144 | // ShowOptions mocks base method. 145 | func (m *MockDriver) ShowOptions(arg0 tmux.ShowOptionsRequest) ([]byte, error) { 146 | m.ctrl.T.Helper() 147 | ret := m.ctrl.Call(m, "ShowOptions", arg0) 148 | ret0, _ := ret[0].([]byte) 149 | ret1, _ := ret[1].(error) 150 | return ret0, ret1 151 | } 152 | 153 | // ShowOptions indicates an expected call of ShowOptions. 154 | func (mr *MockDriverMockRecorder) ShowOptions(arg0 any) *gomock.Call { 155 | mr.mock.ctrl.T.Helper() 156 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ShowOptions", reflect.TypeOf((*MockDriver)(nil).ShowOptions), arg0) 157 | } 158 | 159 | // SwapPane mocks base method. 160 | func (m *MockDriver) SwapPane(arg0 tmux.SwapPaneRequest) error { 161 | m.ctrl.T.Helper() 162 | ret := m.ctrl.Call(m, "SwapPane", arg0) 163 | ret0, _ := ret[0].(error) 164 | return ret0 165 | } 166 | 167 | // SwapPane indicates an expected call of SwapPane. 168 | func (mr *MockDriverMockRecorder) SwapPane(arg0 any) *gomock.Call { 169 | mr.mock.ctrl.T.Helper() 170 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SwapPane", reflect.TypeOf((*MockDriver)(nil).SwapPane), arg0) 171 | } 172 | 173 | // WaitForSignal mocks base method. 174 | func (m *MockDriver) WaitForSignal(arg0 string) error { 175 | m.ctrl.T.Helper() 176 | ret := m.ctrl.Call(m, "WaitForSignal", arg0) 177 | ret0, _ := ret[0].(error) 178 | return ret0 179 | } 180 | 181 | // WaitForSignal indicates an expected call of WaitForSignal. 182 | func (mr *MockDriverMockRecorder) WaitForSignal(arg0 any) *gomock.Call { 183 | mr.mock.ctrl.T.Helper() 184 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WaitForSignal", reflect.TypeOf((*MockDriver)(nil).WaitForSignal), arg0) 185 | } 186 | -------------------------------------------------------------------------------- /internal/ui/annotated_text.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "sync" 7 | 8 | "github.com/gdamore/tcell/v2" 9 | "github.com/gdamore/tcell/v2/views" 10 | ) 11 | 12 | // TextAnnotation changes what gets rendered for AnnotatedText. 13 | type TextAnnotation interface { 14 | offset() int 15 | length() int 16 | } 17 | 18 | // StyleTextAnnotation changes the style of a section of text in AnnotatedText. 19 | type StyleTextAnnotation struct { 20 | Style tcell.Style // style for this section 21 | 22 | // Offset in the text, and the length of it for which this alternative 23 | // style applies. 24 | Offset, Length int 25 | } 26 | 27 | func (sa StyleTextAnnotation) offset() int { return sa.Offset } 28 | func (sa StyleTextAnnotation) length() int { return sa.Length } 29 | 30 | // OverlayTextAnnotation overlays a different text over a section of text in 31 | // AnnotatedText. 32 | type OverlayTextAnnotation struct { 33 | Overlay string 34 | Style tcell.Style // style for the overlay 35 | 36 | // Offset in the text over which to draw this overlay. 37 | Offset int 38 | } 39 | 40 | func (oa OverlayTextAnnotation) offset() int { return oa.Offset } 41 | func (oa OverlayTextAnnotation) length() int { return len(oa.Overlay) } 42 | 43 | // AnnotatedText is a block of text rendered with annotations. 44 | type AnnotatedText struct { 45 | // Text block to render. This may be multi-line. 46 | Text string 47 | Style tcell.Style 48 | 49 | mu sync.RWMutex 50 | anns []TextAnnotation // sorted by offset 51 | } 52 | 53 | var _ Widget = (*AnnotatedText)(nil) 54 | 55 | // SetAnnotations changes the annotations for an AnnotatedText. Offsets MUST 56 | // not overlap. 57 | func (at *AnnotatedText) SetAnnotations(anns ...TextAnnotation) { 58 | anns = append(make([]TextAnnotation, 0, len(anns)), anns...) 59 | sort.Sort(byOffset(anns)) 60 | 61 | at.mu.Lock() 62 | at.anns = anns 63 | at.mu.Unlock() 64 | } 65 | 66 | // Draw draws the annotated text onto the provided view. 67 | func (at *AnnotatedText) Draw(view views.View) { 68 | at.mu.RLock() 69 | defer at.mu.RUnlock() 70 | 71 | var ( 72 | lastIdx int 73 | pos Pos 74 | ) 75 | for _, ann := range at.anns { 76 | if ann.length() == 0 { 77 | continue 78 | } 79 | 80 | // Previous annotation overlaps with this one. Skip. 81 | if ann.offset() < lastIdx { 82 | continue 83 | } 84 | 85 | // TODO: The way this is set up, an overlay annotation 86 | // can undo the row increment that would happen from a newline. 87 | // This is probably not the best internal representation. 88 | 89 | pos = DrawText(at.Text[lastIdx:ann.offset()], at.Style, view, pos) 90 | 91 | var ( 92 | style tcell.Style 93 | text string 94 | ) 95 | switch ann := ann.(type) { 96 | case StyleTextAnnotation: 97 | style = ann.Style 98 | text = at.Text[ann.Offset : ann.Offset+ann.Length] 99 | 100 | case OverlayTextAnnotation: 101 | style = ann.Style 102 | text = ann.Overlay 103 | 104 | default: 105 | panic(fmt.Sprintf("unknown annotation %#v", ann)) 106 | } 107 | 108 | pos = DrawText(text, style, view, pos) 109 | lastIdx = ann.offset() + ann.length() 110 | } 111 | 112 | DrawText(at.Text[lastIdx:], at.Style, view, pos) 113 | } 114 | 115 | // HandleEvent returns false. 116 | func (at *AnnotatedText) HandleEvent(tcell.Event) bool { 117 | return false 118 | } 119 | 120 | // byOffset sorts TextAnnotations by offset. 121 | type byOffset []TextAnnotation 122 | 123 | func (as byOffset) Len() int { return len(as) } 124 | 125 | func (as byOffset) Swap(i, j int) { 126 | as[i], as[j] = as[j], as[i] 127 | } 128 | 129 | func (as byOffset) Less(i, j int) bool { 130 | return as[i].offset() < as[j].offset() 131 | } 132 | -------------------------------------------------------------------------------- /internal/ui/annotated_text_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gdamore/tcell/v2" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | //nolint:paralleltest // shared state between subtests 12 | func TestAnnotatedText(t *testing.T) { 13 | t.Parallel() 14 | 15 | const ( 16 | W = 3 17 | H = 3 18 | ) 19 | 20 | normal := tcell.StyleDefault 21 | highlighted := tcell.StyleDefault.Foreground(tcell.ColorRed) 22 | 23 | scr := NewTestScreen(t, W, H) 24 | at := AnnotatedText{ 25 | Text: "foo\nbar\nbaz", 26 | Style: normal, 27 | } 28 | 29 | matchScreen := func(t *testing.T, want ...tcell.SimCell) { 30 | require.Len(t, want, W*H, "invalid test: not enough cells") 31 | 32 | t.Helper() 33 | 34 | got, w, h := scr.GetContents() 35 | assert.Equal(t, W, w) 36 | assert.Equal(t, H, h) 37 | assert.Equal(t, want, got) 38 | } 39 | 40 | // n generates a normal cell 41 | n := func(r rune) tcell.SimCell { 42 | return tcell.SimCell{ 43 | Bytes: []byte(string(r)), 44 | Style: normal, 45 | Runes: []rune{r}, 46 | } 47 | } 48 | 49 | // h generates a highlighted rune. 50 | h := func(r rune) tcell.SimCell { 51 | return tcell.SimCell{ 52 | Bytes: []byte(string(r)), 53 | Style: highlighted, 54 | Runes: []rune{r}, 55 | } 56 | } 57 | 58 | t.Run("no annotations", func(t *testing.T) { 59 | defer scr.Clear() 60 | defer at.SetAnnotations() 61 | 62 | at.Draw(scr) 63 | scr.Show() 64 | 65 | matchScreen(t, 66 | n('f'), n('o'), n('o'), 67 | n('b'), n('a'), n('r'), 68 | n('b'), n('a'), n('z'), 69 | ) 70 | }) 71 | 72 | t.Run("empty annotation", func(t *testing.T) { 73 | defer scr.Clear() 74 | defer at.SetAnnotations() 75 | 76 | at.SetAnnotations(OverlayTextAnnotation{}) 77 | 78 | at.Draw(scr) 79 | scr.Show() 80 | 81 | matchScreen(t, 82 | n('f'), n('o'), n('o'), 83 | n('b'), n('a'), n('r'), 84 | n('b'), n('a'), n('z'), 85 | ) 86 | }) 87 | 88 | t.Run("style", func(t *testing.T) { 89 | defer scr.Clear() 90 | defer at.SetAnnotations() 91 | 92 | at.SetAnnotations( 93 | StyleTextAnnotation{Style: highlighted, Offset: 4, Length: 1}, // ar 94 | StyleTextAnnotation{Style: highlighted, Offset: 8, Length: 2}, // z 95 | StyleTextAnnotation{Style: highlighted, Offset: 1, Length: 2}, // f 96 | ) 97 | 98 | // +---+---+---+ 99 | // | 0 |*1*|*2*| 3 100 | // +---+---+---+ 101 | // |*4*| 5 | 6 | 7 102 | // +---+---+---+ 103 | // |*8*|*9*|10 | 11 104 | // +---+---+---+ 105 | 106 | at.Draw(scr) 107 | scr.Show() 108 | 109 | matchScreen(t, 110 | n('f'), h('o'), h('o'), 111 | h('b'), n('a'), n('r'), 112 | h('b'), h('a'), n('z'), 113 | ) 114 | }) 115 | 116 | t.Run("overlay", func(t *testing.T) { 117 | defer scr.Clear() 118 | defer at.SetAnnotations() 119 | 120 | at.SetAnnotations( 121 | OverlayTextAnnotation{Overlay: "a", Offset: 1}, 122 | OverlayTextAnnotation{Overlay: "b", Style: highlighted}, 123 | ) 124 | 125 | at.Draw(scr) 126 | scr.Show() 127 | 128 | matchScreen(t, 129 | h('b'), n('a'), n('o'), 130 | n('b'), n('a'), n('r'), 131 | n('b'), n('a'), n('z'), 132 | ) 133 | }) 134 | 135 | t.Run("overlapping", func(t *testing.T) { 136 | defer scr.Clear() 137 | defer at.SetAnnotations() 138 | 139 | at.SetAnnotations( 140 | OverlayTextAnnotation{Overlay: "abc"}, 141 | OverlayTextAnnotation{Overlay: "de", Offset: 1}, // ignored 142 | ) 143 | 144 | at.Draw(scr) 145 | scr.Show() 146 | 147 | matchScreen(t, 148 | n('a'), n('b'), n('c'), 149 | n('b'), n('a'), n('r'), 150 | n('b'), n('a'), n('z'), 151 | ) 152 | }) 153 | 154 | t.Run("unknown annotation", func(t *testing.T) { 155 | defer scr.Clear() 156 | defer at.SetAnnotations() 157 | 158 | var foo struct{ StyleTextAnnotation } 159 | foo.Length = 3 160 | at.SetAnnotations(foo) 161 | 162 | assert.Panics(t, func() { 163 | at.Draw(scr) 164 | }) 165 | }) 166 | } 167 | -------------------------------------------------------------------------------- /internal/ui/app.go: -------------------------------------------------------------------------------- 1 | // Package ui defines a thin framework for building terminal UIs using tcell 2 | // and accompanying widgets. 3 | package ui 4 | 5 | import ( 6 | "sync" 7 | 8 | "github.com/abhinav/tmux-fastcopy/internal/log" 9 | "github.com/abhinav/tmux-fastcopy/internal/paniclog" 10 | "github.com/gdamore/tcell/v2" 11 | ) 12 | 13 | // App drives the main UI for the application. 14 | type App struct { 15 | // Root is the main application widget. 16 | Root Widget 17 | 18 | // Screen upon which to draw. 19 | Screen tcell.Screen 20 | 21 | // Logger to post messages to. Optional. 22 | Log *log.Logger 23 | 24 | once sync.Once 25 | err error // error, if any 26 | quit chan struct{} 27 | events chan tcell.Event 28 | } 29 | 30 | func (app *App) init() { 31 | app.once.Do(func() { 32 | if app.Log == nil { 33 | app.Log = log.Discard 34 | } 35 | 36 | app.quit = make(chan struct{}) 37 | app.events = make(chan tcell.Event) 38 | 39 | go app.streamEvents() 40 | }) 41 | } 42 | 43 | // Start starts the app, rendering the root widget on the screen indefinitely 44 | // until Stop is called. 45 | func (app *App) Start() { 46 | app.init() 47 | 48 | go app.run() 49 | } 50 | 51 | // Wait waits until the application is stopped with Stop. 52 | func (app *App) Wait() error { 53 | <-app.quit 54 | return app.err 55 | } 56 | 57 | func (app *App) run() { 58 | defer app.handlePanic() 59 | 60 | app.Screen.Clear() 61 | app.Root.Draw(app.Screen) 62 | app.Screen.Show() 63 | 64 | events := app.events 65 | for { 66 | select { 67 | case <-app.quit: 68 | return 69 | 70 | case ev, ok := <-events: 71 | if ok { 72 | if app.handleEvent(ev) { 73 | app.Screen.Clear() 74 | app.Root.Draw(app.Screen) 75 | app.Screen.Show() 76 | } 77 | } else { 78 | // don't resolve this channel again 79 | events = nil 80 | } 81 | } 82 | } 83 | } 84 | 85 | func (app *App) handleEvent(ev tcell.Event) (draw bool) { 86 | switch ev := ev.(type) { 87 | case *tcell.EventResize: 88 | app.Screen.Sync() 89 | return true 90 | 91 | case *tcell.EventKey: 92 | switch ev.Key() { 93 | case tcell.KeyEscape, tcell.KeyCtrlC: 94 | app.Stop() 95 | return false 96 | } 97 | } 98 | 99 | app.Root.HandleEvent(ev) 100 | return true 101 | } 102 | 103 | // Stop informs the application that it's time to stop. This will cause the Run 104 | // function to unblock and return. 105 | func (app *App) Stop() { 106 | select { 107 | case <-app.quit: 108 | // already closed 109 | 110 | default: 111 | if app.quit != nil { 112 | close(app.quit) 113 | } 114 | } 115 | } 116 | 117 | // Defer this inside goroutines to catch panics inside them. 118 | func (app *App) handlePanic() { 119 | w := log.Writer{Log: app.Log, Level: log.Error} 120 | defer func() { _ = w.Close() }() 121 | 122 | if err := paniclog.Handle(recover(), &w); err != nil { 123 | app.err = err 124 | app.Stop() 125 | } 126 | } 127 | 128 | // streams events from tcell to the app.events channel. Blocks until Stop is 129 | // called. 130 | func (app *App) streamEvents() { 131 | defer app.handlePanic() 132 | 133 | app.Screen.ChannelEvents(app.events, app.quit) 134 | } 135 | -------------------------------------------------------------------------------- /internal/ui/app_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/abhinav/tmux-fastcopy/internal/log" 8 | "github.com/abhinav/tmux-fastcopy/internal/log/logtest" 9 | tcell "github.com/gdamore/tcell/v2" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "go.uber.org/mock/gomock" 13 | ) 14 | 15 | //nolint:paralleltest // shared state between subtests 16 | func TestAppEvents(t *testing.T) { 17 | t.Parallel() 18 | 19 | ctrl := gomock.NewController(t) 20 | scr := NewTestScreen(t, 80, 40) 21 | 22 | widget := NewMockWidget(ctrl) 23 | widget.EXPECT().Draw(gomock.Any()).AnyTimes() 24 | 25 | app := App{ 26 | Root: widget, 27 | Screen: scr, 28 | Log: logtest.NewLogger(t), 29 | } 30 | app.Start() 31 | defer func() { 32 | app.Stop() 33 | assert.NoError(t, app.Wait()) 34 | }() 35 | 36 | t.Run("resize", func(t *testing.T) { 37 | scr.SetSize(100, 60) 38 | }) 39 | 40 | t.Run("handled action", func(t *testing.T) { 41 | widget.EXPECT(). 42 | HandleEvent(gomock.Any()). 43 | Return(true) 44 | 45 | scr.InjectKey(tcell.KeyRune, 'f', 0) 46 | }) 47 | 48 | t.Run("quit", func(t *testing.T) { 49 | scr.InjectKey(tcell.KeyEscape, 0, 0) 50 | 51 | // If this deadlocks, esc didn't quit. 52 | assert.NoError(t, app.Wait()) 53 | }) 54 | } 55 | 56 | func TestAppPanic(t *testing.T) { 57 | t.Parallel() 58 | 59 | assertPanic := func(t *testing.T, app *App, buff *bytes.Buffer) { 60 | t.Helper() 61 | 62 | err := app.Wait() 63 | require.Error(t, err) 64 | assert.Contains(t, err.Error(), "great sadness") 65 | assert.Contains(t, buff.String(), "panic: great sadness") 66 | assert.Contains(t, buff.String(), "TestAppPanic") 67 | assert.Contains(t, buff.String(), "app_test.go") 68 | } 69 | 70 | t.Run("event panic", func(t *testing.T) { 71 | t.Parallel() 72 | 73 | ctrl := gomock.NewController(t) 74 | scr := NewTestScreen(t, 80, 40) 75 | 76 | widget := NewMockWidget(ctrl) 77 | widget.EXPECT().Draw(gomock.Any()).AnyTimes() 78 | 79 | var buff bytes.Buffer 80 | app := App{ 81 | Root: widget, 82 | Screen: scr, 83 | Log: log.New(&buff), 84 | } 85 | app.Start() 86 | 87 | widget.EXPECT(). 88 | HandleEvent(gomock.Any()). 89 | Do(func(tcell.Event) { 90 | panic("great sadness") 91 | }) 92 | 93 | scr.InjectKey(tcell.KeyRune, 'f', 0) 94 | assertPanic(t, &app, &buff) 95 | }) 96 | 97 | t.Run("render panic", func(t *testing.T) { 98 | t.Parallel() 99 | 100 | ctrl := gomock.NewController(t) 101 | scr := NewTestScreen(t, 80, 40) 102 | 103 | widget := NewMockWidget(ctrl) 104 | widget.EXPECT().Draw(gomock.Any()). 105 | Do(func(tcell.Screen) { 106 | panic("great sadness") 107 | }) 108 | 109 | var buff bytes.Buffer 110 | app := App{ 111 | Root: widget, 112 | Screen: scr, 113 | Log: log.New(&buff), 114 | } 115 | app.Start() 116 | 117 | assertPanic(t, &app, &buff) 118 | }) 119 | } 120 | -------------------------------------------------------------------------------- /internal/ui/mock_widget_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/abhinav/tmux-fastcopy/internal/ui (interfaces: Widget) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination mock_widget_test.go -package ui github.com/abhinav/tmux-fastcopy/internal/ui Widget 7 | // 8 | 9 | // Package ui is a generated GoMock package. 10 | package ui 11 | 12 | import ( 13 | reflect "reflect" 14 | 15 | tcell "github.com/gdamore/tcell/v2" 16 | views "github.com/gdamore/tcell/v2/views" 17 | gomock "go.uber.org/mock/gomock" 18 | ) 19 | 20 | // MockWidget is a mock of Widget interface. 21 | type MockWidget struct { 22 | ctrl *gomock.Controller 23 | recorder *MockWidgetMockRecorder 24 | isgomock struct{} 25 | } 26 | 27 | // MockWidgetMockRecorder is the mock recorder for MockWidget. 28 | type MockWidgetMockRecorder struct { 29 | mock *MockWidget 30 | } 31 | 32 | // NewMockWidget creates a new mock instance. 33 | func NewMockWidget(ctrl *gomock.Controller) *MockWidget { 34 | mock := &MockWidget{ctrl: ctrl} 35 | mock.recorder = &MockWidgetMockRecorder{mock} 36 | return mock 37 | } 38 | 39 | // EXPECT returns an object that allows the caller to indicate expected use. 40 | func (m *MockWidget) EXPECT() *MockWidgetMockRecorder { 41 | return m.recorder 42 | } 43 | 44 | // Draw mocks base method. 45 | func (m *MockWidget) Draw(arg0 views.View) { 46 | m.ctrl.T.Helper() 47 | m.ctrl.Call(m, "Draw", arg0) 48 | } 49 | 50 | // Draw indicates an expected call of Draw. 51 | func (mr *MockWidgetMockRecorder) Draw(arg0 any) *gomock.Call { 52 | mr.mock.ctrl.T.Helper() 53 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Draw", reflect.TypeOf((*MockWidget)(nil).Draw), arg0) 54 | } 55 | 56 | // HandleEvent mocks base method. 57 | func (m *MockWidget) HandleEvent(arg0 tcell.Event) bool { 58 | m.ctrl.T.Helper() 59 | ret := m.ctrl.Call(m, "HandleEvent", arg0) 60 | ret0, _ := ret[0].(bool) 61 | return ret0 62 | } 63 | 64 | // HandleEvent indicates an expected call of HandleEvent. 65 | func (mr *MockWidgetMockRecorder) HandleEvent(arg0 any) *gomock.Call { 66 | mr.mock.ctrl.T.Helper() 67 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleEvent", reflect.TypeOf((*MockWidget)(nil).HandleEvent), arg0) 68 | } 69 | -------------------------------------------------------------------------------- /internal/ui/pos.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "fmt" 4 | 5 | // Pos is a position in the terminal UI. 6 | type Pos struct{ X, Y int } 7 | 8 | // Get returns the coordinates as a pair. 9 | // 10 | // x, y = pos.Get() 11 | func (p Pos) Get() (x, y int) { 12 | return p.X, p.Y 13 | } 14 | 15 | func (p Pos) String() string { 16 | return fmt.Sprintf("(%d, %d)", p.X, p.Y) 17 | } 18 | -------------------------------------------------------------------------------- /internal/ui/pos_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestPos(t *testing.T) { 10 | t.Parallel() 11 | 12 | t.Run("zero", func(t *testing.T) { 13 | t.Parallel() 14 | 15 | var p Pos 16 | 17 | x, y := p.Get() 18 | assert.Equal(t, 0, x, "X") 19 | assert.Equal(t, 0, y, "y") 20 | 21 | assert.Equal(t, "(0, 0)", p.String()) 22 | }) 23 | 24 | t.Run("zero", func(t *testing.T) { 25 | t.Parallel() 26 | 27 | p := Pos{5, 6} 28 | 29 | x, y := p.Get() 30 | assert.Equal(t, 5, x, "X") 31 | assert.Equal(t, 6, y, "y") 32 | 33 | assert.Equal(t, "(5, 6)", p.String()) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /internal/ui/screen_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | 6 | tcell "github.com/gdamore/tcell/v2" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func NewTestScreen(t testing.TB, w, h int) tcell.SimulationScreen { 11 | scr := tcell.NewSimulationScreen("") 12 | require.NoError(t, scr.Init()) 13 | t.Cleanup(scr.Fini) 14 | scr.SetSize(w, h) 15 | return scr 16 | } 17 | -------------------------------------------------------------------------------- /internal/ui/text.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/gdamore/tcell/v2" 5 | "github.com/gdamore/tcell/v2/views" 6 | "github.com/mattn/go-runewidth" 7 | "github.com/rivo/uniseg" 8 | ) 9 | 10 | // DrawText draws a string on the provided view at the specified position. 11 | // Returns the new position, after having drawn the text, making it possible to 12 | // continue drawing at the last written position. 13 | // 14 | // pos = DrawText("foo\nb", style, view, pos) 15 | // pos = DrawText("ar", style, view, pos) 16 | // 17 | // Text that bleeds outside the bounds of the view is ignored. 18 | func DrawText(s string, style tcell.Style, view views.View, pos Pos) Pos { 19 | if len(s) == 0 { 20 | return pos 21 | } 22 | 23 | w, h := view.Size() 24 | g := uniseg.NewGraphemes(s) 25 | for g.Next() { 26 | r := g.Runes() 27 | mainc := r[0] 28 | var combc []rune 29 | if len(r) > 1 { 30 | combc = r[1:] 31 | } 32 | 33 | s := g.Str() 34 | if pos.X >= w || s == "\n" { 35 | pos.Y++ 36 | pos.X = 0 37 | } 38 | 39 | if pos.Y >= h { 40 | return pos 41 | } 42 | 43 | if s != "\n" { 44 | view.SetContent(pos.X, pos.Y, mainc, combc, style) 45 | pos.X += runewidth.StringWidth(s) 46 | } 47 | } 48 | 49 | return pos 50 | } 51 | -------------------------------------------------------------------------------- /internal/ui/text_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | 6 | tcell "github.com/gdamore/tcell/v2" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestDrawText(t *testing.T) { 11 | t.Parallel() 12 | 13 | tests := []struct { 14 | desc string 15 | text string 16 | w, h int // defaults to 10 17 | give Pos 18 | want Pos 19 | }{ 20 | { 21 | desc: "empty", 22 | give: Pos{1, 2}, 23 | want: Pos{1, 2}, 24 | }, 25 | { 26 | desc: "single line", 27 | text: "hello", 28 | want: Pos{5, 0}, 29 | }, 30 | { 31 | desc: "multi line", 32 | text: "hello\nworld", 33 | want: Pos{5, 1}, 34 | }, 35 | { 36 | desc: "multi line/end with newline", 37 | text: "hello\nworld\n", 38 | want: Pos{0, 2}, 39 | }, 40 | { 41 | desc: "out of bounds/x", 42 | w: 4, 43 | text: "hello", 44 | want: Pos{1, 1}, 45 | }, 46 | { 47 | desc: "out of bounds/y", 48 | h: 2, 49 | text: "h\ne\nl\nl\no", 50 | want: Pos{0, 2}, 51 | }, 52 | { 53 | desc: "wide char", 54 | text: "世", 55 | want: Pos{2, 0}, 56 | }, 57 | { 58 | desc: "zero width char", 59 | text: "a\x00b", 60 | want: Pos{2, 0}, 61 | }, 62 | { 63 | desc: "combining rune", 64 | text: string([]rune{0x1f3f3, 0xfe0f, 0x200d, 0x1f308}), // 🏳️‍🌈 65 | want: Pos{1, 0}, 66 | }, 67 | } 68 | 69 | for _, tt := range tests { 70 | tt := tt 71 | t.Run(tt.desc, func(t *testing.T) { 72 | t.Parallel() 73 | 74 | w, h := 10, 10 75 | if tt.w > 0 { 76 | w = tt.w 77 | } 78 | if tt.h > 0 { 79 | h = tt.h 80 | } 81 | scr := NewTestScreen(t, w, h) 82 | 83 | got := DrawText( 84 | tt.text, tcell.StyleDefault, scr, tt.give, 85 | ) 86 | assert.Equal(t, tt.want, got) 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /internal/ui/widget.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/gdamore/tcell/v2" 5 | "github.com/gdamore/tcell/v2/views" 6 | ) 7 | 8 | //go:generate mockgen -destination mock_widget_test.go -package ui github.com/abhinav/tmux-fastcopy/internal/ui Widget 9 | 10 | // Widget is a drawable object that may handle events. 11 | type Widget interface { 12 | // Draw draws the widget on the supplied view. Widgets do not need to 13 | // clear the view; the caller will do that for them. 14 | Draw(views.View) 15 | 16 | // HandleEvent handles the given event, or returns false if the event 17 | // wasn't meant for it. 18 | HandleEvent(tcell.Event) (handled bool) 19 | } 20 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // tmux-fastcopy is a plugin for tmux that aids in copying text. 2 | // It allows matching text on the screen with pre-defined regular expressions 3 | // and copying the matched text with minimal keystrokes. 4 | // 5 | // See README.md for more information. 6 | package main 7 | 8 | import ( 9 | "flag" 10 | "fmt" 11 | "io" 12 | "os" 13 | 14 | "github.com/abhinav/tmux-fastcopy/internal/log" 15 | "github.com/abhinav/tmux-fastcopy/internal/paniclog" 16 | "github.com/abhinav/tmux-fastcopy/internal/tmux" 17 | tcell "github.com/gdamore/tcell/v2" 18 | "go.uber.org/multierr" 19 | ) 20 | 21 | var _version = "dev" 22 | 23 | var _main = mainCmd{ 24 | Stdout: os.Stdout, 25 | Stderr: os.Stderr, 26 | Executable: os.Executable, 27 | Getenv: os.Getenv, 28 | Environ: os.Environ, 29 | Getpid: os.Getpid, 30 | } 31 | 32 | func main() { 33 | if err := run(&_main, os.Args[1:]); err != nil && err != flag.ErrHelp { 34 | fmt.Fprintln(_main.Stderr, err) 35 | os.Exit(1) 36 | } 37 | } 38 | 39 | func run(cmd *mainCmd, args []string) (err error) { 40 | var cfg config 41 | flag := flag.NewFlagSet(_name, flag.ContinueOnError) 42 | flag.SetOutput(cmd.Stderr) 43 | flag.Usage = func() { 44 | name := flag.Name() 45 | fmt.Fprintf(flag.Output(), _usage, name) 46 | } 47 | cfg.RegisterFlags(flag) 48 | version := flag.Bool("version", false, "") 49 | if err := flag.Parse(args); err != nil { 50 | return err 51 | } 52 | 53 | if *version { 54 | fmt.Fprintln(cmd.Stdout, "tmux-fastcopy version", _version) 55 | fmt.Fprintln(cmd.Stdout, "Copyright (C) 2023 Abhinav Gupta") 56 | fmt.Fprintln(cmd.Stdout, " ") 57 | fmt.Fprintln(cmd.Stdout, "tmux-fastcopy comes with ABSOLUTELY NO WARRANTY.") 58 | fmt.Fprintln(cmd.Stdout, "This is free software, and you are welcome to redistribute it") 59 | fmt.Fprintln(cmd.Stdout, "under certain conditions. See source for details.") 60 | return nil 61 | } 62 | 63 | if args := flag.Args(); len(args) > 0 { 64 | return fmt.Errorf("unexpected arguments %q", args) 65 | } 66 | 67 | return cmd.Run(&cfg) 68 | } 69 | 70 | type mainCmd struct { 71 | Stdout io.Writer 72 | Stderr io.Writer 73 | 74 | Executable func() (string, error) // == os.Executable 75 | Getenv func(string) string // == os.Getenv 76 | Environ func() []string // == os.Environ 77 | Getpid func() int 78 | 79 | newTmuxDriver func(string) tmuxShellDriver 80 | runTarget runTargetFunc 81 | } 82 | 83 | const _name = "tmux-fastcopy" 84 | 85 | const _usage = `usage: %v [options] 86 | 87 | Renders a vimium/vimperator-style overlay on top of the text in a tmux window 88 | to allow copying important text on the screen. 89 | 90 | The following flags are available: 91 | 92 | -pane PANE 93 | target pane for the overlay. 94 | This may be a pane index in the current window, or a unique 95 | pane identifier. 96 | Uses the current pane if unspecified. 97 | -action COMMAND 98 | -shift-action COMMAND 99 | command and arguments that handle the selection. 100 | 'action' specifies the default selection action, and 101 | 'shift-action' specifies the action with the Shift key pressed. 102 | The first '{}' in the argument list is the selected text. 103 | If there is no '{}', the selected text is sent over stdin. 104 | -action 'tmux load-buffer -' # default 105 | -action pbcopy -shift-action open 106 | Uses 'tmux load-buffer' by default for 'action' and no-op for 107 | 'shift-action'. 108 | -regex NAME:PATTERN 109 | regular expressions to search for. 110 | Name identifies the pattern. Add this option any number of 111 | times. 112 | -regex 'attr:\w+\.\w+' 113 | Use prior names to replace or unset patterns. 114 | -regex 'ipv4:' 115 | Capture groups in the regex indicate the text to be copied, 116 | defaulting to the whole string if there are no capture groups. 117 | -regex 'gitsha:([0-9a-f]{7})[0-9a-f]{,33}' 118 | Actions receive the name of the matching regex in the 119 | FASTCOPY_REGEX_NAME environment variable. 120 | Default set includes: ipv4, gitsha, hexaddr, hexcolor, int, 121 | path, uuid. 122 | -alphabet STRING 123 | characters used to generate labels. 124 | -alphabet "asdfghjkl;" # qwerty home row 125 | Uses the English alphabet by default. 126 | -tmux PATH 127 | path to tmux executable. 128 | -tmux /usr/bin/tmux 129 | Searches $PATH for tmux by default. 130 | -log FILE 131 | file to write logs to. 132 | Uses stderr by default. 133 | -verbose 134 | log more output. 135 | -version 136 | display version information. 137 | ` 138 | 139 | func (cmd *mainCmd) init() { 140 | if cmd.newTmuxDriver == nil { 141 | cmd.newTmuxDriver = func(path string) tmuxShellDriver { 142 | return &tmux.ShellDriver{Path: path} 143 | } 144 | } 145 | 146 | if cmd.runTarget == nil { 147 | cmd.runTarget = runTarget 148 | } 149 | } 150 | 151 | func (cmd *mainCmd) Run(cfg *config) (err error) { 152 | cmd.init() 153 | 154 | if file := cfg.LogFile; len(file) > 0 { 155 | f, err := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) 156 | if err != nil { 157 | return fmt.Errorf("open log %q: %v", file, err) 158 | } 159 | defer multierr.AppendInvoke(&err, multierr.Close(f)) 160 | cmd.Stderr = f 161 | } 162 | 163 | tmuxDriver := cmd.newTmuxDriver(cfg.Tmux) 164 | 165 | // If we're wrapped, wait to send the done signal *after* writing the 166 | // panic. 167 | parent := cmd.Getenv(_parentPIDEnv) 168 | if len(parent) > 0 { 169 | defer func(signal string) { 170 | err = multierr.Append(err, tmuxDriver.SendSignal(signal)) 171 | }(_signalPrefix + parent) 172 | } 173 | defer paniclog.Recover(&err, cmd.Stderr) 174 | 175 | logger := log.New(cmd.Stderr) 176 | if cfg.Verbose { 177 | logger = logger.WithLevel(log.Debug) 178 | } 179 | tmuxDriver.SetLogger(logger.WithName("tmux")) 180 | 181 | var target interface{ Run(*config) error } 182 | if len(parent) > 0 { 183 | target = &app{ 184 | Log: logger, 185 | Tmux: tmuxDriver, 186 | NewScreen: tcell.NewScreen, 187 | NewAction: (&actionFactory{ 188 | Log: logger, 189 | Environ: cmd.Environ, 190 | Getwd: os.Getwd, 191 | }).New, 192 | } 193 | } else { 194 | target = &wrapper{ 195 | Log: logger, 196 | Tmux: tmuxDriver, 197 | Executable: cmd.Executable, 198 | Getenv: cmd.Getenv, 199 | Getpid: cmd.Getpid, 200 | } 201 | } 202 | 203 | return cmd.runTarget(target, cfg) 204 | } 205 | 206 | type tmuxShellDriver interface { 207 | tmux.Driver 208 | 209 | SetLogger(*log.Logger) 210 | } 211 | 212 | // runTargetFunc runs objects that conform to the wrapper/app signatures. This 213 | // type is intentionally cumbersome because it's not meant to be used widely. 214 | type runTargetFunc func(interface { 215 | Run(*config) error 216 | }, *config) error 217 | 218 | func runTarget(target interface{ Run(*config) error }, cfg *config) error { 219 | return target.Run(cfg) 220 | } 221 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/abhinav/tmux-fastcopy/internal/envtest" 11 | "github.com/abhinav/tmux-fastcopy/internal/log" 12 | "github.com/abhinav/tmux-fastcopy/internal/tmux" 13 | "github.com/abhinav/tmux-fastcopy/internal/tmux/tmuxtest" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | "go.uber.org/mock/gomock" 17 | ) 18 | 19 | func TestVersion(t *testing.T) { 20 | t.Parallel() 21 | 22 | var stdout, stderr bytes.Buffer 23 | defer func() { 24 | assert.Empty(t, stderr.String(), "stderr should be empty") 25 | }() 26 | err := run(&mainCmd{ 27 | Stdout: &stdout, 28 | Stderr: &stderr, 29 | Getenv: envtest.Empty.Getenv, 30 | }, []string{"-version"}) 31 | require.NoError(t, err) 32 | assert.Contains(t, stdout.String(), _version) 33 | } 34 | 35 | type fakeTmux struct{ tmux.Driver } 36 | 37 | func (fakeTmux) SetLogger(*log.Logger) {} 38 | 39 | func TestMainParentSignal(t *testing.T) { 40 | t.Parallel() 41 | 42 | ctrl := gomock.NewController(t) 43 | mockTmux := tmuxtest.NewMockDriver(ctrl) 44 | 45 | var stdout, stderr bytes.Buffer 46 | defer func() { 47 | assert.Empty(t, stdout.String(), "stdout must be empty") 48 | assert.Empty(t, stderr.String(), "stderr must be empty") 49 | }() 50 | 51 | mockTmux.EXPECT(). 52 | SendSignal(_signalPrefix + "42"). 53 | Return(nil) 54 | 55 | err := (&mainCmd{ 56 | Stdout: io.Discard, 57 | Stderr: &stderr, 58 | Getenv: envtest.MustPairs(_parentPIDEnv, "42").Getenv, 59 | newTmuxDriver: func(string) tmuxShellDriver { 60 | return fakeTmux{mockTmux} 61 | }, 62 | runTarget: func(interface{ Run(*config) error }, *config) error { 63 | return nil 64 | }, 65 | }).Run(&config{}) 66 | assert.NoError(t, err) 67 | } 68 | 69 | func TestMainTargetPanicWithLog(t *testing.T) { 70 | t.Parallel() 71 | 72 | logfile := filepath.Join(t.TempDir(), "log.txt") 73 | var stdout, stderr bytes.Buffer 74 | defer func() { 75 | assert.Empty(t, stdout.String(), "stdout must be empty") 76 | assert.Empty(t, stderr.String(), "stderr must be empty") 77 | }() 78 | 79 | ctrl := gomock.NewController(t) 80 | mockTmux := tmuxtest.NewMockDriver(ctrl) 81 | 82 | called := false 83 | defer func() { 84 | assert.True(t, called, "runTarget was called") 85 | }() 86 | runTarget := func(interface{ Run(*config) error }, *config) error { 87 | called = true 88 | panic("great sadness") 89 | } 90 | 91 | err := (&mainCmd{ 92 | Stdout: &stdout, 93 | Stderr: &stderr, 94 | Getenv: envtest.Empty.Getenv, 95 | Getpid: func() int { return 42 }, 96 | newTmuxDriver: func(string) tmuxShellDriver { 97 | return fakeTmux{mockTmux} 98 | }, 99 | runTarget: runTarget, 100 | }).Run(&config{ 101 | LogFile: logfile, 102 | }) 103 | require.Error(t, err) 104 | assert.Contains(t, err.Error(), "great sadness") 105 | 106 | body, err := os.ReadFile(logfile) 107 | require.NoError(t, err) 108 | assert.Contains(t, string(body), "panic: great sadness") 109 | } 110 | -------------------------------------------------------------------------------- /matcher.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "sort" 7 | 8 | "github.com/abhinav/tmux-fastcopy/internal/fastcopy" 9 | ) 10 | 11 | type matcher []*regexpMatcher 12 | 13 | func (rms matcher) Match(s string) []fastcopy.Match { 14 | var ms []match 15 | for _, m := range rms { 16 | ms = m.AppendMatches(s, ms) 17 | } 18 | ms = rms.removeOverlaps(ms) 19 | 20 | rs := make([]fastcopy.Match, len(ms)) 21 | for i, m := range ms { 22 | rs[i] = fastcopy.Match{ 23 | Matcher: m.Matcher, 24 | Range: m.Sel, 25 | } 26 | } 27 | return rs 28 | } 29 | 30 | func (rms matcher) removeOverlaps(ms []match) []match { 31 | if len(ms) < 2 { 32 | return ms 33 | } 34 | 35 | // Sort in ascending order by: 36 | // - Starts earliest 37 | // - Runs longest 38 | sort.Slice(ms, func(i, j int) bool { 39 | l, r := ms[i].Full, ms[j].Full 40 | 41 | if l.Start < r.Start { 42 | return true 43 | } 44 | if l.Start > r.Start { 45 | return false 46 | } 47 | 48 | return l.Len() > r.Len() 49 | }) 50 | 51 | out := ms[:1] 52 | for _, m := range ms[1:] { 53 | if m.Full.Start < out[len(out)-1].Full.End { 54 | continue 55 | } 56 | out = append(out, m) 57 | } 58 | 59 | return out 60 | } 61 | 62 | type regexpMatcher struct { 63 | name string 64 | regex *regexp.Regexp 65 | subexp int 66 | } 67 | 68 | // compileRegexpMatcher builds a regexpMatcher with the provided name and 69 | // regular expression. 70 | func compileRegexpMatcher(name, s string) (*regexpMatcher, error) { 71 | if len(s) == 0 { 72 | return ®expMatcher{name: name}, nil 73 | } 74 | 75 | re, err := regexp.Compile(s) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | n := 0 81 | if re.NumSubexp() > 0 { 82 | n++ 83 | } 84 | 85 | return ®expMatcher{ 86 | regex: re, 87 | subexp: n, 88 | name: name, 89 | }, nil 90 | } 91 | 92 | func (rm *regexpMatcher) Name() string { 93 | return rm.name 94 | } 95 | 96 | func (rm *regexpMatcher) String() string { 97 | return fmt.Sprintf("%v:%v", rm.name, rm.regex) 98 | } 99 | 100 | type match struct { 101 | // Name of the matcher that found this match. 102 | Matcher string 103 | 104 | // Full matched area. 105 | Full fastcopy.Range 106 | 107 | // Selected portion that will be copied. 108 | Sel fastcopy.Range 109 | } 110 | 111 | func (rm *regexpMatcher) AppendMatches(s string, ms []match) []match { 112 | if rm.regex == nil { 113 | return ms 114 | } 115 | for _, m := range rm.regex.FindAllStringSubmatchIndex(s, -1) { 116 | ms = append(ms, match{ 117 | Matcher: rm.Name(), 118 | Full: fastcopy.Range{Start: m[0], End: m[1]}, 119 | Sel: fastcopy.Range{Start: m[2*rm.subexp], End: m[2*rm.subexp+1]}, 120 | }) 121 | } 122 | return ms 123 | } 124 | -------------------------------------------------------------------------------- /matcher_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestRegexpMatcher(t *testing.T) { 11 | t.Parallel() 12 | 13 | tests := []struct { 14 | desc string 15 | regex string 16 | s string 17 | wantSel []string 18 | wantFull []string 19 | }{ 20 | { 21 | desc: "empty", 22 | s: "foo", 23 | wantSel: []string{}, 24 | wantFull: []string{}, 25 | }, 26 | { 27 | desc: "full match", 28 | regex: "a(?:b|c)", 29 | s: "foo ab bar ac", 30 | wantSel: []string{"ab", "ac"}, 31 | wantFull: []string{"ab", "ac"}, 32 | }, 33 | { 34 | desc: "subexp match", 35 | regex: "a(b|c)", 36 | s: "foo ab bar ac", 37 | wantSel: []string{"b", "c"}, 38 | wantFull: []string{"ab", "ac"}, 39 | }, 40 | } 41 | 42 | for _, tt := range tests { 43 | tt := tt 44 | t.Run(tt.desc, func(t *testing.T) { 45 | t.Parallel() 46 | 47 | m, err := compileRegexpMatcher(tt.desc, tt.regex) 48 | require.NoError(t, err, "compile regex") 49 | 50 | assert.NotPanics(t, func() { 51 | _ = m.String() 52 | }, "String") 53 | 54 | t.Run("Name", func(t *testing.T) { 55 | assert.Equal(t, tt.desc, m.Name()) 56 | }) 57 | 58 | t.Run("Matches", func(t *testing.T) { 59 | ms := m.AppendMatches(tt.s, nil) 60 | gotSel := make([]string, len(ms)) 61 | gotFull := make([]string, len(ms)) 62 | for i, m := range ms { 63 | gotSel[i] = tt.s[m.Sel.Start:m.Sel.End] 64 | gotFull[i] = tt.s[m.Full.Start:m.Full.End] 65 | } 66 | 67 | assert.Equal(t, tt.wantSel, gotSel) 68 | assert.Equal(t, tt.wantFull, gotFull) 69 | }) 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /mise.lock: -------------------------------------------------------------------------------- 1 | [tools."aqua:golangci/golangci-lint"] 2 | version = "2.1.6" 3 | backend = "aqua:golangci/golangci-lint" 4 | 5 | [tools."aqua:golangci/golangci-lint".checksums] 6 | "golangci-lint-2.1.6-darwin-arm64.tar.gz" = "sha256:90783fa092a0f64a4f7b7d419f3da1f53207e300261773babe962957240e9ea6" 7 | 8 | [tools.go] 9 | version = "1.24.3" 10 | backend = "core:go" 11 | 12 | [tools.go.checksums] 13 | "go1.24.3.darwin-arm64.tar.gz" = "sha256:64a3fa22142f627e78fac3018ce3d4aeace68b743eff0afda8aae0411df5e4fb" 14 | 15 | [tools.gofumpt] 16 | version = "0.8.0" 17 | backend = "ubi:mvdan/gofumpt" 18 | 19 | [tools."ubi:abhinav/doc2go"] 20 | version = "0.8.1" 21 | backend = "ubi:abhinav/doc2go" 22 | 23 | [tools."ubi:miniscruff/changie"] 24 | version = "1.21.1" 25 | backend = "ubi:miniscruff/changie" 26 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [env] 2 | GOBIN = "{{ config_root }}/bin" 3 | _.path = ["bin"] 4 | 5 | [tools] 6 | go = "latest" 7 | "aqua:golangci/golangci-lint" = "latest" 8 | "ubi:miniscruff/changie" = "latest" 9 | "ubi:abhinav/doc2go" = "latest" 10 | gofumpt = "latest" 11 | 12 | [tasks.all] 13 | description = "Build, lint and test the code." 14 | depends = ["build", "lint", "test"] 15 | 16 | [tasks.build] 17 | run = "go install github.com/abhinav/tmux-fastcopy" 18 | description = "Build the project" 19 | 20 | [tasks.generate] 21 | description = "Generate code" 22 | run = "go generate -x ./..." 23 | depends = ["tools"] 24 | 25 | [tasks.lint] 26 | description = "Run all linters" 27 | depends = ["lint:*"] 28 | 29 | [tasks.test] 30 | description = "Run all tests" 31 | depends = ["test:*"] 32 | 33 | [tasks.cover] 34 | description = "Run all tests with coverage" 35 | depends = ["cover:*"] 36 | 37 | [tasks.tools] 38 | description = "Install tools" 39 | run = "go install tool" 40 | 41 | [tasks.fmt] 42 | description = "Format the code" 43 | run = "golangci-lint fmt" 44 | 45 | [tasks."test:unit"] 46 | description = "Run tests" 47 | run = "go test -race ./..." 48 | 49 | [tasks."test:integration"] 50 | description = "Run integration tests" 51 | run = """ 52 | GOBIN=$(mktemp -d) 53 | GOBIN=$GOBIN go install -race github.com/abhinav/tmux-fastcopy 54 | PATH=$GOBIN:$PATH go test -C integration ./... 55 | """ 56 | 57 | [tasks."cover:unit"] 58 | description = "Run tests with coverage" 59 | run = [ 60 | "go test -race -coverprofile=cover.out -coverpkg=./... ./...", 61 | "go tool cover -html=cover.out -o cover.html" 62 | ] 63 | 64 | [tasks."cover:integration"] 65 | description = "Run tests with coverage and generate HTML report" 66 | run = """ 67 | GOBIN=$(mktemp -d) 68 | GOCOVERDIR=$(mktemp -d) 69 | GOBIN=$GOBIN go install -race -cover -coverpkg=./... github.com/abhinav/tmux-fastcopy 70 | GOCOVERDIR=$GOCOVERDIR PATH=$GOBIN:$PATH \ 71 | go test -C integration ./... 72 | go tool covdata textfmt -i=$GOCOVERDIR -o=cover.integration.out 73 | go tool cover -html=cover.integration.out -o cover.integration.html 74 | """ 75 | 76 | [tasks."lint:tidy"] 77 | description = "Ensure go.mod is tidy" 78 | run = [ 79 | "go mod tidy -diff", 80 | "go -C integration mod tidy -diff" 81 | ] 82 | 83 | [tasks."lint:golangci"] 84 | description = "Run golangci-lint" 85 | run = [ 86 | "golangci-lint run", 87 | "(cd integration && golangci-lint run)" 88 | ] 89 | 90 | [tasks."lint:generate"] 91 | description = "Ensure generated code is up to date" 92 | depends = ["generate"] 93 | run = """ 94 | if ! git diff --quiet; then 95 | echo "Working tree dirty after code generation" 96 | git status --porcelain 97 | false 98 | fi 99 | """ 100 | 101 | [tasks."changes:new"] 102 | description = "Add a changelog entry" 103 | run = "changie new" 104 | 105 | [tasks."changes:prepare"] 106 | description = "Prepare the changelog for release" 107 | run = 'changie batch {{arg(name="version")}} && changie merge' 108 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>abhinav/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /wrap.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/abhinav/tmux-fastcopy/internal/log" 9 | "github.com/abhinav/tmux-fastcopy/internal/tail" 10 | "github.com/abhinav/tmux-fastcopy/internal/tmux" 11 | "github.com/abhinav/tmux-fastcopy/internal/tmux/tmuxopt" 12 | "go.uber.org/multierr" 13 | ) 14 | 15 | const ( 16 | _parentPIDEnv = "TMUX_FASTCOPY_WRAPPED_BY" 17 | _signalPrefix = "TMUX_FASTCOPY_WRAPPER_" 18 | ) 19 | 20 | // wrapper wraps another function to ensure that it runs in its own tmux 21 | // session that it has full ownership of. 22 | type wrapper struct { 23 | Tmux tmux.Driver 24 | Log *log.Logger 25 | 26 | Executable func() (string, error) // os.Executable 27 | Getenv func(string) string // os.Getenv 28 | Getpid func() int // os.Getpid 29 | 30 | // To override tmux.InspectPane for tests. 31 | inspectPane func(tmux.Driver, string) (*tmux.PaneInfo, error) 32 | } 33 | 34 | // Run runs the wrapper with the provided configuration. If we're already 35 | // wrapped in a tmux session, Run calls the wrapped command. Otherwise, it 36 | // calls re-runs the binary in a new tmux session and waits for it to exit. 37 | // Logs written by the wrapped command will be reproduced to the logs for 38 | // wrapper. 39 | func (w *wrapper) Run(cfg *config) (err error) { 40 | // We work by setting the TMUX_FASTCOPY_WRAPPED_BY environment variable 41 | // to the PID of the wrapper process. If TMUX_FASTCOPY_WRAPPED_BY is 42 | // set, we know we're inside the wrapped binary. 43 | // 44 | // Further, we use the PID as part of the signal we sent to block and 45 | // unblock the binary with tmux using the tmux wait-for command, so if 46 | // the PID is 42, the signal is TMUX_FASTCOPY_WRAPPER_42. 47 | 48 | exe, err := w.Executable() 49 | if err != nil { 50 | return fmt.Errorf("determine executable: %v", err) 51 | } 52 | 53 | // Disambiguate the pane identifier to a pane ID. This is unqiue across 54 | // sessions. 55 | inspectPane := tmux.InspectPane 56 | if w.inspectPane != nil { 57 | inspectPane = w.inspectPane 58 | } 59 | pane, err := inspectPane(w.Tmux, cfg.Pane) 60 | if err != nil { 61 | return fmt.Errorf("inspect pane %q: %v", cfg.Pane, err) 62 | } 63 | cfg.Pane = pane.ID 64 | 65 | // Send the logs to a temporary file that we will copy from until we 66 | // exit. 67 | tmpLog, err := os.CreateTemp("", "tmux-fastcopy") 68 | if err != nil { 69 | return err 70 | } 71 | defer func() { 72 | err = multierr.Append(err, os.Remove(tmpLog.Name())) 73 | }() 74 | 75 | tmuxLoader := tmuxopt.Loader{Tmux: w.Tmux} 76 | var ( 77 | tmuxCfg config 78 | destroyUnattached bool 79 | ) 80 | tmuxCfg.RegisterOptions(&tmuxLoader) 81 | tmuxLoader.BoolVar(&destroyUnattached, "destroy-unattached") 82 | if err := tmuxLoader.Load(tmux.ShowOptionsRequest{Global: true}); err != nil { 83 | return fmt.Errorf("load options: %v", err) 84 | } 85 | 86 | cfg.LogFile = tmpLog.Name() 87 | cfg.FillFrom(&tmuxCfg) 88 | 89 | if destroyUnattached { 90 | // If destroy-unattached is set, tmux-fastcopy's session 91 | // will be terminated immediately upon spawning. 92 | // 93 | // We work around this by temporarily disabling the option 94 | // and then re-enabling it after tmux-fastcopy exits. 95 | req := tmux.SetOptionRequest{ 96 | Global: true, 97 | Name: "destroy-unattached", 98 | Value: "off", 99 | } 100 | if err := w.Tmux.SetOption(req); err != nil { 101 | return fmt.Errorf("set destroy-unattached=off: %v", err) 102 | } 103 | 104 | req.Value = "on" 105 | defer func(req tmux.SetOptionRequest) { 106 | if setErr := w.Tmux.SetOption(req); setErr != nil { 107 | err = multierr.Append(err, fmt.Errorf("set destroy-unattached=on: %v", setErr)) 108 | } 109 | }(req) 110 | 111 | } 112 | 113 | parent := strconv.Itoa(w.Getpid()) 114 | req := tmux.NewSessionRequest{ 115 | Width: pane.Width, 116 | Height: pane.Height, 117 | Detached: true, 118 | Env: []string{ 119 | fmt.Sprintf("%v=%v", _parentPIDEnv, w.Getpid()), 120 | }, 121 | Command: append([]string{exe}, cfg.Flags()...), 122 | } 123 | if _, err := w.Tmux.NewSession(req); err != nil { 124 | return err 125 | } 126 | 127 | logw := &log.Writer{Log: w.Log} 128 | defer multierr.AppendInvoke(&err, multierr.Close(logw)) 129 | 130 | tee := tail.Tee{W: logw, R: tmpLog} 131 | tee.Start() 132 | defer func() { 133 | err = multierr.Append(err, tmpLog.Close()) 134 | err = multierr.Append(err, tee.Stop()) 135 | }() 136 | 137 | return w.Tmux.WaitForSignal(_signalPrefix + parent) 138 | } 139 | -------------------------------------------------------------------------------- /wrap_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/abhinav/tmux-fastcopy/internal/envtest" 9 | "github.com/abhinav/tmux-fastcopy/internal/log/logtest" 10 | "github.com/abhinav/tmux-fastcopy/internal/tmux" 11 | "github.com/abhinav/tmux-fastcopy/internal/tmux/tmuxtest" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | "go.abhg.dev/io/ioutil" 15 | "go.uber.org/mock/gomock" 16 | ) 17 | 18 | func TestWrapper(t *testing.T) { 19 | t.Parallel() 20 | 21 | ctrl := gomock.NewController(t) 22 | 23 | tests := []struct { 24 | desc string 25 | 26 | paneInfo tmux.PaneInfo 27 | options []string // options reported by tmux show-options 28 | 29 | wantConfig config 30 | }{ 31 | { 32 | desc: "minimal", 33 | paneInfo: tmux.PaneInfo{ 34 | ID: "%1", 35 | Width: 80, 36 | Height: 40, 37 | }, 38 | wantConfig: config{ 39 | Pane: "%1", 40 | Tmux: "tmux", 41 | }, 42 | }, 43 | { 44 | desc: "has options", 45 | options: []string{ 46 | "@fastcopy-action pbcopy", 47 | "@fastcopy-alphabet asdfghjkl", 48 | }, 49 | paneInfo: tmux.PaneInfo{ 50 | ID: "%3", 51 | Width: 80, 52 | Height: 40, 53 | }, 54 | wantConfig: config{ 55 | Pane: "%3", 56 | Action: "pbcopy", 57 | Alphabet: alphabet("asdfghjkl"), 58 | Tmux: "tmux", 59 | }, 60 | }, 61 | } 62 | 63 | for _, tt := range tests { 64 | tt := tt 65 | t.Run(tt.desc, func(t *testing.T) { 66 | t.Parallel() 67 | 68 | mockTmux := tmuxtest.NewMockDriver(ctrl) 69 | mockTmux.EXPECT().NewSession(gomock.Any()). 70 | Do(func(req tmux.NewSessionRequest) { 71 | fset := flag.NewFlagSet(_name, flag.ContinueOnError) 72 | fset.SetOutput(ioutil.TestLogWriter(t, "")) 73 | 74 | var gotConfig config 75 | gotConfig.RegisterFlags(fset) 76 | 77 | require.NoError(t, fset.Parse(req.Command[1:])) 78 | 79 | // zero out log file for comparison. 80 | if assert.NotEmpty(t, gotConfig.LogFile, "log file must be specified") { 81 | gotConfig.LogFile = "" 82 | } 83 | 84 | assert.Equal(t, tt.wantConfig, gotConfig) 85 | }) 86 | 87 | mockTmux.EXPECT().WaitForSignal(gomock.Any()) 88 | mockTmux.EXPECT().ShowOptions(gomock.Any()). 89 | Return([]byte(strings.Join(tt.options, "\n")+"\n"), nil) 90 | 91 | w := wrapper{ 92 | Tmux: mockTmux, 93 | Log: logtest.NewLogger(t), 94 | Executable: func() (string, error) { 95 | return _name, nil 96 | }, 97 | Getenv: envtest.Empty.Getenv, 98 | Getpid: func() int { return 42 }, 99 | inspectPane: func(tmux.Driver, string) (*tmux.PaneInfo, error) { 100 | return &tt.paneInfo, nil 101 | }, 102 | } 103 | assert.NoError(t, w.Run(&config{})) 104 | }) 105 | } 106 | } 107 | 108 | func TestWrapper_destroyUnattached(t *testing.T) { 109 | t.Parallel() 110 | 111 | ctrl := gomock.NewController(t) 112 | 113 | mockTmux := tmuxtest.NewMockDriver(ctrl) 114 | mockTmux.EXPECT().ShowOptions(gomock.Any()). 115 | Return([]byte("destroy-unattached on\n"), nil) 116 | mockTmux.EXPECT().WaitForSignal(gomock.Any()) 117 | 118 | // destroy-unattached is set to off before we create a new session, 119 | // and set back to on after we create a new session. 120 | gomock.InOrder( 121 | mockTmux.EXPECT().SetOption(tmux.SetOptionRequest{ 122 | Global: true, 123 | Name: "destroy-unattached", 124 | Value: "off", 125 | }).Return(nil), 126 | mockTmux.EXPECT().NewSession(gomock.Any()), 127 | mockTmux.EXPECT().SetOption(tmux.SetOptionRequest{ 128 | Global: true, 129 | Name: "destroy-unattached", 130 | Value: "on", 131 | }).Return(nil), 132 | ) 133 | 134 | w := wrapper{ 135 | Tmux: mockTmux, 136 | Log: logtest.NewLogger(t), 137 | Executable: func() (string, error) { 138 | return _name, nil 139 | }, 140 | Getenv: envtest.Empty.Getenv, 141 | Getpid: func() int { return 42 }, 142 | inspectPane: func(tmux.Driver, string) (*tmux.PaneInfo, error) { 143 | return &tmux.PaneInfo{ 144 | ID: "%1", 145 | Width: 80, 146 | Height: 40, 147 | }, nil 148 | }, 149 | } 150 | assert.NoError(t, w.Run(&config{})) 151 | } 152 | --------------------------------------------------------------------------------