├── .envrc ├── .github ├── settings.yml └── workflows │ └── gh-pages.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── VERSION ├── book.toml ├── docs ├── SUMMARY.md ├── audience.md ├── flake.nix ├── introduction.md ├── motivation.md ├── rebranding-nix.md └── tui-reference.md ├── flake.lock ├── flake.nix ├── mdbook-paisano-preprocessor.css ├── nix ├── repo │ ├── config.nix │ └── shells.nix └── tui │ ├── app.md │ └── app.nix └── src ├── .gitignore ├── cache ├── cache.go ├── cache_test.go └── hash.go ├── cli.go ├── data └── data.go ├── default.md ├── env └── env.go ├── flake ├── flake.go ├── load.go └── run.go ├── go.mod ├── go.sum ├── keys └── keys.go ├── load.go ├── main.go ├── models └── readme.go ├── styles └── styles.go └── tui.go /.envrc: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | # shellcheck disable=SC1090 4 | . "$(fetchurl "https://raw.githubusercontent.com/paisano-nix/direnv/main/lib" "sha256-IgQhKK7UHL1AfCUntJO2KCaIDJQotRnK2qC4Daxk+wI=")" 5 | 6 | use envreload //repo/shells/default //repo/config 7 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | labels: 2 | - color: '#000000' 3 | description: This issue has been abdandoned 4 | name: ':running: Status: Abdandoned' 5 | - color: '#4CAF50' 6 | description: This issue has been accepted 7 | name: ':ok: Status: Accepted' 8 | - color: '#F44336' 9 | description: This issue is in a blocking state 10 | name: ':x: Status: Blocked' 11 | - color: '#A6A6A6' 12 | description: This issue is actively being worked on 13 | name: ':construction: Status: In Progress' 14 | - color: '#F44336' 15 | description: This issue is not currently being worked on 16 | name: ':golf: Status: On Hold' 17 | - color: '#FDD835' 18 | description: This issue is pending a review 19 | name: ':eyes: Status: Review Needed' 20 | - color: '#F44336' 21 | description: This issue targets a bug 22 | name: ':bug: Type: Bug' 23 | - color: '#FB8C00' 24 | description: This issue targets general maintenance 25 | name: ':wrench: Type: Maintenance' 26 | - color: '#AB47BC' 27 | description: This issue contains a question 28 | name: ':grey_question: Type: Question' 29 | - color: '#F44336' 30 | description: This issue targets a security vulnerability 31 | name: ':cop: Type: Security' 32 | - color: '#64B5F6' 33 | description: This issue targets a new feature through a story 34 | name: ':scroll: Type: Story' 35 | - color: '#F44336' 36 | description: This issue is prioritized as critical 37 | name: ':boom: Priority: Critical' 38 | - color: '#FB8C00' 39 | description: This issue is prioritized as high 40 | name: ':fire: Priority: High' 41 | - color: '#4CAF50' 42 | description: This issue is prioritized as low 43 | name: ':low_brightness: Priority: Low' 44 | - color: '#FFEE58' 45 | description: This issue is prioritized as medium 46 | name: ':star2: Priority: Medium' 47 | - color: '#4CAF50' 48 | description: This issue is of low complexity or very well understood 49 | name: ':muscle: Effort: 1' 50 | - color: '#FFEE58' 51 | description: This issue is of medium complexity or only partly well understood 52 | name: ':muscle: Effort: 3' 53 | - color: '#F44336' 54 | description: This issue is of high complexity or just not yet well understood 55 | name: ':muscle: Effort: 5' 56 | repository: 57 | allow_merge_commit: false 58 | allow_rebase_merge: true 59 | allow_squash_merge: true 60 | default_branch: main 61 | delete_branch_on_merge: true 62 | description: Paisano's TUI/CLI companion 63 | has_downloads: false 64 | has_issues: true 65 | has_projects: false 66 | has_wiki: false 67 | homepage: https://paisano-nix.github.io/tui 68 | name: tui 69 | private: false 70 | topics: nix, nix-flakes, flake, ux, tui, cli 71 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yaml: -------------------------------------------------------------------------------- 1 | name: github pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | # Build job 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - uses: nixbuild/nix-quick-install-action@v26 16 | with: 17 | nix_conf: | 18 | experimental-features = nix-command flakes 19 | access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} 20 | 21 | - run: nix develop --no-write-lock-file ./docs#mdbook -c mdbook build 22 | 23 | - name: Upload GitHub Pages artifact 24 | uses: actions/upload-pages-artifact@v1.0.7 25 | with: 26 | # Path of the directory containing the static assets. 27 | path: docs/build/html 28 | # Duration after which artifact will expire in days. 29 | retention-days: # optional, default is 1 30 | 31 | # Deploy job 32 | deploy: 33 | # Add a dependency to the build job 34 | needs: build 35 | 36 | # Grant GITHUB_TOKEN the permissions required to make a Pages deployment 37 | permissions: 38 | pages: write # to deploy to Pages 39 | id-token: write # to verify the deployment originates from an appropriate source 40 | 41 | # Deploy to the github-pages environment 42 | environment: 43 | name: github-pages 44 | url: ${{ steps.deployment.outputs.page_url }} 45 | 46 | # Specify runner + deployment step 47 | runs-on: ubuntu-latest 48 | steps: 49 | - name: Deploy to GitHub Pages 50 | id: deployment 51 | uses: actions/deploy-pages@v1 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .std 2 | result 3 | 4 | # nixago: ignore-linked-files 5 | /cog.toml 6 | /lefthook.yml 7 | /.conform.yaml 8 | /treefmt.toml 9 | adrgen.config.yml 10 | lefthook.yml 11 | .editorconfig 12 | .conform.yaml 13 | treefmt.toml 14 | 15 | # nixago-auto-created: mdbook-build-folder 16 | docs/build -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines. 3 | 4 | - - - 5 | ## [v0.5.0](https://github.com/paisano-nix/tui/compare/v0.4.3..v0.5.0) - 2024-02-22 6 | #### Build system 7 | - bump version of std tooling - ([01a2ab0](https://github.com/paisano-nix/tui/commit/01a2ab07b8fdeea407d16b376f469fdd297126d4)) - David Arnold 8 | #### Continuous Integration 9 | - update nix version in ci - ([6512f8c](https://github.com/paisano-nix/tui/commit/6512f8c755cbd20b7ac3a55e0126a0b5a783257b)) - David Arnold 10 | #### Features 11 | - improve cli/tui ouput - ([2fff01a](https://github.com/paisano-nix/tui/commit/2fff01a54bb4fae0daee3921b4360fdc695570c4)) - David Arnold 12 | 13 | - - - 14 | 15 | ## [v0.4.3](https://github.com/paisano-nix/tui/compare/v0.4.2..v0.4.3) - 2024-02-19 16 | #### Build system 17 | - bump inpuuts - ([7fe73ab](https://github.com/paisano-nix/tui/commit/7fe73abac6e2303548a6b1617ae6257d655a9b69)) - David Arnold 18 | 19 | - - - 20 | 21 | ## [v0.4.2](https://github.com/paisano-nix/tui/compare/v0.4.1..v0.4.2) - 2024-02-19 22 | #### Build system 23 | - fix with go mod tidy - ([d59f7a4](https://github.com/paisano-nix/tui/commit/d59f7a4d05e42216c90687d4beb17af39058b136)) - David Arnold 24 | 25 | - - - 26 | 27 | ## [v0.4.1](https://github.com/paisano-nix/tui/compare/v0.4.0..v0.4.1) - 2024-02-17 28 | #### Bug Fixes 29 | - std data for cog patch release flow - ([37f06fa](https://github.com/paisano-nix/tui/commit/37f06fa0846119ae2b15ffcb2eecb1462c24f894)) - David Arnold 30 | - vendor sha - ([8121467](https://github.com/paisano-nix/tui/commit/8121467cc676d9887671d7533c9687f53e92c241)) - David Arnold 31 | #### Miscellaneous Chores 32 | - update std to see if cog data has been fixed - ([8d1c341](https://github.com/paisano-nix/tui/commit/8d1c3412f49a1212846d70ff97b49929f357c64e)) - David Arnold 33 | - remove benchmark-like test for faster build time - ([ccfcaec](https://github.com/paisano-nix/tui/commit/ccfcaec93068eac196e4222ebe53b3599c7be071)) - pegasust 34 | 35 | - - - 36 | 37 | ## [v0.4.0](https://github.com/paisano-nix/tui/compare/v0.3.0..v0.4.0) - 2024-02-17 38 | #### Miscellaneous Chores 39 | - update prj-spec to latest version - ([d4007c2](https://github.com/paisano-nix/tui/commit/d4007c245a816c855976ff0ccf94233024ce64cd)) - David Arnold 40 | - fix cocogitto config - ([4cfe6c1](https://github.com/paisano-nix/tui/commit/4cfe6c1ceef85c413a31c9d4fdfd42679fe3e92b)) - David Arnold 41 | 42 | - - - 43 | 44 | ## [v0.3.0](https://github.com/divnix/hive/compare/v0.2.0..v0.3.0) - 2023-09-15 45 | #### Miscellaneous Chores 46 | - adopt local cell best practices - ([4874308](https://github.com/divnix/hive/commit/48743087ad1c61d9cb84551ace2955be54bf53d4)) - David Arnold 47 | 48 | - - - 49 | 50 | ## [0.2.0](https://github.com/paisano-nix/tui/compare/0.1.1..0.2.0) - 2023-09-06 51 | #### Bug Fixes 52 | - quote action invokations as they may contain '.' - ([c2f752b](https://github.com/paisano-nix/tui/commit/c2f752b4f288468c2190367afad1d66bc959d4bd)) - [@blaggacao](https://github.com/blaggacao) 53 | - show nix build instead of nix run - ([1dd6997](https://github.com/paisano-nix/tui/commit/1dd69975f3cec30a2f4ababde28d7cf8c1a61b3d)) - [@blaggacao](https://github.com/blaggacao) 54 | 55 | - - - 56 | 57 | ## [0.1.1](https://github.com/paisano-nix/tui/compare/0.1.0..0.1.1) - 2023-04-18 58 | #### Bug Fixes 59 | - pass args along to the final invocation - ([83af50d](https://github.com/paisano-nix/tui/commit/83af50d6c058999094bfab633e0a50faedafa1d1)) - [@blaggacao](https://github.com/blaggacao) 60 | - cog config - ([507cd13](https://github.com/paisano-nix/tui/commit/507cd138a26807aac2be5b859c9000aad8283203)) - [@blaggacao](https://github.com/blaggacao) 61 | #### Miscellaneous Chores 62 | - add instructions to publish release notesd - ([ad7ba7a](https://github.com/paisano-nix/tui/commit/ad7ba7a1cbc0302103a25d1262c9eb55c0939223)) - [@blaggacao](https://github.com/blaggacao) 63 | 64 | - - - 65 | 66 | ## [0.1.0](https://github.com/tui/paisano-nix/compare/5eef783baf77df737e33e8265834ac8afd0b78df..0.1.0) - 2023-04-17 67 | #### Bug Fixes 68 | - polish the completion ux and add some bling - ([3859076](https://github.com/tui/paisano-nix/commit/38590763cbbdf3175cf62b1c693f83c449313e54)) - [@blaggacao](https://github.com/blaggacao) 69 | - infinit loop on prj-spec init if outside a project repo - ([9b2bf76](https://github.com/tui/paisano-nix/commit/9b2bf7679b671319b96fc24f534620f1d9f27f0f)) - [@blaggacao](https://github.com/blaggacao) 70 | - branding on the `check` sub command - ([1c84e60](https://github.com/tui/paisano-nix/commit/1c84e604adb8907bc20ee5030bb124020ac79ace)) - [@blaggacao](https://github.com/blaggacao) 71 | - nil deref - damint cobra - ([f0272b3](https://github.com/tui/paisano-nix/commit/f0272b3986fbf153322b6e1c8b13016830e3577a)) - [@blaggacao](https://github.com/blaggacao) 72 | - bump std - ([802958d](https://github.com/tui/paisano-nix/commit/802958d123b0a5437441be0cab1dee487b0ed3eb)) - [@blaggacao](https://github.com/blaggacao) 73 | - oversight so that current system is detected again - ([bf8ef13](https://github.com/tui/paisano-nix/commit/bf8ef13f4ad9c84e7bf177c8a5f1c9586c41a4e4)) - [@blaggacao](https://github.com/blaggacao) 74 | #### Continuous Integration 75 | - add gh pages action - ([9756b9a](https://github.com/tui/paisano-nix/commit/9756b9aacc3ab369016c5b56677bf0e8902e8e01)) - [@blaggacao](https://github.com/blaggacao) 76 | #### Documentation 77 | - add tagline description - ([9f03a91](https://github.com/tui/paisano-nix/commit/9f03a911b9293acd93c3fbb1cf1cdaa92ec89c13)) - [@blaggacao](https://github.com/blaggacao) 78 | - add flake-view for docs with mdbook-paisano-preprocessor - ([f45d054](https://github.com/tui/paisano-nix/commit/f45d054b1329e70e475eb185367d18fa08a6a176)) - [@blaggacao](https://github.com/blaggacao) 79 | - fix intro page link - ([92488a2](https://github.com/tui/paisano-nix/commit/92488a29c7b9feac773feba8672d406d5268e3ae)) - [@blaggacao](https://github.com/blaggacao) 80 | - improve wording - ([0fe8858](https://github.com/tui/paisano-nix/commit/0fe88586963807b918cab3e4a6a651604b0a82c2)) - [@blaggacao](https://github.com/blaggacao) 81 | - add rebranding example - ([f32aaec](https://github.com/tui/paisano-nix/commit/f32aaec2774be698590c45438c2b8d0d5cbfa87e)) - [@blaggacao](https://github.com/blaggacao) 82 | - add documentation - ([6c52cf0](https://github.com/tui/paisano-nix/commit/6c52cf0de2e0acd88aef3515f909936abfebb4b6)) - [@blaggacao](https://github.com/blaggacao) 83 | #### Features 84 | - improve description on CLI completion - ([830d91f](https://github.com/tui/paisano-nix/commit/830d91ff32d3e12a4f89dec2f74179416af513c8)) - [@blaggacao](https://github.com/blaggacao) 85 | - comply with PRJ Spec (akin XDG_*) - ([de2574d](https://github.com/tui/paisano-nix/commit/de2574dc7390a9f38ace10b3cb3b35737595f365)) - [@blaggacao](https://github.com/blaggacao) 86 | - add license - ([cb9ac8b](https://github.com/tui/paisano-nix/commit/cb9ac8bc142c6bfac2bebb6566a03175aeb97a05)) - [@blaggacao](https://github.com/blaggacao) 87 | - actions on current system when (remote) build for other system - ([cd31e1c](https://github.com/tui/paisano-nix/commit/cd31e1c13aa01fa811d21b522215037c57e03cd3)) - [@blaggacao](https://github.com/blaggacao) 88 | #### Miscellaneous Chores 89 | - instrument release - ([40fab50](https://github.com/tui/paisano-nix/commit/40fab501a95f1a7f966f0b392557a01c1bcd2b60)) - [@blaggacao](https://github.com/blaggacao) 90 | - add hint to commit readme files - ([f080910](https://github.com/tui/paisano-nix/commit/f0809101b957e831ff5ae3be432397a0da9149b7)) - [@blaggacao](https://github.com/blaggacao) 91 | #### Refactoring 92 | - improve and clean up the code; optional `nom` support - ([2896332](https://github.com/tui/paisano-nix/commit/2896332e412153d7110bac3ebf330e9c5e34404b)) - [@blaggacao](https://github.com/blaggacao) 93 | - use new and shiny paisano direnv support - ([7db9c76](https://github.com/tui/paisano-nix/commit/7db9c76c3e440a926faf3efa585faf1d080585de)) - [@blaggacao](https://github.com/blaggacao) 94 | - make branding configurable at build time - ([694baa7](https://github.com/tui/paisano-nix/commit/694baa76fd58492b721f9091f2ed6736bfa6d85e)) - [@blaggacao](https://github.com/blaggacao) 95 | 96 | - - - 97 | 98 | Changelog generated by [cocogitto](https://github.com/cocogitto/cocogitto). -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. 4 | 5 | In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and 6 | successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | 10 | For more information, please refer to 11 | 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The `paisano` TUI / CLI 2 | 3 | The Paisano TUI / CLI is a brandable general purpose command line kit for Flake based projects which fulfill a Paisano layout and importer contract. 4 | 5 | ## Usage 6 | 7 | - Install Paisano: `nix profile install github:paisano-nix/tui` 8 | - Set up autocompletion (optional): `paisano _carapace [SHELL]` — see [carapace docs][carapace-docs] 9 | - Enter a Paisano-based repository. 10 | - Run `paisano` or `paisano list` and profit ✨! 11 | 12 | [carapace-docs]: https://rsteube.github.io/carapace/carapace/gen/hiddenSubcommand.html 13 | 14 | ## Branding 15 | 16 | To change the branding of this binary you can set these variables via `-X` compile flag: 17 | 18 | ``` 19 | main.buildVersion | default: dev 20 | main.buildCommit | default: dirty 21 | main.argv0 | default: paisano 22 | main.project | default: Paisano 23 | flake.registry | default: __std # temp kept, mainly for `std-action` 24 | ``` 25 | 26 | Example: `go build -o my-bin-name -ldflags="-X main.argv0=hive -X main.project=Hive"` 27 | 28 | ## Contributing 29 | 30 | ##### Prerequisites 31 | 32 | You need [nix](https://nixos.org/download.html) and [direnv](https://direnv.net/). 33 | 34 | ##### Enter Contribution Environment 35 | 36 | ```console 37 | direnv allow 38 | ``` 39 | 40 | ##### Change Contribution Environment 41 | 42 | ```console 43 | $EDITOR ./nix/repo/config.nix 44 | direnv reload 45 | ``` 46 | 47 | ##### Preview Documentation 48 | 49 | You need to be inside the Contribution Environment. 50 | 51 | ```console 52 | mdbook build -o 53 | ``` 54 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.6.0-dev 2 | -------------------------------------------------------------------------------- /book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | language = "en" 3 | multilingual = false 4 | src = "docs" 5 | title = "Paisano TUI Book" 6 | 7 | [build] 8 | build-dir = "docs/book" 9 | 10 | [output] 11 | [output.html] 12 | additional-css = ["./mdbook-paisano-preprocessor.css"] 13 | 14 | [output.linkcheck] 15 | 16 | [preprocessor.paisano-preprocessor] 17 | before = ["links"] 18 | registry = ".#__std.init" 19 | 20 | [[preprocessor.paisano-preprocessor.multi]] 21 | cell = "tui" 22 | chapter = "TUI Reference" 23 | -------------------------------------------------------------------------------- /docs/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Prelude 2 | 3 | - [Introduction](introduction.md) 4 | - [Motivation](motivation.md) 5 | - [Target Audience](audience.md) 6 | 7 | # Reference 8 | 9 | - [TUI Reference](tui-reference.md) 10 | - [Rebranding with Nix](rebranding-nix.md) 11 | -------------------------------------------------------------------------------- /docs/audience.md: -------------------------------------------------------------------------------- 1 | # Intended Audience 2 | 3 | ## End user 4 | 5 | End users of this tool can supercharge any Paisano-based project with discoverability. 6 | 7 | However, if a framework-specific branded version is available, that should be used instead. 8 | 9 | ## Framework creator 10 | 11 | This repository implements a white-label version of the Paisano repository companion. 12 | 13 | Downstream frameworks, such as [Standard][std] or [Hive][hive] can re-brand this tool to offer a seamless and branded experience to their users. 14 | 15 | [std]: https://github.com/divnix/std 16 | [hive]: https://github.com/divnix/hive 17 | 18 | Please refer to the readme on how to re-brand. 19 | -------------------------------------------------------------------------------- /docs/flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | # A flake-view into the top-level flake's contrib env 3 | # with `mdbook-paisano-preprocessor' available in inputs 4 | description = "Paisano's TUI/CLI extended docs env"; 5 | 6 | inputs.super.url = "path:../."; 7 | inputs.mdbook-paisano-preprocessor.url = "github:paisano-nix/mdbook-paisano-preprocessor"; 8 | inputs.std.follows = "super/std"; 9 | inputs.nixpkgs.follows = "super/std/nixpkgs"; 10 | 11 | outputs = { 12 | std, 13 | self, 14 | super, 15 | ... 16 | } @ inputs: 17 | std.growOn { 18 | inherit inputs; 19 | cellsFrom = std.incl ../nix [ 20 | "repo" 21 | ]; 22 | cellBlocks = with std.blockTypes; [ 23 | # Development Environments 24 | (nixago "config") 25 | (devshells "shells") 26 | ]; 27 | } 28 | { 29 | devShells = std.harvest self ["repo" "shells"]; 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /docs/motivation.md: -------------------------------------------------------------------------------- 1 | # Motivation 2 | 3 | # Problem 4 | 5 | A Paisano-based repository has a well-defined folder structure. 6 | 7 | That folder structure is parsed and transformed by Paisano into flake outputs. 8 | 9 | However, as a project grows, and so does the number of outputs, it becomes increasingly hard to discover all of them easily. 10 | 11 | When it already becomes complicated to find stuff for Nix experts, it is even more difficult for a user who is not deeply familiar with Nix. 12 | 13 | Hence, as a project grows, we face unsolved problems of discoverability. 14 | 15 | # Solution 16 | 17 | We could, of course, write a readme and detail all outputs and bespoke ways of how to interact with them. 18 | 19 | But we all know a readme's flaws: 20 | 21 | - people tend to not read it, especially as it grows larger 22 | - it tends to become outdated, coincidentally also as it grows larger 23 | 24 | # Better Solution 25 | 26 | To solve this discoverability problem while never becoming outdated, this tool renders all Paisano-based flake outputs into an easily browsable and searchable terminal user interface. A variety of actions offer the user pre-defined and discoverable ways to interact with these outputs based on their types. 27 | 28 | Once the user knows what she's looking for, she can choose to access her intended target action directly by using this tool as a CLI. 29 | -------------------------------------------------------------------------------- /docs/rebranding-nix.md: -------------------------------------------------------------------------------- 1 | # How To: Rebrand using Nix 2 | 3 | Example taken from [Standard][std]. 4 | 5 | [std]: https://std.divnix.com 6 | 7 | ```nix 8 | let 9 | version = "0.15.0+dev"; 10 | 11 | inherit (inputs) nixpkgs; 12 | inherit (nixpkgs.lib) licenses; 13 | in { 14 | default = cell.cli.std; 15 | 16 | std = nixpkgs.buildGoModule rec { 17 | inherit version; 18 | pname = "std"; 19 | meta = { 20 | inherit (import (inputs.self + /flake.nix)) description; 21 | license = licenses.unlicense; 22 | homepage = "https://github.com/divnix/std"; 23 | }; 24 | 25 | src = inputs.paisano-tui.sourceInfo + /src; 26 | 27 | vendorHash = "sha256-1le14dcr2b8TDUNdhIFbZGX3khQoCcEZRH86eqlZaQE="; 28 | 29 | nativeBuildInputs = [nixpkgs.installShellFiles]; 30 | 31 | postInstall = '' 32 | mv $out/bin/paisano $out/bin/${pname} 33 | 34 | installShellCompletion --cmd std \ 35 | --bash <($out/bin/std _carapace bash) \ 36 | --fish <($out/bin/std _carapace fish) \ 37 | --zsh <($out/bin/std _carapace zsh) 38 | ''; 39 | 40 | ldflags = [ 41 | "-s" 42 | "-w" 43 | "-X main.buildVersion=${version}" 44 | "-X main.argv0=${pname}" 45 | "-X main.project=Standard" 46 | "-X flake.registry=__std" 47 | ]; 48 | }; 49 | } 50 | 51 | ``` 52 | -------------------------------------------------------------------------------- /docs/tui-reference.md: -------------------------------------------------------------------------------- 1 | # Reference 2 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "blank": { 4 | "locked": { 5 | "lastModified": 1625557891, 6 | "narHash": "sha256-O8/MWsPBGhhyPoPLHZAuoZiiHo9q6FLlEeIDEXuj6T4=", 7 | "owner": "divnix", 8 | "repo": "blank", 9 | "rev": "5a5d2684073d9f563072ed07c871d577a6c614a8", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "divnix", 14 | "repo": "blank", 15 | "type": "github" 16 | } 17 | }, 18 | "call-flake": { 19 | "locked": { 20 | "lastModified": 1687380775, 21 | "narHash": "sha256-bmhE1TmrJG4ba93l9WQTLuYM53kwGQAjYHRvHOeuxWU=", 22 | "owner": "divnix", 23 | "repo": "call-flake", 24 | "rev": "74061f6c241227cd05e79b702db9a300a2e4131a", 25 | "type": "github" 26 | }, 27 | "original": { 28 | "owner": "divnix", 29 | "repo": "call-flake", 30 | "type": "github" 31 | } 32 | }, 33 | "devshell": { 34 | "inputs": { 35 | "nixpkgs": [ 36 | "nixpkgs" 37 | ], 38 | "systems": "systems" 39 | }, 40 | "locked": { 41 | "lastModified": 1694435990, 42 | "narHash": "sha256-yLQPD2eZGepu3yvdwABXrR3GhAqWRWTj9rn3a4knYuk=", 43 | "owner": "numtide", 44 | "repo": "devshell", 45 | "rev": "f6aec2e8b1cdddcab10ce7fc2eac66886e3deaad", 46 | "type": "github" 47 | }, 48 | "original": { 49 | "owner": "numtide", 50 | "repo": "devshell", 51 | "type": "github" 52 | } 53 | }, 54 | "dmerge": { 55 | "inputs": { 56 | "haumea": [ 57 | "std", 58 | "haumea" 59 | ], 60 | "nixlib": [ 61 | "std", 62 | "lib" 63 | ], 64 | "yants": [ 65 | "std", 66 | "yants" 67 | ] 68 | }, 69 | "locked": { 70 | "lastModified": 1686862774, 71 | "narHash": "sha256-ojGtRQ9pIOUrxsQEuEPerUkqIJEuod9hIflfNkY+9CE=", 72 | "owner": "divnix", 73 | "repo": "dmerge", 74 | "rev": "9f7f7a8349d33d7bd02e0f2b484b1f076e503a96", 75 | "type": "github" 76 | }, 77 | "original": { 78 | "owner": "divnix", 79 | "ref": "0.2.1", 80 | "repo": "dmerge", 81 | "type": "github" 82 | } 83 | }, 84 | "flake-utils": { 85 | "locked": { 86 | "lastModified": 1653893745, 87 | "narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=", 88 | "owner": "numtide", 89 | "repo": "flake-utils", 90 | "rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1", 91 | "type": "github" 92 | }, 93 | "original": { 94 | "owner": "numtide", 95 | "repo": "flake-utils", 96 | "type": "github" 97 | } 98 | }, 99 | "haumea": { 100 | "inputs": { 101 | "nixpkgs": [ 102 | "std", 103 | "lib" 104 | ] 105 | }, 106 | "locked": { 107 | "lastModified": 1685133229, 108 | "narHash": "sha256-FePm/Gi9PBSNwiDFq3N+DWdfxFq0UKsVVTJS3cQPn94=", 109 | "owner": "nix-community", 110 | "repo": "haumea", 111 | "rev": "34dd58385092a23018748b50f9b23de6266dffc2", 112 | "type": "github" 113 | }, 114 | "original": { 115 | "owner": "nix-community", 116 | "ref": "v0.2.2", 117 | "repo": "haumea", 118 | "type": "github" 119 | } 120 | }, 121 | "incl": { 122 | "inputs": { 123 | "nixlib": [ 124 | "std", 125 | "lib" 126 | ] 127 | }, 128 | "locked": { 129 | "lastModified": 1669263024, 130 | "narHash": "sha256-E/+23NKtxAqYG/0ydYgxlgarKnxmDbg6rCMWnOBqn9Q=", 131 | "owner": "divnix", 132 | "repo": "incl", 133 | "rev": "ce7bebaee048e4cd7ebdb4cee7885e00c4e2abca", 134 | "type": "github" 135 | }, 136 | "original": { 137 | "owner": "divnix", 138 | "repo": "incl", 139 | "type": "github" 140 | } 141 | }, 142 | "lib": { 143 | "locked": { 144 | "lastModified": 1694306727, 145 | "narHash": "sha256-26fkTOJOI65NOTNKFvtcJF9mzzf/kK9swHzfYt1Dl6Q=", 146 | "owner": "nix-community", 147 | "repo": "nixpkgs.lib", 148 | "rev": "c30b6a84c0b84ec7aecbe74466033facc9ed103f", 149 | "type": "github" 150 | }, 151 | "original": { 152 | "owner": "nix-community", 153 | "repo": "nixpkgs.lib", 154 | "type": "github" 155 | } 156 | }, 157 | "nixago": { 158 | "inputs": { 159 | "flake-utils": "flake-utils", 160 | "nixago-exts": [], 161 | "nixpkgs": [ 162 | "nixpkgs" 163 | ] 164 | }, 165 | "locked": { 166 | "lastModified": 1687381756, 167 | "narHash": "sha256-IUMIlYfrvj7Yli4H2vvyig8HEPpfCeMaE6+kBGPzFyk=", 168 | "owner": "nix-community", 169 | "repo": "nixago", 170 | "rev": "dacceb10cace103b3e66552ec9719fa0d33c0dc9", 171 | "type": "github" 172 | }, 173 | "original": { 174 | "owner": "nix-community", 175 | "repo": "nixago", 176 | "type": "github" 177 | } 178 | }, 179 | "nixpkgs": { 180 | "locked": { 181 | "lastModified": 1708343346, 182 | "narHash": "sha256-qlzHvterVRzS8fS0ophQpkh0rqw0abijHEOAKm0HmV0=", 183 | "owner": "nixos", 184 | "repo": "nixpkgs", 185 | "rev": "9312b935a538684049cb668885e60f15547d4c5f", 186 | "type": "github" 187 | }, 188 | "original": { 189 | "owner": "nixos", 190 | "ref": "release-23.11", 191 | "repo": "nixpkgs", 192 | "type": "github" 193 | } 194 | }, 195 | "nosys": { 196 | "locked": { 197 | "lastModified": 1668010795, 198 | "narHash": "sha256-JBDVBnos8g0toU7EhIIqQ1If5m/nyBqtHhL3sicdPwI=", 199 | "owner": "divnix", 200 | "repo": "nosys", 201 | "rev": "feade0141487801c71ff55623b421ed535dbdefa", 202 | "type": "github" 203 | }, 204 | "original": { 205 | "owner": "divnix", 206 | "repo": "nosys", 207 | "type": "github" 208 | } 209 | }, 210 | "paisano": { 211 | "inputs": { 212 | "call-flake": "call-flake", 213 | "nixpkgs": [ 214 | "std", 215 | "nixpkgs" 216 | ], 217 | "nosys": "nosys", 218 | "yants": [ 219 | "std", 220 | "yants" 221 | ] 222 | }, 223 | "locked": { 224 | "lastModified": 1693982790, 225 | "narHash": "sha256-WTZYlqGUjzzz/PSzcvjEZz2kkwYSXObjeQVrFBaqa2Y=", 226 | "owner": "paisano-nix", 227 | "repo": "core", 228 | "rev": "3e897a19418361ece34841105122ed4f9379ca96", 229 | "type": "github" 230 | }, 231 | "original": { 232 | "owner": "paisano-nix", 233 | "repo": "core", 234 | "type": "github" 235 | } 236 | }, 237 | "paisano-tui": { 238 | "flake": false, 239 | "locked": { 240 | "lastModified": 1708353388, 241 | "narHash": "sha256-RzNQ5P4fdYYAXb5Bmazh5KLjlbeCtyMX7WNiOFsqN68=", 242 | "owner": "paisano-nix", 243 | "repo": "tui", 244 | "rev": "db1f97e3d5213e66e5c1251d23d1401d7068b5f5", 245 | "type": "github" 246 | }, 247 | "original": { 248 | "owner": "paisano-nix", 249 | "ref": "v0.4.3", 250 | "repo": "tui", 251 | "type": "github" 252 | } 253 | }, 254 | "root": { 255 | "inputs": { 256 | "devshell": "devshell", 257 | "nixago": "nixago", 258 | "nixpkgs": "nixpkgs", 259 | "std": "std" 260 | } 261 | }, 262 | "std": { 263 | "inputs": { 264 | "arion": [ 265 | "std", 266 | "blank" 267 | ], 268 | "blank": "blank", 269 | "devshell": [ 270 | "devshell" 271 | ], 272 | "dmerge": "dmerge", 273 | "haumea": "haumea", 274 | "incl": "incl", 275 | "lib": "lib", 276 | "makes": [ 277 | "std", 278 | "blank" 279 | ], 280 | "microvm": [ 281 | "std", 282 | "blank" 283 | ], 284 | "n2c": [ 285 | "std", 286 | "blank" 287 | ], 288 | "nixago": [ 289 | "nixago" 290 | ], 291 | "nixpkgs": [ 292 | "nixpkgs" 293 | ], 294 | "paisano": "paisano", 295 | "paisano-tui": "paisano-tui", 296 | "terranix": [ 297 | "std", 298 | "blank" 299 | ], 300 | "yants": "yants" 301 | }, 302 | "locked": { 303 | "lastModified": 1708473028, 304 | "narHash": "sha256-z5/UbogAnRVlyT2qZPqUUb5yPLgGxO+iWAy4pxBjDAQ=", 305 | "owner": "divnix", 306 | "repo": "std", 307 | "rev": "151ecce8eecc8d0fd15d9fcd75d59a45ebdcd7ee", 308 | "type": "github" 309 | }, 310 | "original": { 311 | "owner": "divnix", 312 | "repo": "std", 313 | "type": "github" 314 | } 315 | }, 316 | "systems": { 317 | "locked": { 318 | "lastModified": 1681028828, 319 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 320 | "owner": "nix-systems", 321 | "repo": "default", 322 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 323 | "type": "github" 324 | }, 325 | "original": { 326 | "owner": "nix-systems", 327 | "repo": "default", 328 | "type": "github" 329 | } 330 | }, 331 | "yants": { 332 | "inputs": { 333 | "nixpkgs": [ 334 | "std", 335 | "lib" 336 | ] 337 | }, 338 | "locked": { 339 | "lastModified": 1686863218, 340 | "narHash": "sha256-kooxYm3/3ornWtVBNHM3Zh020gACUyFX2G0VQXnB+mk=", 341 | "owner": "divnix", 342 | "repo": "yants", 343 | "rev": "8f0da0dba57149676aa4817ec0c880fbde7a648d", 344 | "type": "github" 345 | }, 346 | "original": { 347 | "owner": "divnix", 348 | "repo": "yants", 349 | "type": "github" 350 | } 351 | } 352 | }, 353 | "root": "root", 354 | "version": 7 355 | } 356 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Paisano's TUI/CLI companion"; 3 | 4 | inputs.nixpkgs.url = "github:nixos/nixpkgs/release-23.11"; 5 | 6 | inputs.std = { 7 | url = "github:divnix/std"; 8 | inputs.nixpkgs.follows = "nixpkgs"; 9 | inputs.devshell.follows = "devshell"; 10 | inputs.nixago.follows = "nixago"; 11 | }; 12 | 13 | inputs.devshell = { 14 | url = "github:numtide/devshell"; 15 | inputs.nixpkgs.follows = "nixpkgs"; 16 | }; 17 | 18 | inputs.nixago = { 19 | url = "github:nix-community/nixago"; 20 | inputs.nixpkgs.follows = "nixpkgs"; 21 | inputs.nixago-exts.follows = ""; 22 | }; 23 | 24 | outputs = { 25 | std, 26 | self, 27 | ... 28 | } @ inputs: 29 | std.growOn { 30 | inherit inputs; 31 | cellsFrom = ./nix; 32 | cellBlocks = with std.blockTypes; [ 33 | # Development Environments 34 | (nixago "config") 35 | (devshells "shells") 36 | # Application Development 37 | (installables "app") 38 | ]; 39 | } 40 | { 41 | packages = std.harvest self ["tui" "app"]; 42 | devShells = std.harvest self ["repo" "shells"]; 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /mdbook-paisano-preprocessor.css: -------------------------------------------------------------------------------- 1 | /* 2 | CSS for rendering the Standard Refrerence page 3 | 4 | You can modify it to customize the representation. 5 | */ 6 | 7 | /* Cell Reference */ 8 | div.std-cell.std-no-readme { 9 | display: block; 10 | } 11 | 12 | div.std-cell div.std-readme-md { 13 | /* CSS rules */ 14 | } 15 | 16 | div.std-cell h2 { 17 | /* CSS rules */ 18 | } 19 | 20 | div.std-cell h2 { 21 | /* CSS rules */ 22 | } 23 | 24 | /* Block Reference */ 25 | div.std-block.std-no-readme { 26 | display: block; 27 | } 28 | 29 | div.std-block div.std-readme-md { 30 | /* CSS rules */ 31 | } 32 | 33 | div.std-cell h3 { 34 | /* CSS rules */ 35 | } 36 | 37 | /* Target Reference */ 38 | div.std-target.std-no-readme { 39 | display: none; 40 | } 41 | 42 | div.std-target div.std-readme-md { 43 | /* CSS rules */ 44 | } 45 | 46 | div.std-target h4 { 47 | /* CSS rules */ 48 | } 49 | 50 | div.std-target h5 { 51 | /* CSS rules */ 52 | } 53 | -------------------------------------------------------------------------------- /nix/repo/config.nix: -------------------------------------------------------------------------------- 1 | /* 2 | This file holds configuration data for repo dotfiles. 3 | 4 | Q: Why not just put the put the file there? 5 | 6 | A: (1) dotfile proliferation 7 | (2) have all the things in one place / fromat 8 | (3) potentially share / re-use configuration data - keeping it in sync 9 | */ 10 | let 11 | inherit (inputs) nixpkgs; 12 | inherit (inputs.std.inputs) dmerge; 13 | inherit (inputs.std.data) configs; 14 | inherit (inputs.std.lib.dev) mkNixago; 15 | in { 16 | # Tool Homepage: https://numtide.github.io/treefmt/ 17 | treefmt = (mkNixago configs.treefmt) { 18 | packages = [nixpkgs.go]; 19 | data = { 20 | formatter = { 21 | go = { 22 | command = "gofmt"; 23 | options = ["-w"]; 24 | includes = ["*.go"]; 25 | }; 26 | }; 27 | }; 28 | }; 29 | 30 | # Tool Homepage: https://editorconfig.org/ 31 | editorconfig = (mkNixago configs.editorconfig) {}; 32 | conform = (mkNixago configs.conform) {}; 33 | 34 | # Tool Homepage: https://github.com/evilmartians/lefthook 35 | lefthook = (mkNixago configs.lefthook) {}; 36 | 37 | cog = (mkNixago configs.cog) { 38 | data = { 39 | changelog = { 40 | remote = "github.com"; 41 | repository = "tui"; 42 | owner = "paisano-nix"; 43 | }; 44 | post_bump_hooks = dmerge.append [ 45 | "echo Go to and post: https://discourse.nixos.org/t/paisano-tui-cli/27351" 46 | ]; 47 | }; 48 | }; 49 | 50 | # Tool Hompeage: https://github.com/apps/settings 51 | # Install Setting App in your repo to enable it 52 | githubsettings = (mkNixago configs.githubsettings) { 53 | data = { 54 | repository = { 55 | name = "tui"; 56 | inherit (import (inputs.self + /flake.nix)) description; 57 | homepage = "https://paisano-nix.github.io/tui"; 58 | topics = "nix, nix-flakes, flake, ux, tui, cli"; 59 | default_branch = "main"; 60 | allow_squash_merge = true; 61 | allow_merge_commit = false; 62 | allow_rebase_merge = true; 63 | delete_branch_on_merge = true; 64 | private = false; 65 | has_issues = true; 66 | has_projects = false; 67 | has_wiki = false; 68 | has_downloads = false; 69 | }; 70 | }; 71 | }; 72 | 73 | # Tool Homepage: https://rust-lang.github.io/mdBook/ 74 | mdbook = (mkNixago configs.mdbook) { 75 | # add preprocessor packages here 76 | packages = [nixpkgs.mdbook-linkcheck]; 77 | data = { 78 | # Configuration Reference: https://rust-lang.github.io/mdBook/format/configuration/index.html 79 | book.title = "Paisano TUI Book"; 80 | preprocessor.paisano-preprocessor = { 81 | multi = [ 82 | { 83 | chapter = "TUI Reference"; 84 | cell = "tui"; 85 | } 86 | ]; 87 | }; 88 | output = { 89 | html = { 90 | additional-css = ["./mdbook-paisano-preprocessor.css"]; 91 | }; 92 | # Tool Homepage: https://github.com/Michael-F-Bryan/mdbook-linkcheck 93 | linkcheck = {}; 94 | }; 95 | }; 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /nix/repo/shells.nix: -------------------------------------------------------------------------------- 1 | /* 2 | This file holds reproducible shells with commands in them. 3 | 4 | They conveniently also generate config files in their startup hook. 5 | */ 6 | let 7 | inherit (inputs) nixpkgs; 8 | inherit (inputs.std.lib.dev) mkShell; 9 | inherit (inputs.nixpkgs.lib) mapAttrs optionals; 10 | # Tool Homepage: https://numtide.github.io/devshell/ 11 | in 12 | mapAttrs (_: mkShell) rec { 13 | mdbook.nixago = [cell.config.mdbook]; 14 | default = { 15 | name = "Paisano TUI"; 16 | 17 | # Tool Homepage: https://nix-community.github.io/nixago/ 18 | # This is Standard's devshell integration. 19 | # It runs the startup hook when entering the shell. 20 | nixago = [ 21 | cell.config.conform 22 | cell.config.treefmt 23 | cell.config.editorconfig 24 | cell.config.githubsettings 25 | cell.config.lefthook 26 | cell.config.mdbook 27 | cell.config.cog 28 | ]; 29 | 30 | commands = 31 | [ 32 | { 33 | package = nixpkgs.delve; 34 | category = "dev"; 35 | name = "dlv"; 36 | } 37 | { 38 | package = nixpkgs.go; 39 | category = "dev"; 40 | } 41 | { 42 | package = nixpkgs.gotools; 43 | category = "dev"; 44 | } 45 | { 46 | package = nixpkgs.gopls; 47 | category = "dev"; 48 | } 49 | ] 50 | ++ optionals nixpkgs.stdenv.isLinux [ 51 | { 52 | package = nixpkgs.golangci-lint; 53 | category = "dev"; 54 | } 55 | ]; 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /nix/tui/app.md: -------------------------------------------------------------------------------- 1 | # `paisano` CLI / TUI 2 | 3 | ```console 4 | ❯ paisano --help 5 | paisano is the CLI / TUI companion for Paisano. 6 | 7 | - Invoke without any arguments to start the TUI. 8 | - Invoke with a target spec and action to run a known target's action directly. 9 | 10 | Enable autocompletion via 'paisano _carapace '. 11 | For more instructions, see: https://rsteube.github.io/carapace/carapace/gen/hiddenSubcommand.html 12 | 13 | Usage: 14 | paisano //[cell]/[block]/[target]:[action] [args...] 15 | paisano [command] 16 | 17 | Available Commands: 18 | check Validate the repository. 19 | list List available targets. 20 | re-cache Refresh the CLI cache. 21 | 22 | Flags: 23 | --for string system, for which the target will be built (e.g. 'x86_64-linux') 24 | -h, --help help for paisano 25 | -v, --version version for paisano 26 | 27 | Use "paisano [command] --help" for more information about a command. 28 | 29 | ``` 30 | -------------------------------------------------------------------------------- /nix/tui/app.nix: -------------------------------------------------------------------------------- 1 | let 2 | version = "0.15.0+dev"; 3 | 4 | inherit (inputs) nixpkgs; 5 | inherit (nixpkgs.lib) licenses; 6 | in { 7 | default = cell.app.paisano; 8 | 9 | paisano = nixpkgs.buildGoModule rec { 10 | inherit version; 11 | pname = "paisano"; 12 | meta = { 13 | inherit (import (inputs.self + /flake.nix)) description; 14 | license = licenses.unlicense; 15 | homepage = "https://github.com/paisano-nix/tui"; 16 | }; 17 | 18 | src = inputs.self + /src; 19 | 20 | vendorHash = "sha256-S1oPselqHRIPcqDSsvdIkCwu1siQGRDHOkxWtYwa+g4="; 21 | 22 | nativeBuildInputs = [nixpkgs.installShellFiles]; 23 | 24 | postInstall = '' 25 | installShellCompletion --cmd paisano \ 26 | --bash <($out/bin/paisano _carapace bash) \ 27 | --fish <($out/bin/paisano _carapace fish) \ 28 | --zsh <($out/bin/paisano _carapace zsh) 29 | ''; 30 | 31 | ldflags = [ 32 | "-s" 33 | "-w" 34 | "-X main.buildVersion=${version}" 35 | ]; 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | -------------------------------------------------------------------------------- /src/cache/cache.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021, 2022 Tamás Gulácsi 2 | // SPDX-FileCopyrightText: 2017 The Go Authors. All rights reserved. 3 | // 4 | // SPDX-License-Identifier: BSD-3-Clause 5 | 6 | // Package filecache implements an artifact cache. 7 | // 8 | // It is copied from Go's cmd/go/internal/cache, 9 | // cleared the Go-specific environment settings for default cache, 10 | // Go version-salted hash, and added TrimWithLimits. 11 | package cache 12 | 13 | import ( 14 | "bytes" 15 | "crypto/sha256" 16 | "encoding/hex" 17 | "errors" 18 | "fmt" 19 | "io" 20 | "io/fs" 21 | "os" 22 | "path/filepath" 23 | "strconv" 24 | "strings" 25 | "time" 26 | 27 | "github.com/google/renameio/v2" 28 | "github.com/rogpeppe/go-internal/lockedfile" 29 | ) 30 | 31 | // An ActionID is a cache action key, the hash of a complete description of a 32 | // repeatable computation (command line, environment variables, 33 | // input file contents, executable contents). 34 | type ActionID ID 35 | 36 | // NewActionID returns the hashed bytes. 37 | func NewActionID(p []byte) ActionID { return sha256.Sum256(p) } 38 | 39 | // An OutputID is a cache output key, the hash of an output of a computation. 40 | type OutputID ID 41 | 42 | // A Cache is a package cache, backed by a file system directory tree. 43 | type Cache struct { 44 | dir string 45 | now func() time.Time 46 | 47 | mtimeInterval time.Duration 48 | } 49 | 50 | // Open opens and returns the cache in the given directory. 51 | // 52 | // It is safe for multiple processes on a single machine to use the 53 | // same cache directory in a local file system simultaneously. 54 | // They will coordinate using operating system file locks and may 55 | // duplicate effort but will not corrupt the cache. 56 | // 57 | // However, it is NOT safe for multiple processes on different machines 58 | // to share a cache directory (for example, if the directory were stored 59 | // in a network file system). File locking is notoriously unreliable in 60 | // network file systems and may not suffice to protect the cache. 61 | func Open(dir string) (*Cache, error) { 62 | info, err := os.Stat(dir) 63 | if err != nil { 64 | return nil, err 65 | } 66 | if !info.IsDir() { 67 | return nil, &fs.PathError{Op: "open", Path: dir, Err: fmt.Errorf("not a directory")} 68 | } 69 | for i := 0; i < 256; i++ { 70 | name := filepath.Join(dir, fmt.Sprintf("%02x", i)) 71 | // nosemgrep: go.lang.correctness.permissions.file_permission.incorrect-default-permission 72 | if err := os.MkdirAll(name, 0770); err != nil { 73 | return nil, err 74 | } 75 | } 76 | c := &Cache{ 77 | dir: dir, 78 | now: time.Now, 79 | mtimeInterval: DefaultMTimeInterval, 80 | } 81 | return c, nil 82 | } 83 | 84 | // SetMTimeInterval set the time precision for updating file access times. 85 | // The default is 1 hour. 86 | func (c *Cache) SetMTimeInterval(d time.Duration) { 87 | if d <= 0 { 88 | d = DefaultMTimeInterval 89 | } 90 | c.mtimeInterval = d 91 | } 92 | 93 | // fileName returns the name of the file corresponding to the given id. 94 | func (c *Cache) fileName(id [HashSize]byte, key string) string { 95 | return filepath.Join(c.dir, fmt.Sprintf("%02x", id[0]), fmt.Sprintf("%x", id)+"-"+key) 96 | } 97 | 98 | // An entryNotFoundError indicates that a cache entry was not found, with an 99 | // optional underlying reason. 100 | type entryNotFoundError struct { 101 | Err error 102 | } 103 | 104 | func (e *entryNotFoundError) Error() string { 105 | if e.Err == nil { 106 | return "cache entry not found" 107 | } 108 | return fmt.Sprintf("cache entry not found: %v", e.Err) 109 | } 110 | 111 | func (e *entryNotFoundError) Unwrap() error { 112 | return e.Err 113 | } 114 | 115 | const ( 116 | // action entry file is "v1 \n" 117 | hexSize = HashSize * 2 118 | entrySize = 2 + 1 + hexSize + 1 + hexSize + 1 + 20 + 1 + 20 + 1 119 | ) 120 | 121 | type Entry struct { 122 | OutputID OutputID 123 | Size int64 124 | Time time.Time 125 | } 126 | 127 | // Get looks up the action ID in the cache, 128 | // returning the corresponding output ID and file size, if any. 129 | // Note that finding an output ID does not guarantee that the 130 | // saved file for that output ID is still available. 131 | func (c *Cache) Get(id ActionID) (Entry, error) { 132 | missing := func(reason error) (Entry, error) { 133 | return Entry{}, &entryNotFoundError{Err: reason} 134 | } 135 | f, err := os.Open(c.fileName(id, "a")) 136 | if err != nil { 137 | return missing(err) 138 | } 139 | defer f.Close() 140 | entry := make([]byte, entrySize+1) // +1 to detect whether f is too long 141 | if n, err := io.ReadFull(f, entry); n > entrySize { 142 | return missing(errors.New("too long")) 143 | } else if !errors.Is(err, io.ErrUnexpectedEOF) { 144 | if errors.Is(err, io.EOF) { 145 | return missing(errors.New("file is empty")) 146 | } 147 | return missing(err) 148 | } else if n < entrySize { 149 | return missing(errors.New("entry file incomplete")) 150 | } 151 | if entry[0] != 'v' || entry[1] != '1' || entry[2] != ' ' || entry[3+hexSize] != ' ' || entry[3+hexSize+1+hexSize] != ' ' || entry[3+hexSize+1+hexSize+1+20] != ' ' || entry[entrySize-1] != '\n' { 152 | return missing(errors.New("invalid header")) 153 | } 154 | eid, entry := entry[3:3+hexSize], entry[3+hexSize:] 155 | eout, entry := entry[1:1+hexSize], entry[1+hexSize:] 156 | esize, entry := entry[1:1+20], entry[1+20:] 157 | etime, _ := entry[1:1+20], entry[1+20:] 158 | var buf [HashSize]byte 159 | if _, err := hex.Decode(buf[:], eid); err != nil { 160 | return missing(fmt.Errorf("decoding ID: %w", err)) 161 | } else if buf != id { 162 | return missing(errors.New("mismatched ID")) 163 | } 164 | if _, err := hex.Decode(buf[:], eout); err != nil { 165 | return missing(fmt.Errorf("decoding output ID: %w", err)) 166 | } 167 | i := 0 168 | for i < len(esize) && esize[i] == ' ' { 169 | i++ 170 | } 171 | size, err := strconv.ParseInt(string(esize[i:]), 10, 64) 172 | if err != nil { 173 | return missing(fmt.Errorf("parsing size: %w", err)) 174 | } else if size < 0 { 175 | return missing(errors.New("negative size")) 176 | } 177 | i = 0 178 | for i < len(etime) && etime[i] == ' ' { 179 | i++ 180 | } 181 | tm, err := strconv.ParseInt(string(etime[i:]), 10, 64) 182 | if err != nil { 183 | return missing(fmt.Errorf("parsing timestamp: %w", err)) 184 | } else if tm < 0 { 185 | return missing(errors.New("negative timestamp")) 186 | } 187 | 188 | c.used(c.fileName(id, "a")) 189 | 190 | return Entry{buf, size, time.Unix(0, tm)}, nil 191 | } 192 | 193 | // GetFile looks up the action ID in the cache and returns 194 | // the name of the corresponding data file. 195 | func (c *Cache) GetFile(id ActionID) (file string, entry Entry, err error) { 196 | entry, err = c.Get(id) 197 | if err != nil { 198 | return "", Entry{}, err 199 | } 200 | file = c.OutputFile(entry.OutputID) 201 | info, err := os.Stat(file) 202 | if err != nil { 203 | return "", Entry{}, &entryNotFoundError{Err: err} 204 | } 205 | if info.Size() != entry.Size { 206 | return "", Entry{}, &entryNotFoundError{Err: errors.New("file incomplete")} 207 | } 208 | return file, entry, nil 209 | } 210 | 211 | // GetBytes looks up the action ID in the cache and returns 212 | // the corresponding output bytes. 213 | // GetBytes should only be used for data that can be expected to fit in memory. 214 | func (c *Cache) GetBytes(id ActionID) ([]byte, Entry, error) { 215 | entry, err := c.Get(id) 216 | if err != nil { 217 | return nil, entry, err 218 | } 219 | data, _ := lockedfile.Read(c.OutputFile(entry.OutputID)) 220 | if sha256.Sum256(data) != entry.OutputID { 221 | return nil, entry, &entryNotFoundError{Err: errors.New("bad checksum")} 222 | } 223 | return data, entry, nil 224 | } 225 | 226 | // OutputFile returns the name of the cache file storing output with the given OutputID. 227 | func (c *Cache) OutputFile(out OutputID) string { 228 | file := c.fileName(out, "d") 229 | c.used(file) 230 | return file 231 | } 232 | 233 | // Time constants for cache expiration. 234 | // 235 | // We set the mtime on a cache file on each use, but at most one per mtimeInterval (1 hour), 236 | // to avoid causing many unnecessary inode updates. The mtimes therefore 237 | // roughly reflect "time of last use" but may in fact be older by at most an hour. 238 | // 239 | // We scan the cache for entries to delete at most once per trimInterval (1 day). 240 | // 241 | // When we do scan the cache, we delete entries that have not been used for 242 | // at least trimLimit (5 days). Statistics gathered from a month of usage by 243 | // Go developers found that essentially all reuse of cached entries happened 244 | // within 5 days of the previous reuse. See golang.org/issue/22990. 245 | const ( 246 | DefaultMTimeInterval = 1 * time.Hour 247 | DefaultTrimInterval = 24 * time.Hour 248 | DefaultTrimLimit = 5 * 24 * time.Hour 249 | ) 250 | 251 | // used makes a best-effort attempt to update mtime on file, 252 | // so that mtime reflects cache access time. 253 | // 254 | // Because the reflection only needs to be approximate, 255 | // and to reduce the amount of disk activity caused by using 256 | // cache entries, used only updates the mtime if the current 257 | // mtime is more than an hour old. This heuristic eliminates 258 | // nearly all of the mtime updates that would otherwise happen, 259 | // while still keeping the mtimes useful for cache trimming. 260 | func (c *Cache) used(file string) { 261 | info, err := os.Stat(file) 262 | if err == nil && c.now().Sub(info.ModTime()) < c.mtimeInterval { 263 | return 264 | } 265 | _ = os.Chtimes(file, c.now(), c.now()) 266 | } 267 | 268 | // Trim removes old cache entries that are likely not to be reused. 269 | // It uses the default trim interval and limit. 270 | func (c *Cache) Trim() { 271 | c.TrimWithLimit(0, 0) 272 | } 273 | 274 | // TrimLimited removes old cache entries that are likely not to be reused. 275 | // 276 | // For each duration, <=0 means Default. 277 | func (c *Cache) TrimWithLimit(trimInterval, trimLimit time.Duration) { 278 | if trimInterval <= 0 { 279 | trimInterval = DefaultTrimInterval 280 | } 281 | if trimLimit <= 0 { 282 | trimLimit = DefaultTrimLimit 283 | } 284 | now := c.now() 285 | 286 | // We maintain in dir/trim.txt the time of the last completed cache trim. 287 | // If the cache has been trimmed recently enough, do nothing. 288 | // This is the common case. 289 | // If the trim file is corrupt, detected if the file can't be parsed, or the 290 | // trim time is too far in the future, attempt the trim anyway. It's possible that 291 | // the cache was full when the corruption happened. Attempting a trim on 292 | // an empty cache is cheap, so there wouldn't be a big performance hit in that case. 293 | if data, err := os.ReadFile(filepath.Join(c.dir, "trim.txt")); err == nil { 294 | if t, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64); err == nil { 295 | lastTrim := time.Unix(t, 0) 296 | if d := now.Sub(lastTrim); d < trimInterval && d > -c.mtimeInterval { 297 | return 298 | } 299 | } 300 | } 301 | 302 | // Trim each of the 256 subdirectories. 303 | // We subtract an additional mtimeInterval 304 | // to account for the imprecision of our "last used" mtimes. 305 | cutoff := now.Add(-trimLimit - c.mtimeInterval) 306 | for i := 0; i < 256; i++ { 307 | subdir := filepath.Join(c.dir, fmt.Sprintf("%02x", i)) 308 | c.trimSubdir(subdir, cutoff) 309 | } 310 | 311 | // Ignore errors from here: if we don't write the complete timestamp, the 312 | // cache will appear older than it is, and we'll trim it again next time. 313 | var b bytes.Buffer 314 | fmt.Fprintf(&b, "%d", now.Unix()) 315 | if err := lockedfile.Write(filepath.Join(c.dir, "trim.txt"), &b, 0666); err != nil { 316 | return 317 | } 318 | } 319 | 320 | // trimSubdir trims a single cache subdirectory. 321 | func (c *Cache) trimSubdir(subdir string, cutoff time.Time) { 322 | // Read all directory entries from subdir before removing 323 | // any files, in case removing files invalidates the file offset 324 | // in the directory scan. Also, ignore error from f.Readdirnames, 325 | // because we don't care about reporting the error and we still 326 | // want to process any entries found before the error. 327 | f, err := os.Open(subdir) 328 | if err != nil { 329 | return 330 | } 331 | names, _ := f.Readdirnames(-1) 332 | f.Close() 333 | 334 | for _, name := range names { 335 | // Remove only cache entries (xxxx-a and xxxx-d). 336 | if !strings.HasSuffix(name, "-a") && !strings.HasSuffix(name, "-d") { 337 | continue 338 | } 339 | entry := filepath.Join(subdir, name) 340 | info, err := os.Stat(entry) 341 | if err == nil && info.ModTime().Before(cutoff) { 342 | os.Remove(entry) 343 | } 344 | } 345 | } 346 | 347 | // putIndexEntry adds an entry to the cache recording that executing the action 348 | // with the given id produces an output with the given output id (hash) and size. 349 | func (c *Cache) putIndexEntry(id ActionID, out OutputID, size int64) error { 350 | // Note: We expect that for one reason or another it may happen 351 | // that repeating an action produces a different output hash 352 | // (for example, if the output contains a time stamp or temp dir name). 353 | // While not ideal, this is also not a correctness problem, so we 354 | // don't make a big deal about it. In particular, we leave the action 355 | // cache entries writable specifically so that they can be overwritten. 356 | // 357 | entry := fmt.Sprintf("v1 %x %x %20d %20d\n", id, out, size, time.Now().UnixNano()) 358 | file := c.fileName(id, "a") 359 | 360 | // Copy file to cache directory. 361 | if err := renameio.WriteFile(file, []byte(entry), 0666); err != nil { 362 | return err 363 | } 364 | _ = os.Chtimes(file, c.now(), c.now()) // mainly for tests 365 | 366 | return nil 367 | } 368 | 369 | // Put stores the given output in the cache as the output for the action ID. 370 | // It may read file twice. The content of file must not change between the two passes. 371 | func (c *Cache) Put(id ActionID, file io.ReadSeeker) (OutputID, int64, error) { 372 | // Compute output ID. 373 | h := NewHash() 374 | if _, err := file.Seek(0, 0); err != nil { 375 | return OutputID{}, 0, err 376 | } 377 | size, err := io.Copy(h, file) 378 | if err != nil { 379 | return OutputID{}, 0, err 380 | } 381 | out := OutputID(h.SumID()) 382 | 383 | // Copy to cached output file (if not already present). 384 | if err := c.copyFile(file, out, size); err != nil { 385 | return out, size, err 386 | } 387 | 388 | // Add to cache index. 389 | return out, size, c.putIndexEntry(id, out, size) 390 | } 391 | 392 | // PutBytes stores the given bytes in the cache as the output for the action ID. 393 | func (c *Cache) PutBytes(id ActionID, data []byte) error { 394 | _, _, err := c.Put(id, bytes.NewReader(data)) 395 | return err 396 | } 397 | 398 | // copyFile copies file into the cache, expecting it to have the given 399 | // output ID and size, if that file is not present already. 400 | func (c *Cache) copyFile(file io.ReadSeeker, out OutputID, size int64) error { 401 | name := c.fileName(out, "d") 402 | info, err := os.Stat(name) 403 | if err == nil && info.Size() == size { 404 | // Check hash. 405 | if f, err := os.Open(name); err == nil { 406 | h := NewHash() 407 | _, _ = io.Copy(h, f) 408 | _ = f.Close() 409 | out2 := OutputID(h.SumID()) 410 | if out == out2 { 411 | return nil 412 | } 413 | } 414 | // Hash did not match. Fall through and rewrite file. 415 | } 416 | 417 | // Copy file to cache directory. 418 | mode := os.O_RDWR | os.O_CREATE 419 | if err == nil && info.Size() > size { // shouldn't happen but fix in case 420 | mode |= os.O_TRUNC 421 | } 422 | // nosemgrep: go.lang.correctness.permissions.file_permission.incorrect-default-permission 423 | f, err := os.OpenFile(name, mode, 0660) 424 | if err != nil { 425 | return err 426 | } 427 | defer f.Close() 428 | if size == 0 { 429 | // File now exists with correct size. 430 | // Only one possible zero-length file, so contents are OK too. 431 | // Early return here makes sure there's a "last byte" for code below. 432 | return nil 433 | } 434 | 435 | // From here on, if any of the I/O writing the file fails, 436 | // we make a best-effort attempt to truncate the file f 437 | // before returning, to avoid leaving bad bytes in the file. 438 | 439 | // Copy file to f, but also into h to double-check hash. 440 | if _, err := file.Seek(0, 0); err != nil { 441 | _ = f.Truncate(0) 442 | return err 443 | } 444 | h := NewHash() 445 | w := io.MultiWriter(f, h) 446 | if _, err := io.CopyN(w, file, size-1); err != nil { 447 | _ = f.Truncate(0) 448 | return err 449 | } 450 | // Check last byte before writing it; writing it will make the size match 451 | // what other processes expect to find and might cause them to start 452 | // using the file. 453 | buf := make([]byte, 1) 454 | if _, err := file.Read(buf); err != nil { 455 | _ = f.Truncate(0) 456 | return err 457 | } 458 | h.Write(buf) 459 | sum := h.Sum(nil) 460 | if !bytes.Equal(sum, out[:]) { 461 | _ = f.Truncate(0) 462 | return fmt.Errorf("file content changed underfoot") 463 | } 464 | 465 | // Commit cache file entry. 466 | if _, err := f.Write(buf); err != nil { 467 | _ = f.Truncate(0) 468 | return err 469 | } 470 | if err := f.Close(); err != nil { 471 | // Data might not have been written, 472 | // but file may look like it is the right size. 473 | // To be extra careful, remove cached file. 474 | _ = os.Remove(name) 475 | return err 476 | } 477 | _ = os.Chtimes(name, c.now(), c.now()) // mainly for tests 478 | 479 | return nil 480 | } 481 | -------------------------------------------------------------------------------- /src/cache/cache_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021, 2022 Tamás Gulácsi 2 | // SPDX-FileCopyrightText: 2017 The Go Authors. All rights reserved. 3 | // 4 | // SPDX-License-Identifier: BSD-3-Clause 5 | 6 | package cache 7 | 8 | import ( 9 | "bytes" 10 | "encoding/binary" 11 | "fmt" 12 | "os" 13 | "path/filepath" 14 | "testing" 15 | "time" 16 | ) 17 | 18 | func TestBasic(t *testing.T) { 19 | dir, err := os.MkdirTemp("", "cachetest-") 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | defer os.RemoveAll(dir) 24 | _, err = Open(filepath.Join(dir, "notexist")) 25 | if err == nil { 26 | t.Fatal(`Open("tmp/notexist") succeeded, want failure`) 27 | } 28 | 29 | cdir := filepath.Join(dir, "c1") 30 | if err := os.Mkdir(cdir, 0777); err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | c1, err := Open(cdir) 35 | if err != nil { 36 | t.Fatalf("Open(c1) (create): %v", err) 37 | } 38 | if err := c1.putIndexEntry(dummyID(1), dummyID(12), 13); err != nil { 39 | t.Fatalf("addIndexEntry: %v", err) 40 | } 41 | if err := c1.putIndexEntry(dummyID(1), dummyID(2), 3); err != nil { // overwrite entry 42 | t.Fatalf("addIndexEntry: %v", err) 43 | } 44 | if entry, err := c1.Get(dummyID(1)); err != nil || entry.OutputID != dummyID(2) || entry.Size != 3 { 45 | t.Fatalf("c1.Get(1) = %x, %v, %v, want %x, %v, nil", entry.OutputID, entry.Size, err, dummyID(2), 3) 46 | } 47 | 48 | c2, err := Open(cdir) 49 | if err != nil { 50 | t.Fatalf("Open(c2) (reuse): %v", err) 51 | } 52 | if entry, err := c2.Get(dummyID(1)); err != nil || entry.OutputID != dummyID(2) || entry.Size != 3 { 53 | t.Fatalf("c2.Get(1) = %x, %v, %v, want %x, %v, nil", entry.OutputID, entry.Size, err, dummyID(2), 3) 54 | } 55 | if err := c2.putIndexEntry(dummyID(2), dummyID(3), 4); err != nil { 56 | t.Fatalf("addIndexEntry: %v", err) 57 | } 58 | if entry, err := c1.Get(dummyID(2)); err != nil || entry.OutputID != dummyID(3) || entry.Size != 4 { 59 | t.Fatalf("c1.Get(2) = %x, %v, %v, want %x, %v, nil", entry.OutputID, entry.Size, err, dummyID(3), 4) 60 | } 61 | } 62 | 63 | func BenchmarkGrowth(t *testing.B) { 64 | dir, err := os.MkdirTemp("", "cachetest-") 65 | if err != nil { 66 | t.Fatal(err) 67 | } 68 | defer os.RemoveAll(dir) 69 | 70 | c, err := Open(dir) 71 | if err != nil { 72 | t.Fatalf("Open: %v", err) 73 | } 74 | 75 | // previously 40s (2/3 of build time) to check for this on a laptop 76 | // Now, run this with `go test -bench=.` 77 | n := t.N 78 | if testing.Short() { 79 | n = 10 80 | } 81 | 82 | for i := 0; i < n; i++ { 83 | if err := c.putIndexEntry(dummyID(i), dummyID(i*99), int64(i)*101); err != nil { 84 | t.Fatalf("addIndexEntry: %v", err) 85 | } 86 | id := ActionID(dummyID(i)) 87 | entry, err := c.Get(id) 88 | if err != nil { 89 | t.Fatalf("Get(%x): %v", id, err) 90 | } 91 | if entry.OutputID != dummyID(i*99) || entry.Size != int64(i)*101 { 92 | t.Errorf("Get(%x) = %x, %d, want %x, %d", id, entry.OutputID, entry.Size, dummyID(i*99), int64(i)*101) 93 | } 94 | } 95 | for i := 0; i < n; i++ { 96 | id := ActionID(dummyID(i)) 97 | entry, err := c.Get(id) 98 | if err != nil { 99 | t.Fatalf("Get2(%x): %v", id, err) 100 | } 101 | if entry.OutputID != dummyID(i*99) || entry.Size != int64(i)*101 { 102 | t.Errorf("Get2(%x) = %x, %d, want %x, %d", id, entry.OutputID, entry.Size, dummyID(i*99), int64(i)*101) 103 | } 104 | } 105 | } 106 | 107 | func dummyID(x int) [HashSize]byte { 108 | var out [HashSize]byte 109 | binary.LittleEndian.PutUint64(out[:], uint64(x)) 110 | return out 111 | } 112 | 113 | func TestCacheTrim(t *testing.T) { 114 | dir, err := os.MkdirTemp("", "cachetest-") 115 | if err != nil { 116 | t.Fatal(err) 117 | } 118 | defer os.RemoveAll(dir) 119 | 120 | c, err := Open(dir) 121 | if err != nil { 122 | t.Fatalf("Open: %v", err) 123 | } 124 | const start = 1000000000 125 | now := int64(start) 126 | c.now = func() time.Time { return time.Unix(now, 0) } 127 | 128 | checkTime := func(name string, mtime int64) { 129 | t.Helper() 130 | file := filepath.Join(c.dir, name[:2], name) 131 | info, err := os.Stat(file) 132 | if err != nil { 133 | t.Fatal(err) 134 | } 135 | if info.ModTime().Unix() != mtime { 136 | t.Fatalf("%s mtime = %d, want %d", name, info.ModTime().Unix(), mtime) 137 | } 138 | } 139 | 140 | id := ActionID(dummyID(1)) 141 | _ = c.PutBytes(id, []byte("abc")) 142 | entry, _ := c.Get(id) 143 | _ = c.PutBytes(ActionID(dummyID(2)), []byte("def")) 144 | mtime := now 145 | checkTime(fmt.Sprintf("%x-a", id), mtime) 146 | checkTime(fmt.Sprintf("%x-d", entry.OutputID), mtime) 147 | 148 | // Get should not change recent mtimes. 149 | now = start + 10 150 | _, _ = c.Get(id) 151 | checkTime(fmt.Sprintf("%x-a", id), mtime) 152 | checkTime(fmt.Sprintf("%x-d", entry.OutputID), mtime) 153 | 154 | // Get should change distant mtimes. 155 | now = start + 5000 156 | mtime2 := now 157 | if _, err := c.Get(id); err != nil { 158 | t.Fatal(err) 159 | } 160 | c.OutputFile(entry.OutputID) 161 | checkTime(fmt.Sprintf("%x-a", id), mtime2) 162 | checkTime(fmt.Sprintf("%x-d", entry.OutputID), mtime2) 163 | 164 | // Trim should leave everything alone: it's all too new. 165 | c.Trim() 166 | if _, err := c.Get(id); err != nil { 167 | t.Fatal(err) 168 | } 169 | c.OutputFile(entry.OutputID) 170 | data, err := os.ReadFile(filepath.Join(dir, "trim.txt")) 171 | if err != nil { 172 | t.Fatal(err) 173 | } 174 | checkTime(fmt.Sprintf("%x-a", dummyID(2)), start) 175 | 176 | // Trim less than a day later should not do any work at all. 177 | now = start + 80000 178 | c.Trim() 179 | if _, err := c.Get(id); err != nil { 180 | t.Fatal(err) 181 | } 182 | c.OutputFile(entry.OutputID) 183 | data2, err := os.ReadFile(filepath.Join(dir, "trim.txt")) 184 | if err != nil { 185 | t.Fatal(err) 186 | } 187 | if !bytes.Equal(data, data2) { 188 | t.Fatalf("second trim did work: %q -> %q", data, data2) 189 | } 190 | 191 | // Fast forward and do another trim just before the 5 day cutoff. 192 | // Note that because of usedQuantum the cutoff is actually 5 days + 1 hour. 193 | // We used c.Get(id) just now, so 5 days later it should still be kept. 194 | // On the other hand almost a full day has gone by since we wrote dummyID(2) 195 | // and we haven't looked at it since, so 5 days later it should be gone. 196 | now += 5 * 86400 197 | checkTime(fmt.Sprintf("%x-a", dummyID(2)), start) 198 | c.Trim() 199 | if _, err := c.Get(id); err != nil { 200 | t.Fatal(err) 201 | } 202 | c.OutputFile(entry.OutputID) 203 | mtime3 := now 204 | if _, err := c.Get(dummyID(2)); err == nil { // haven't done a Get for this since original write above 205 | t.Fatalf("Trim did not remove dummyID(2)") 206 | } 207 | 208 | // The c.Get(id) refreshed id's mtime again. 209 | // Check that another 5 days later it is still not gone, 210 | // but check by using checkTime, which doesn't bring mtime forward. 211 | now += 5 * 86400 212 | c.Trim() 213 | checkTime(fmt.Sprintf("%x-a", id), mtime3) 214 | checkTime(fmt.Sprintf("%x-d", entry.OutputID), mtime3) 215 | 216 | // Half a day later Trim should still be a no-op, because there was a Trim recently. 217 | // Even though the entry for id is now old enough to be trimmed, 218 | // it gets a reprieve until the time comes for a new Trim scan. 219 | now += 86400 / 2 220 | c.Trim() 221 | checkTime(fmt.Sprintf("%x-a", id), mtime3) 222 | checkTime(fmt.Sprintf("%x-d", entry.OutputID), mtime3) 223 | 224 | // Another half a day later, Trim should actually run, and it should remove id. 225 | now += 86400/2 + 1 226 | c.Trim() 227 | if _, err := c.Get(dummyID(1)); err == nil { 228 | t.Fatal("Trim did not remove dummyID(1)") 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/cache/hash.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Tamás Gulácsi. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package cache 6 | 7 | import ( 8 | "crypto/sha256" 9 | "hash" 10 | ) 11 | 12 | const HashSize = sha256.Size 13 | 14 | type ID [HashSize]byte 15 | type Hash struct { 16 | hash.Hash 17 | } 18 | 19 | func NewHash() Hash { return Hash{Hash: sha256.New()} } 20 | func (h Hash) SumID() ID { 21 | var a ID 22 | h.Hash.Sum(a[:0]) 23 | return a 24 | } 25 | -------------------------------------------------------------------------------- /src/cli.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | "text/tabwriter" 10 | 11 | "github.com/oriser/regroup" 12 | 13 | "github.com/rsteube/carapace" 14 | "github.com/rsteube/carapace/pkg/style" 15 | "github.com/spf13/cobra" 16 | 17 | "github.com/paisano-nix/paisano/data" 18 | "github.com/paisano-nix/paisano/flake" 19 | ) 20 | 21 | type Spec struct { 22 | Cell string `regroup:"cell,required"` 23 | Block string `regroup:"block,required"` 24 | Target string `regroup:"target,required"` 25 | Action string `regroup:"action,required"` 26 | } 27 | 28 | var re = regroup.MustCompile(`^//(?P[^/]+)/(?P[^/]+)/(?P.+):(?P[^:]+)`) 29 | 30 | var forSystem string 31 | 32 | var rootCmd = &cobra.Command{ 33 | Use: fmt.Sprintf("%[1]s //[cell]/[block]/[target]:[action] [args...]", argv0), 34 | DisableFlagsInUseLine: true, 35 | Version: fmt.Sprintf("%s (%s)", buildVersion, buildCommit), 36 | Short: fmt.Sprintf("%[1]s is the CLI / TUI companion for %[2]s", argv0, project), 37 | Long: fmt.Sprintf(`%[1]s is the CLI / TUI companion for %[2]s. 38 | 39 | - Invoke without any arguments to start the TUI. 40 | - Invoke with a target spec and action to run a known target's action directly. 41 | 42 | Enable autocompletion via '%[1]s _carapace '. 43 | For more instructions, see: https://rsteube.github.io/carapace/carapace/gen/hiddenSubcommand.html 44 | `, argv0, project), 45 | Args: func(cmd *cobra.Command, args []string) error { 46 | s := &Spec{} 47 | if err := re.MatchToTarget(args[0], s); err != nil { 48 | return fmt.Errorf("invalid argument format: %s", args[0]) 49 | } 50 | return nil 51 | }, 52 | RunE: func(cmd *cobra.Command, args []string) error { 53 | s := &Spec{} 54 | if err := re.MatchToTarget(args[0], s); err != nil { 55 | return err 56 | } 57 | command := flake.RunActionCmd{ 58 | ShowCmdStr: false, 59 | CmdStr: strings.Join(args[:], " "), 60 | System: forSystem, 61 | Cell: s.Cell, 62 | Block: s.Block, 63 | Target: s.Target, 64 | Action: s.Action} 65 | if err := command.Exec(args[1:]); err != nil { 66 | return err 67 | } 68 | return nil 69 | 70 | }, 71 | } 72 | var reCacheCmd = &cobra.Command{ 73 | Use: "re-cache", 74 | Short: "Refresh the CLI cache.", 75 | Long: `Refresh the CLI cache. 76 | Use this command to cold-start or refresh the CLI cache. 77 | The TUI does this automatically, but the command completion needs manual initialization of the CLI cache.`, 78 | Args: cobra.NoArgs, 79 | RunE: func(cmd *cobra.Command, args []string) error { 80 | c, key, loadCmd, buf, err := flake.LoadFlakeCmd() 81 | if err != nil { 82 | return fmt.Errorf("while loading flake (cmd '%v'): %w", loadCmd, err) 83 | } 84 | loadCmd.Run() 85 | c.PutBytes(*key, buf.Bytes()) 86 | return nil 87 | }, 88 | } 89 | var checkCmd = &cobra.Command{ 90 | Use: "check", 91 | Short: "Validate the repository.", 92 | Long: fmt.Sprintf(`Validates that the repository conforms to %[1]s. 93 | Returns a non-zero exit code and an error message if the repository is not a valid %[1]s repository. 94 | The TUI does this automatically.`, project), 95 | Args: cobra.NoArgs, 96 | RunE: func(cmd *cobra.Command, args []string) error { 97 | _, _, loadCmd, _, err := flake.LoadFlakeCmd() 98 | loadCmd.Args = append(loadCmd.Args, "--trace-verbose") 99 | if err != nil { 100 | return fmt.Errorf("while loading flake (cmd '%v'): %w", loadCmd, err) 101 | } 102 | loadCmd.Stderr = os.Stderr 103 | if err := loadCmd.Run(); err != nil { 104 | os.Exit(1) 105 | } 106 | fmt.Printf("Valid %s repository ✓\n", project) 107 | 108 | return nil 109 | }, 110 | } 111 | var listCmd = &cobra.Command{ 112 | Use: "list", 113 | Short: "List available targets.", 114 | Long: `List available targets. 115 | Shows a list of all available targets. Can be used as an alternative to the TUI. 116 | Also loads the CLI cache, if no cache is found. Reads the cache, otherwise.`, 117 | Args: cobra.NoArgs, 118 | RunE: func(cmd *cobra.Command, args []string) error { 119 | cache, key, loadCmd, buf, err := flake.LoadFlakeCmd() 120 | if err != nil { 121 | return fmt.Errorf("while loading flake (cmd '%v'): %w", loadCmd, err) 122 | } 123 | cached, _, err := cache.GetBytes(*key) 124 | var root *data.Root 125 | if err == nil { 126 | root, err = LoadJson(bytes.NewReader(cached)) 127 | if err != nil { 128 | return fmt.Errorf("while loading cached json: %w", err) 129 | } 130 | } else { 131 | loadCmd.Run() 132 | bufA := &bytes.Buffer{} 133 | r := io.TeeReader(buf, bufA) 134 | root, err = LoadJson(r) 135 | if err != nil { 136 | return fmt.Errorf("while loading json (cmd: '%v'): %w", loadCmd, err) 137 | } 138 | cache.PutBytes(*key, bufA.Bytes()) 139 | } 140 | w := tabwriter.NewWriter(os.Stdout, 5, 2, 4, ' ', 0) 141 | for _, c := range root.Cells { 142 | for _, o := range c.Blocks { 143 | for _, t := range o.Targets { 144 | for _, a := range t.Actions { 145 | fmt.Fprintf(w, "//%s/%s/%s:%s\t--\t%s: %s\n", c.Name, o.Name, t.Name, a.Name, t.Description(), a.Description()) 146 | } 147 | } 148 | } 149 | } 150 | w.Flush() 151 | return nil 152 | }, 153 | } 154 | 155 | func ExecuteCli() { 156 | if err := rootCmd.Execute(); err != nil { 157 | fmt.Println(err) 158 | os.Exit(1) 159 | } 160 | } 161 | 162 | func init() { 163 | rootCmd.Flags().StringVar(&forSystem, "for", "", "system, for which the target will be built (e.g. 'x86_64-linux')") 164 | rootCmd.AddCommand(reCacheCmd) 165 | rootCmd.AddCommand(listCmd) 166 | rootCmd.AddCommand(checkCmd) 167 | carapace.Gen(rootCmd).Standalone() 168 | // completes: '//cell/block/target:action' 169 | carapace.Gen(rootCmd).PositionalCompletion( 170 | carapace.ActionCallback(func(ctx carapace.Context) carapace.Action { 171 | cache, key, _, _, err := flake.LoadFlakeCmd() 172 | if err != nil { 173 | return carapace.ActionMessage(fmt.Sprintf("%v\n", err)) 174 | } 175 | cached, _, err := cache.GetBytes(*key) 176 | var root *data.Root 177 | if err == nil { 178 | root, err = LoadJson(bytes.NewReader(cached)) 179 | if err != nil { 180 | return carapace.ActionMessage(fmt.Sprintf("%v\n", err)) 181 | } 182 | } else { 183 | return carapace.ActionMessage(fmt.Sprintf("No completion cache: please initialize by running '%[1]s re-cache'.", argv0)) 184 | } 185 | var cells = []string{} 186 | var blocks = map[string][]string{} 187 | var targets = map[string]map[string][]string{} 188 | var actions = map[string]map[string]map[string][]string{} 189 | for _, c := range root.Cells { 190 | blocks[c.Name] = []string{} 191 | targets[c.Name] = map[string][]string{} 192 | actions[c.Name] = map[string]map[string][]string{} 193 | cells = append(cells, c.Name, "cell") 194 | for _, b := range c.Blocks { 195 | targets[c.Name][b.Name] = []string{} 196 | actions[c.Name][b.Name] = map[string][]string{} 197 | blocks[c.Name] = append(blocks[c.Name], b.Name, "block") 198 | for _, t := range b.Targets { 199 | actions[c.Name][b.Name][t.Name] = []string{} 200 | targets[c.Name][b.Name] = append(targets[c.Name][b.Name], t.Name, t.Description()) 201 | for _, a := range t.Actions { 202 | actions[c.Name][b.Name][t.Name] = append( 203 | actions[c.Name][b.Name][t.Name], 204 | a.Name, 205 | a.Description(), 206 | ) 207 | } 208 | } 209 | } 210 | } 211 | return carapace.ActionMultiParts("/", func(c carapace.Context) carapace.Action { 212 | switch len(c.Parts) { 213 | // start with ; no typing 214 | case 0: 215 | return carapace.ActionValuesDescribed( 216 | cells..., 217 | ).Invoke(c).Prefix("//").Suffix("/").ToA().Style( 218 | style.Of(style.Bold, style.Carapace.Highlight(1))) 219 | // only a single / typed 220 | case 1: 221 | return carapace.ActionValuesDescribed( 222 | cells..., 223 | ).Invoke(c).Prefix("/").Suffix("/").ToA() 224 | // start typing cell 225 | case 2: 226 | return carapace.ActionValuesDescribed( 227 | cells..., 228 | ).Invoke(c).Suffix("/").ToA().Style( 229 | style.Carapace.Highlight(1)) 230 | // start typing block 231 | case 3: 232 | return carapace.ActionValuesDescribed( 233 | blocks[c.Parts[2]]..., 234 | ).Invoke(c).Suffix("/").ToA().Style( 235 | style.Carapace.Highlight(2)) 236 | // start typing target 237 | case 4: 238 | return carapace.ActionMultiParts(":", func(d carapace.Context) carapace.Action { 239 | switch len(d.Parts) { 240 | // start typing target 241 | case 0: 242 | return carapace.ActionValuesDescribed( 243 | targets[c.Parts[2]][c.Parts[3]]..., 244 | ).Invoke(c).Suffix(":").ToA().Style( 245 | style.Carapace.Highlight(3)) 246 | // start typing action 247 | case 1: 248 | return carapace.ActionValuesDescribed( 249 | actions[c.Parts[2]][c.Parts[3]][d.Parts[0]]..., 250 | ).Invoke(c).ToA() 251 | default: 252 | return carapace.ActionValues() 253 | } 254 | }) 255 | default: 256 | return carapace.ActionValues() 257 | } 258 | }) 259 | 260 | }), 261 | ) 262 | } 263 | -------------------------------------------------------------------------------- /src/data/data.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/paisano-nix/paisano/flake" 7 | ) 8 | 9 | var ( 10 | targetTemplate = "//%s/%s/%s" 11 | actionTemplate = "//%s/%s/%s:%s" 12 | noReadme = "🥺 No Readme available ...\n\n💡 But hey! You could create one ...\n\n💪 Start with: `$EDITOR %s` (don't forget to commit it!)\n\n👉 It will also be rendered in the docs!" 13 | noDescription = "🥺 Target has no 'meta.description' attribute" 14 | ) 15 | 16 | type Root struct { 17 | Cells []Cell 18 | } 19 | 20 | type Cell struct { 21 | Name string `json:"cell"` 22 | Readme *string `json:"readme,omitempty"` 23 | Blocks []Block `json:"cellBlocks"` 24 | } 25 | 26 | type Block struct { 27 | Name string `json:"cellBlock"` 28 | Readme *string `json:"readme,omitempty"` 29 | Blocktype string `json:"blockType"` 30 | Targets []Target `json:"targets"` 31 | } 32 | 33 | type Action struct { 34 | Name string `json:"name"` 35 | Descr string `json:"description"` 36 | RequiresArgs *bool `json:"requiresArgs,omitempty"` 37 | } 38 | 39 | func (a Action) Title() string { return a.Name } 40 | func (a Action) Description() string { return a.Descr } 41 | func (a Action) FilterValue() string { return a.Title() } 42 | 43 | type Target struct { 44 | Name string `json:"name"` 45 | Readme *string `json:"readme,omitempty"` 46 | Deps []string `json:"deps"` 47 | Descr *string `json:"description,omitempty"` 48 | Actions []Action `json:"actions"` 49 | } 50 | 51 | func (t Target) Description() string { 52 | if t.Descr != nil { 53 | return "💡 " + *t.Descr 54 | } else { 55 | return noDescription 56 | } 57 | } 58 | 59 | func (r *Root) Select(ci, bi, ti int) (Cell, Block, Target) { 60 | var ( 61 | c = r.Cells[ci] 62 | b = c.Blocks[bi] 63 | t = b.Targets[ti] 64 | ) 65 | return c, b, t 66 | } 67 | 68 | func (r *Root) ActionArg(ci, bi, ti, ai int) string { 69 | c, b, t := r.Select(ci, bi, ti) 70 | a := t.Actions[ai] 71 | return fmt.Sprintf(actionTemplate, c.Name, b.Name, t.Name, a.Name) 72 | } 73 | 74 | func (r *Root) ActionTitle(ci, bi, ti, ai int) string { 75 | _, _, t := r.Select(ci, bi, ti) 76 | a := t.Actions[ai] 77 | return a.Title() 78 | } 79 | 80 | func (r *Root) ActionDescription(ci, bi, ti, ai int) string { 81 | _, _, t := r.Select(ci, bi, ti) 82 | a := t.Actions[ai] 83 | return a.Description() 84 | } 85 | func (r *Root) ActionRequiresArgs(ci, bi, ti, ai int) *bool { 86 | _, _, t := r.Select(ci, bi, ti) 87 | a := t.Actions[ai] 88 | return a.RequiresArgs 89 | } 90 | 91 | func (r *Root) TargetTitle(ci, bi, ti int) string { 92 | c, b, t := r.Select(ci, bi, ti) 93 | return fmt.Sprintf(targetTemplate, c.Name, b.Name, t.Name) 94 | } 95 | 96 | func (r *Root) TargetDescription(ci, bi, ti int) string { 97 | _, _, t := r.Select(ci, bi, ti) 98 | return t.Description() 99 | } 100 | func (r *Root) Cell(ci, bi, ti int) Cell { c, _, _ := r.Select(ci, bi, ti); return c } 101 | func (r *Root) CellName(ci, bi, ti int) string { return r.Cell(ci, bi, ti).Name } 102 | func (r *Root) CellHelp(ci, bi, ti int) string { 103 | if r.HasCellHelp(ci, bi, ti) { 104 | return *r.Cell(ci, bi, ti).Readme 105 | } else { 106 | return fmt.Sprintf(noReadme, fmt.Sprintf("%s/%s/Readme.md", flake.CellsFrom.Value(), r.CellName(ci, bi, ti))) 107 | } 108 | } 109 | func (r *Root) HasCellHelp(ci, bi, ti int) bool { 110 | c := r.Cell(ci, bi, ti) 111 | return c.Readme != nil 112 | } 113 | func (r *Root) Block(ci, bi, ti int) Block { _, o, _ := r.Select(ci, bi, ti); return o } 114 | func (r *Root) BlockName(ci, bi, ti int) string { return r.Block(ci, bi, ti).Name } 115 | func (r *Root) BlockHelp(ci, bi, ti int) string { 116 | if r.HasBlockHelp(ci, bi, ti) { 117 | return *r.Block(ci, bi, ti).Readme 118 | } else { 119 | return fmt.Sprintf(noReadme, fmt.Sprintf("%s/%s/%s/Readme.md", flake.CellsFrom.Value(), r.CellName(ci, bi, ti), r.BlockName(ci, bi, ti))) 120 | } 121 | } 122 | func (r *Root) HasBlockHelp(ci, bi, ti int) bool { 123 | b := r.Block(ci, bi, ti) 124 | return b.Readme != nil 125 | } 126 | func (r *Root) Target(ci, bi, ti int) Target { _, _, t := r.Select(ci, bi, ti); return t } 127 | func (r *Root) TargetName(ci, bi, ti int) string { return r.Target(ci, bi, ti).Name } 128 | func (r *Root) TargetHelp(ci, bi, ti int) string { 129 | if r.HasTargetHelp(ci, bi, ti) { 130 | return *r.Target(ci, bi, ti).Readme 131 | } else { 132 | return fmt.Sprintf(noReadme, fmt.Sprintf("%s/%s/%s/%s.md", flake.CellsFrom.Value(), r.CellName(ci, bi, ti), r.BlockName(ci, bi, ti), r.TargetName(ci, bi, ti))) 133 | } 134 | } 135 | func (r *Root) HasTargetHelp(ci, bi, ti int) bool { 136 | t := r.Target(ci, bi, ti) 137 | return t.Readme != nil 138 | } 139 | 140 | func (r *Root) Len() int { 141 | sum := 0 142 | for _, c := range r.Cells { 143 | for _, o := range c.Blocks { 144 | sum += len(o.Targets) 145 | } 146 | } 147 | return sum 148 | } 149 | -------------------------------------------------------------------------------- /src/default.md: -------------------------------------------------------------------------------- 1 | The CLI/TUI only has a single target, called `default`. 2 | 3 | Please consult the Cell Block's Readme for more information. 4 | -------------------------------------------------------------------------------- /src/env/env.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | spec "github.com/numtide/prj-spec/contrib/go" 9 | ) 10 | 11 | // extraNixConfig implements quality of life flags for the nix command invocation 12 | var extraNixConfig = strings.Join([]string{ 13 | // can never occur: actions invoke store path copies of the flake 14 | // "warn-dirty = false", 15 | "accept-flake-config = true", 16 | "builders-use-substitutes = true", 17 | // TODO: these are unfortunately not available for setting as env flags 18 | // update-lock-file = false, 19 | // write-lock-file = false, 20 | }, "\n") 21 | 22 | func SetEnv() { 23 | spec.SetAll() // PRJ_* 24 | 25 | nixConfigEnv, present := os.LookupEnv("NIX_CONFIG") 26 | if !present { 27 | os.Setenv("NIX_CONFIG", extraNixConfig) 28 | } else { 29 | os.Setenv("NIX_CONFIG", fmt.Sprintf("%s\n%s", nixConfigEnv, extraNixConfig)) 30 | } 31 | } 32 | 33 | func GetStateActionPath() (string, error) { return spec.DataFile("last-action") } 34 | func GetProjectMetadataCacheDir() (string, error) { 35 | path, err := spec.CacheFile("metadata") 36 | if err != nil { 37 | return "", err 38 | } 39 | err = os.MkdirAll(path, os.ModePerm) 40 | if err != nil { 41 | return "", err 42 | } 43 | return path, nil 44 | } 45 | -------------------------------------------------------------------------------- /src/flake/flake.go: -------------------------------------------------------------------------------- 1 | package flake 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "os/exec" 8 | "text/template" 9 | 10 | "github.com/hymkor/go-lazy" 11 | ) 12 | 13 | var ( 14 | registry = "__std" // keep for now for historic reasons 15 | flakeRegistry = func(flake string) string { return fmt.Sprintf("%[2]s#%[1]s", registry, flake) } 16 | ) 17 | 18 | type outt struct { 19 | drvPath string `json:"drvPath"` 20 | outputs map[string]string `json:"outputs"` 21 | } 22 | 23 | var CellsFrom = lazy.Of[string]{ 24 | New: func() string { 25 | if s, err := getCellsFrom(); err != nil { 26 | return "${cellsFrom}" 27 | } else { 28 | return s 29 | } 30 | }, 31 | } 32 | 33 | // tprintf passed template string is formatted usign its operands and returns the resulting string. 34 | // Spaces are added between operands when neither is a string. 35 | func tprintf(data interface{}, tmpl string) string { 36 | t := template.Must(template.New("tmp").Parse(tmpl)) 37 | buf := &bytes.Buffer{} 38 | if err := t.Execute(buf, data); err != nil { 39 | return "" 40 | } 41 | return buf.String() 42 | } 43 | 44 | func getNix() (string, error) { 45 | nix, err := exec.LookPath("nix") 46 | if err != nil { 47 | return "", errors.New("You need to install 'nix' in order to use this tool") 48 | } 49 | return nix, nil 50 | } 51 | 52 | func getCurrentSystem() (string, error) { 53 | // detect the current system 54 | nix, err := getNix() 55 | if err != nil { 56 | return "", err 57 | } 58 | currentSystem, err := exec.Command( 59 | nix, "eval", "--raw", "--impure", "--expr", "builtins.currentSystem", 60 | ).Output() 61 | if err != nil { 62 | if exitErr, ok := err.(*exec.ExitError); ok { 63 | return "", fmt.Errorf("%w, stderr:\n%s", exitErr, exitErr.Stderr) 64 | } 65 | return "", err 66 | } 67 | currentSystemStr := string(currentSystem) 68 | return currentSystemStr, nil 69 | } 70 | 71 | func getCellsFrom() (string, error) { 72 | nix, err := getNix() 73 | if err != nil { 74 | return "", err 75 | } 76 | cellsFrom, err := exec.Command( 77 | nix, "eval", "--raw", flakeRegistry(".")+".cellsFrom", 78 | ).Output() 79 | if err != nil { 80 | if exitErr, ok := err.(*exec.ExitError); ok { 81 | return "", fmt.Errorf("%w, stderr:\n%s", exitErr, exitErr.Stderr) 82 | } 83 | return "", err 84 | } 85 | return string(cellsFrom[:]), nil 86 | } 87 | -------------------------------------------------------------------------------- /src/flake/load.go: -------------------------------------------------------------------------------- 1 | package flake 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | 9 | "github.com/paisano-nix/paisano/cache" 10 | "github.com/paisano-nix/paisano/env" 11 | ) 12 | 13 | func LoadFlakeCmd() (*cache.Cache, *cache.ActionID, *exec.Cmd, *bytes.Buffer, error) { 14 | 15 | nix, err := getNix() 16 | if err != nil { 17 | return nil, nil, nil, nil, err 18 | } 19 | 20 | currentSystem, err := getCurrentSystem() 21 | if err != nil { 22 | return nil, nil, nil, nil, err 23 | } 24 | 25 | devNull, err := os.Open(os.DevNull) 26 | if err != nil { 27 | return nil, nil, nil, nil, err 28 | } 29 | 30 | // load the paisano metadata from the flake 31 | buf := new(bytes.Buffer) 32 | args := []string{ 33 | "eval", 34 | "--json", 35 | "--no-update-lock-file", 36 | "--no-write-lock-file", 37 | "--no-warn-dirty", 38 | "--accept-flake-config", 39 | flakeRegistry(".") + ".init." + currentSystem} 40 | cmd := exec.Command(nix, args...) 41 | cmd.Stdin = devNull 42 | cmd.Stdout = buf 43 | 44 | // initialize cache 45 | metadataCacheDir, err := env.GetProjectMetadataCacheDir() 46 | if err != nil { 47 | return nil, nil, nil, nil, err 48 | } 49 | c, err := cache.Open(metadataCacheDir) 50 | if err != nil { 51 | return nil, nil, nil, nil, err 52 | } 53 | key := cache.NewActionID([]byte(strings.Join(args, ""))) 54 | 55 | return c, &key, cmd, buf, nil 56 | } 57 | -------------------------------------------------------------------------------- /src/flake/run.go: -------------------------------------------------------------------------------- 1 | package flake 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | "syscall" 10 | 11 | "github.com/paisano-nix/paisano/env" 12 | ) 13 | 14 | type RunActionCmd struct { 15 | ShowCmdStr bool 16 | CmdStr string 17 | System string 18 | Cell string 19 | Block string 20 | Target string 21 | Action string 22 | RequiresArgs *bool 23 | } 24 | 25 | func (c *RunActionCmd) Assemble(extraArgs []string) (string, []string, error) { 26 | nix, err := getNix() 27 | if err != nil { 28 | return "", nil, err 29 | } 30 | 31 | currentSystem, err := getCurrentSystem() 32 | if err != nil { 33 | return "", nil, err 34 | } 35 | 36 | args, err := c.getArgs(currentSystem) 37 | if err != nil { 38 | return "", nil, err 39 | } 40 | 41 | if extraArgs != nil && len(extraArgs) > 0 { 42 | args = append(args, "--") 43 | args = append(args, extraArgs...) 44 | } 45 | return nix, args, nil 46 | } 47 | 48 | func (c *RunActionCmd) Build(nix string, args, extraArgs []string) (string, []string, error) { 49 | if c.RequiresArgs != nil && *c.RequiresArgs == true && len(extraArgs) == 0 { 50 | return "", nil, errors.New(c.CmdStr + " - requires on or more arguments; run from command line") 51 | } 52 | bash, err := exec.LookPath("bash") 53 | if err != nil { 54 | return "", nil, err 55 | } 56 | // grep, err := exec.LookPath("grep") 57 | // if err != nil { 58 | // return "", nil, err 59 | // } 60 | nom, err := exec.LookPath("nom") 61 | if err == nil { 62 | nix = nom 63 | } 64 | actionPath, err := env.GetStateActionPath() 65 | if err != nil { 66 | return "", nil, err 67 | } 68 | printout := "" 69 | if c.ShowCmdStr { 70 | printout += "echo -e \"\x1b[1;37m------------" + strings.Repeat("-", len(c.CmdStr)) + "-\x1b[0m\";" 71 | printout += "echo -e \"\x1b[1;37m Executing: \x1b[1;32m" + c.CmdStr + "\x1b[0m\";" 72 | printout += "echo -e \"\x1b[1;37m------------" + strings.Repeat("-", len(c.CmdStr)) + "-\x1b[0m\";" 73 | } 74 | args = append(args, "--out-link", actionPath) 75 | args = append(args, 76 | "--no-update-lock-file", 77 | "--no-write-lock-file", 78 | "--no-warn-dirty", 79 | "--accept-flake-config", 80 | "--builders-use-substitutes", 81 | "|| exit 1;", 82 | printout, 83 | "exec", actionPath, "\"${@}\"", 84 | ) 85 | cmd := []string{bash, "-c", nix + " build " + strings.Join(args, " ")} 86 | if extraArgs != nil && len(extraArgs) > 0 { 87 | cmd = append(cmd, "--") 88 | cmd = append(cmd, extraArgs...) 89 | } 90 | // fmt.Printf("%+v\n", cmd) 91 | return bash, cmd, nil 92 | } 93 | 94 | func (c *RunActionCmd) Exec(extraArgs []string) error { 95 | 96 | nix, args, err := c.Assemble(nil) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | bash, cmd, err := c.Build(nix, args, extraArgs) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | env.SetEnv() // PRJ_* + NIX_CONFIG 107 | if err := syscall.Exec(bash, cmd, os.Environ()); err != nil { 108 | return err 109 | } 110 | return nil 111 | } 112 | 113 | func (c *RunActionCmd) getArgs(currentSystem string) ([]string, error) { 114 | 115 | if c.System == currentSystem { 116 | return nil, fmt.Errorf("set the --for flag to a different system than the current one ('%s')", currentSystem) 117 | } 118 | 119 | if c.System != "" { 120 | // if system is set, the impure flag provides a "hack" so that we 121 | // can transport this information to the action evaluation without 122 | // incurring in a prohibitively complex (m*n) data structure in 123 | // which we would have to account for _all_ combinations of current 124 | // and build system 125 | return []string{"--impure", c.renderFragmentFor(c.System)}, nil 126 | } 127 | return []string{c.renderFragmentFor(currentSystem)}, nil 128 | } 129 | 130 | func (c *RunActionCmd) renderFragmentFor(system string) string { 131 | return tprintf(c, "'"+flakeRegistry(".")+".actions."+system+".\"{{.Cell}}\".\"{{.Block}}\".\"{{.Target}}\".\"{{.Action}}\"'") 132 | } 133 | -------------------------------------------------------------------------------- /src/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/paisano-nix/paisano 2 | 3 | go 1.21 4 | 5 | toolchain go1.21.6 6 | 7 | require ( 8 | github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 9 | github.com/aymanbagabas/go-osc52 v1.2.2 10 | github.com/charmbracelet/bubbles v0.18.0 11 | github.com/charmbracelet/bubbletea v0.25.0 12 | github.com/charmbracelet/lipgloss v0.9.1 13 | github.com/google/renameio/v2 v2.0.0 14 | github.com/hymkor/go-lazy v0.4.0 15 | github.com/knipferrc/teacup v0.3.1 16 | github.com/numtide/prj-spec/contrib/go v0.0.0-00010101000000-000000000000 17 | github.com/oriser/regroup v0.0.0-20230527212431-1b00c9bdbc5b 18 | github.com/rogpeppe/go-internal v1.12.0 19 | github.com/rsteube/carapace v0.50.0 20 | github.com/spf13/cobra v1.8.0 21 | ) 22 | 23 | require ( 24 | github.com/adrg/xdg v0.4.0 // indirect 25 | github.com/alecthomas/chroma v0.10.0 // indirect 26 | github.com/atotto/clipboard v0.1.4 // indirect 27 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 28 | github.com/aymerick/douceur v0.2.0 // indirect 29 | github.com/charmbracelet/glamour v0.6.0 // indirect 30 | github.com/containerd/console v1.0.4 // indirect 31 | github.com/dlclark/regexp2 v1.10.0 // indirect 32 | github.com/fatih/color v1.16.0 // indirect 33 | github.com/gorilla/css v1.0.1 // indirect 34 | github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // indirect 35 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 36 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 37 | github.com/mattn/go-colorable v0.1.13 // indirect 38 | github.com/mattn/go-isatty v0.0.20 // indirect 39 | github.com/mattn/go-localereader v0.0.1 // indirect 40 | github.com/mattn/go-runewidth v0.0.15 // indirect 41 | github.com/microcosm-cc/bluemonday v1.0.26 // indirect 42 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 43 | github.com/muesli/cancelreader v0.2.2 // indirect 44 | github.com/muesli/reflow v0.3.0 // indirect 45 | github.com/muesli/termenv v0.15.2 // indirect 46 | github.com/olekukonko/tablewriter v0.0.5 // indirect 47 | github.com/rivo/uniseg v0.4.7 // indirect 48 | github.com/rsteube/carapace-shlex v0.1.2 // indirect 49 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect 50 | github.com/spf13/pflag v1.0.5 // indirect 51 | github.com/yuin/goldmark v1.7.0 // indirect 52 | github.com/yuin/goldmark-emoji v1.0.2 // indirect 53 | golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect 54 | golang.org/x/net v0.21.0 // indirect 55 | golang.org/x/sync v0.6.0 // indirect 56 | golang.org/x/sys v0.17.0 // indirect 57 | golang.org/x/term v0.17.0 // indirect 58 | golang.org/x/text v0.14.0 // indirect 59 | gopkg.in/yaml.v3 v3.0.1 // indirect 60 | ) 61 | 62 | replace github.com/numtide/prj-spec/contrib/go => github.com/blaggacao/prj-spec/contrib/go v0.0.0-20240217212339-2ca723041d63 63 | -------------------------------------------------------------------------------- /src/go.sum: -------------------------------------------------------------------------------- 1 | github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4= 2 | github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w= 3 | github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= 4 | github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= 5 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= 6 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= 7 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 8 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 9 | github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 10 | github.com/aymanbagabas/go-osc52 v1.2.2 h1:NT7wkhEhPTcKnBCdPi9djmyy9L3JOL4+3SsfJyqptCo= 11 | github.com/aymanbagabas/go-osc52 v1.2.2/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 12 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 13 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 14 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 15 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 16 | github.com/blaggacao/prj-spec/contrib/go v0.0.0-20240217212339-2ca723041d63 h1:Xt5Myl9RJmgTVbzq6DAWSbr+r8MAodb88fhCUVC9uDs= 17 | github.com/blaggacao/prj-spec/contrib/go v0.0.0-20240217212339-2ca723041d63/go.mod h1:Z8M0Lsc1sBOwnBHfy8nrFNuO9yqkE4+8IoPpbdoPHo4= 18 | github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= 19 | github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= 20 | github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= 21 | github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= 22 | github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc= 23 | github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc= 24 | github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= 25 | github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= 26 | github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= 27 | github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 28 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 29 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 30 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 31 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 32 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 33 | github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= 34 | github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 35 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 36 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 37 | github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg= 38 | github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4= 39 | github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= 40 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 41 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 42 | github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8= 43 | github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI= 44 | github.com/hymkor/go-lazy v0.4.0 h1:KHZzn64U0UTwdj7rn6kp7RMgFQPQ7rp2ld5H3d2a+JI= 45 | github.com/hymkor/go-lazy v0.4.0/go.mod h1:7weoQ6ibzJeNdZ6sj50tjiCv0bJdQeXXXo2EMGm8tH4= 46 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 47 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 48 | github.com/knipferrc/teacup v0.3.1 h1:EhEGAONKkOgW/7R339jYa+En03UaDlGSY/AImncMfBo= 49 | github.com/knipferrc/teacup v0.3.1/go.mod h1:96Y/xb6KS3vOaOxJuZZwnFas+4m7/FKeQtllLikkjl0= 50 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 51 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 52 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 53 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 54 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 55 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 56 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 57 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 58 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 59 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 60 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 61 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 62 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 63 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 64 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 65 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 66 | github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= 67 | github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= 68 | github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= 69 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 70 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 71 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 72 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 73 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 74 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 75 | github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= 76 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 77 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 78 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 79 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 80 | github.com/oriser/regroup v0.0.0-20230527212431-1b00c9bdbc5b h1:9L56kn3D7E9jd2R9U9p7tfzaBaLTVPt4HgnrP+g2VGk= 81 | github.com/oriser/regroup v0.0.0-20230527212431-1b00c9bdbc5b/go.mod h1:6eb1+OYHjOvThrtgEVue70NTfmzkalZgohRtndAUUbI= 82 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 83 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 84 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 85 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 86 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 87 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 88 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 89 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 90 | github.com/rsteube/carapace v0.50.0 h1:LO3ehEjcdbIx9owiyCiVfgL5A0cJPGq4X7c8V5DsA20= 91 | github.com/rsteube/carapace v0.50.0/go.mod h1:syVOvI8e2rEEK/9aMZxfWuHvcnQK/EcnTV4roClEnLE= 92 | github.com/rsteube/carapace-shlex v0.1.2 h1:ZKjhIfXoCkEnzensMglTaLbkNOaLkmM8SCRshpJKx6s= 93 | github.com/rsteube/carapace-shlex v0.1.2/go.mod h1:zPw1dOFwvLPKStUy9g2BYKanI6bsQMATzDMYQQybo3o= 94 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 95 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= 96 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 97 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 98 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 99 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 100 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 101 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 102 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 103 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 104 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 105 | github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 106 | github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 107 | github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA= 108 | github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 109 | github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= 110 | github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s= 111 | github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY= 112 | golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE= 113 | golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= 114 | golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= 115 | golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= 116 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 117 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 118 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 119 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 120 | golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 121 | golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 122 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 123 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 124 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 125 | golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= 126 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 127 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 128 | golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= 129 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 130 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 131 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 132 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 133 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 134 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 135 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 136 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 137 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 138 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 139 | -------------------------------------------------------------------------------- /src/keys/keys.go: -------------------------------------------------------------------------------- 1 | package keys 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbles/list" 6 | "github.com/charmbracelet/bubbles/viewport" 7 | ) 8 | 9 | const spacebar = " " 10 | 11 | var ( 12 | cursorUp = key.NewBinding(key.WithKeys("k", "up"), key.WithHelp("k/↑", "up")) 13 | cursorDown = key.NewBinding(key.WithKeys("j", "down"), key.WithHelp("j/↓", "down")) 14 | cursorLeft = key.NewBinding(key.WithKeys("left"), key.WithHelp("←", "½ back")) 15 | cursorRight = key.NewBinding(key.WithKeys("right"), key.WithHelp("→", "½ forward")) 16 | pageUp = key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "1 back")) 17 | pageDown = key.NewBinding(key.WithKeys("pgdown", spacebar), key.WithHelp("pgdn", "1 forward")) 18 | home = key.NewBinding(key.WithKeys("home"), key.WithHelp("home", "go to start")) 19 | end = key.NewBinding(key.WithKeys("end"), key.WithHelp("end", "go to end")) 20 | enter = key.NewBinding(key.WithKeys("enter"), key.WithHelp("⏎", "execute")) 21 | textcopy = key.NewBinding(key.WithKeys("c", "ctrl+c"), key.WithHelp("c", "copy cmd")) 22 | search = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")) 23 | showReadme = key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "inspect")) 24 | closeReadme = key.NewBinding(key.WithKeys("?", "esc"), key.WithHelp("?", "close")) 25 | quit = key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")) 26 | forceQuit = key.NewBinding(key.WithKeys("ctrl+c")) 27 | toggleFocus = key.NewBinding(key.WithKeys("tab", "shift+tab"), key.WithHelp("⇥", "toggle focus")) 28 | cycleTab = key.NewBinding(key.WithKeys("tab"), key.WithHelp("⇥", "cycle tabs")) 29 | reverseCycleTab = key.NewBinding(key.WithKeys("shift+tab")) 30 | ) 31 | 32 | type AppKeyMap struct { 33 | ToggleFocus key.Binding 34 | FocusLeft key.Binding 35 | FocusRight key.Binding 36 | ShowReadme key.Binding 37 | Quit key.Binding 38 | ForceQuit key.Binding 39 | } 40 | 41 | func NewAppKeyMap() *AppKeyMap { 42 | return &AppKeyMap{ 43 | ToggleFocus: toggleFocus, 44 | FocusLeft: cursorLeft, 45 | FocusRight: cursorRight, 46 | ShowReadme: showReadme, 47 | ForceQuit: forceQuit, 48 | Quit: quit, 49 | } 50 | } 51 | 52 | type ReadmeKeyMap struct { 53 | viewport.KeyMap 54 | CloseReadme key.Binding 55 | CycleTab key.Binding 56 | ReverseCycleTab key.Binding 57 | } 58 | 59 | func NewReadmeKeyMap() *ReadmeKeyMap { 60 | m := &ReadmeKeyMap{ 61 | CloseReadme: closeReadme, 62 | CycleTab: cycleTab, 63 | ReverseCycleTab: reverseCycleTab, 64 | } 65 | m.PageDown = pageUp 66 | m.PageUp = pageDown 67 | m.HalfPageUp = cursorLeft 68 | m.HalfPageDown = cursorRight 69 | m.Up = cursorUp 70 | m.Down = cursorDown 71 | return m 72 | } 73 | 74 | // DefaultListKeyMap returns a default set of keybindings. 75 | func DefaultListKeyMap() list.KeyMap { 76 | return list.KeyMap{ 77 | // Browsing. 78 | CursorUp: cursorUp, 79 | CursorDown: cursorDown, 80 | PrevPage: pageUp, 81 | NextPage: pageDown, 82 | GoToStart: home, 83 | GoToEnd: end, 84 | Filter: search, 85 | 86 | // Filtering. 87 | ClearFilter: key.NewBinding( 88 | key.WithKeys("esc"), 89 | key.WithHelp("esc", "clear filter"), 90 | ), 91 | CancelWhileFiltering: key.NewBinding( 92 | key.WithKeys("esc"), 93 | key.WithHelp("esc", "cancel"), 94 | ), 95 | AcceptWhileFiltering: key.NewBinding( 96 | key.WithKeys("enter", "tab", "up", "down"), 97 | key.WithHelp("enter", "apply filter"), 98 | ), 99 | } 100 | } 101 | 102 | type ActionDelegateKeyMap struct { 103 | Exec key.Binding 104 | Copy key.Binding 105 | Inspect key.Binding 106 | QuitInspect key.Binding 107 | } 108 | 109 | // Additional short help entries. This satisfies the help.KeyMap interface and 110 | // is entirely optional. 111 | func (d ActionDelegateKeyMap) ShortHelp() []key.Binding { 112 | return []key.Binding{ 113 | d.Exec, 114 | d.Copy, 115 | d.Inspect, 116 | d.QuitInspect, 117 | } 118 | } 119 | 120 | func NewActionDelegateKeyMap() *ActionDelegateKeyMap { 121 | return &ActionDelegateKeyMap{ 122 | Exec: enter, 123 | Copy: textcopy, 124 | Inspect: showReadme, 125 | QuitInspect: closeReadme, 126 | } 127 | } 128 | 129 | // ViewportKeyMap returns a set of pager-like default keybindings. 130 | func ViewportKeyMap() viewport.KeyMap { 131 | return viewport.KeyMap{ 132 | PageDown: pageUp, 133 | PageUp: pageDown, 134 | HalfPageUp: cursorLeft, 135 | HalfPageDown: cursorRight, 136 | Up: cursorUp, 137 | Down: cursorDown, 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/load.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | 10 | "github.com/TylerBrock/colorjson" 11 | 12 | "github.com/paisano-nix/paisano/data" 13 | ) 14 | 15 | func LoadJson(r io.Reader) (*data.Root, error) { 16 | var root = &data.Root{} 17 | 18 | var r2 bytes.Buffer 19 | r1 := io.TeeReader(r, &r2) 20 | 21 | var dec = json.NewDecoder(r1) 22 | 23 | if err := dec.Decode(&root.Cells); err != nil { 24 | var serr *json.SyntaxError 25 | if errors.As(err, &serr) { 26 | return nil, fmt.Errorf("json syntax error: %w: string:\n%v", err, r2.String()) 27 | } 28 | var obj interface{} 29 | var debugDecoder = json.NewDecoder(&r2) 30 | debugDecoder.Decode(&obj) 31 | f := colorjson.NewFormatter() 32 | f.Indent = 2 33 | s, _ := f.Marshal(obj) 34 | return nil, fmt.Errorf("%w - object: %s", err, s) 35 | } 36 | 37 | return root, nil 38 | } 39 | -------------------------------------------------------------------------------- /src/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | ) 9 | 10 | var ( 11 | buildVersion = "dev" 12 | buildCommit = "dirty" 13 | argv0 = "paisano" 14 | project = "Paisano" 15 | ) 16 | 17 | func main() { 18 | if len(os.Args[1:]) == 0 { 19 | // with NO arguments, invoke the TUI 20 | if model, err := tea.NewProgram( 21 | InitialPage(), 22 | tea.WithAltScreen(), 23 | ).StartReturningModel(); err != nil { 24 | log.Fatalf("Error running program: %s", err) 25 | } else if err := model.(*Tui).FatalError; err != nil { 26 | log.Fatal(err) 27 | } else if command := model.(*Tui).ExecveCommand; command != nil { 28 | if err := command.Exec(nil); err != nil { 29 | log.Fatal(err) 30 | } 31 | } 32 | } else { 33 | // with arguments, invoke the CLI 34 | ExecuteCli() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/models/readme.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/bubbles/help" 8 | "github.com/charmbracelet/bubbles/key" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | "github.com/knipferrc/teacup/markdown" 12 | 13 | "github.com/paisano-nix/paisano/data" 14 | "github.com/paisano-nix/paisano/keys" 15 | "github.com/paisano-nix/paisano/styles" 16 | ) 17 | 18 | var ( 19 | 20 | // Tabs. 21 | 22 | activeTabBorder = lipgloss.Border{ 23 | Top: "─", 24 | Bottom: " ", 25 | Left: "│", 26 | Right: "│", 27 | TopLeft: "╭", 28 | TopRight: "╮", 29 | BottomLeft: "┘", 30 | BottomRight: "└", 31 | } 32 | 33 | tabBorder = lipgloss.Border{ 34 | Top: "─", 35 | Bottom: "─", 36 | Left: "│", 37 | Right: "│", 38 | TopLeft: "╭", 39 | TopRight: "╮", 40 | BottomLeft: "┴", 41 | BottomRight: "┴", 42 | } 43 | 44 | tab = lipgloss.NewStyle(). 45 | Border(tabBorder, true). 46 | BorderForeground(styles.Highlight). 47 | Padding(0, 1) 48 | 49 | activeTab = tab.Copy().Border(activeTabBorder, true) 50 | 51 | tabGap = tab.Copy(). 52 | BorderTop(false). 53 | BorderLeft(false). 54 | BorderRight(false) 55 | ) 56 | 57 | type ReadmeModel struct { 58 | TargetHelp markdown.Model 59 | CellHelp markdown.Model 60 | BlockHelp markdown.Model 61 | Cell string 62 | Block string 63 | Target string 64 | Width int 65 | Height int 66 | KeyMap *keys.ReadmeKeyMap 67 | Help help.Model 68 | // Focus 69 | } 70 | 71 | type renderCellMarkdownMsg struct { 72 | msg tea.Msg 73 | } 74 | type renderBlockMarkdownMsg struct { 75 | msg tea.Msg 76 | } 77 | type renderTargetMarkdownMsg struct { 78 | msg tea.Msg 79 | } 80 | 81 | func (m *ReadmeModel) LoadReadme(d *data.Root, ci, bi, ti int) { 82 | m.Cell = d.CellName(ci, bi, ti) 83 | m.CellHelp.Viewport.SetContent(d.CellHelp(ci, bi, ti)) 84 | m.Block = d.BlockName(ci, bi, ti) 85 | m.BlockHelp.Viewport.SetContent(d.BlockHelp(ci, bi, ti)) 86 | m.Target = d.TargetName(ci, bi, ti) 87 | m.TargetHelp.Viewport.SetContent(d.TargetHelp(ci, bi, ti)) 88 | } 89 | 90 | func NewReadme() *ReadmeModel { 91 | var ( 92 | th = markdown.New(false, true, lipgloss.AdaptiveColor{}) 93 | ch = markdown.New(false, true, lipgloss.AdaptiveColor{}) 94 | oh = markdown.New(false, true, lipgloss.AdaptiveColor{}) 95 | ) 96 | th.Viewport.KeyMap = keys.ViewportKeyMap() 97 | ch.Viewport.KeyMap = keys.ViewportKeyMap() 98 | oh.Viewport.KeyMap = keys.ViewportKeyMap() 99 | return &ReadmeModel{ 100 | TargetHelp: th, 101 | CellHelp: ch, 102 | BlockHelp: oh, 103 | Help: help.New(), 104 | KeyMap: keys.NewReadmeKeyMap(), 105 | } 106 | } 107 | func (m *ReadmeModel) Init() tea.Cmd { 108 | return nil 109 | } 110 | 111 | func (m *ReadmeModel) RenderMarkdown(d *data.Root, ci, bi, ti int) tea.Cmd { 112 | var ( 113 | cmds []tea.Cmd 114 | cmd tea.Cmd 115 | ) 116 | m.LoadReadme(d, ci, bi, ti) 117 | m.TargetHelp.SetIsActive(true) 118 | if d.HasCellHelp(ci, bi, ti) { 119 | cmd = func() tea.Msg { 120 | return renderCellMarkdownMsg{ 121 | m.CellHelp.SetFileName( 122 | d.CellHelp(ci, bi, ti), 123 | )(), 124 | } 125 | } 126 | cmds = append(cmds, cmd) 127 | } 128 | if d.HasBlockHelp(ci, bi, ti) { 129 | cmd = func() tea.Msg { 130 | return renderBlockMarkdownMsg{ 131 | m.BlockHelp.SetFileName( 132 | d.BlockHelp(ci, bi, ti), 133 | )(), 134 | } 135 | } 136 | cmds = append(cmds, cmd) 137 | } 138 | if d.HasTargetHelp(ci, bi, ti) { 139 | cmd = func() tea.Msg { 140 | return renderTargetMarkdownMsg{ 141 | m.TargetHelp.SetFileName( 142 | d.TargetHelp(ci, bi, ti), 143 | )(), 144 | } 145 | } 146 | cmds = append(cmds, cmd) 147 | } 148 | return tea.Batch(cmds...) 149 | } 150 | 151 | func (m *ReadmeModel) Update(msg tea.Msg) (*ReadmeModel, tea.Cmd) { 152 | var ( 153 | cmd tea.Cmd 154 | ) 155 | switch msg := msg.(type) { 156 | case tea.KeyMsg: 157 | switch { 158 | case key.Matches(msg, m.KeyMap.CycleTab): 159 | if m.TargetHelp.Active { 160 | m.TargetHelp.SetIsActive(false) 161 | m.CellHelp.SetIsActive(true) 162 | } else if m.CellHelp.Active { 163 | m.CellHelp.SetIsActive(false) 164 | m.BlockHelp.SetIsActive(true) 165 | } else if m.BlockHelp.Active { 166 | m.BlockHelp.SetIsActive(false) 167 | m.TargetHelp.SetIsActive(true) 168 | } 169 | return m, nil 170 | case key.Matches(msg, m.KeyMap.ReverseCycleTab): 171 | if m.TargetHelp.Active { 172 | m.TargetHelp.SetIsActive(false) 173 | m.BlockHelp.SetIsActive(true) 174 | } else if m.CellHelp.Active { 175 | m.CellHelp.SetIsActive(false) 176 | m.TargetHelp.SetIsActive(true) 177 | } else if m.BlockHelp.Active { 178 | m.BlockHelp.SetIsActive(false) 179 | m.CellHelp.SetIsActive(true) 180 | } 181 | return m, nil 182 | } 183 | case tea.WindowSizeMsg: 184 | m.CellHelp.SetSize(m.Width, m.Height) 185 | m.BlockHelp.SetSize(m.Width, m.Height) 186 | m.TargetHelp.SetSize(m.Width, m.Height) 187 | case renderCellMarkdownMsg: 188 | m.CellHelp, cmd = m.CellHelp.Update(msg.msg) 189 | return m, cmd 190 | case renderBlockMarkdownMsg: 191 | m.BlockHelp, cmd = m.BlockHelp.Update(msg.msg) 192 | return m, cmd 193 | case renderTargetMarkdownMsg: 194 | m.TargetHelp, cmd = m.TargetHelp.Update(msg.msg) 195 | return m, cmd 196 | } 197 | if m.TargetHelp.Active { 198 | m.TargetHelp, cmd = m.TargetHelp.Update(msg) 199 | } else if m.CellHelp.Active { 200 | m.CellHelp, cmd = m.CellHelp.Update(msg) 201 | } else if m.BlockHelp.Active { 202 | m.BlockHelp, cmd = m.BlockHelp.Update(msg) 203 | } 204 | return m, cmd 205 | } 206 | 207 | func (m *ReadmeModel) View() string { 208 | // Tabs 209 | var ( 210 | tabs []string 211 | content string 212 | ) 213 | if m.CellHelp.Active { 214 | tabs = append(tabs, activeTab.Render(fmt.Sprintf("Cell: %s", m.Cell))) 215 | content = m.CellHelp.View() 216 | } else { 217 | tabs = append(tabs, tab.Render(fmt.Sprintf("Cell: %s", m.Cell))) 218 | } 219 | if m.BlockHelp.Active { 220 | tabs = append(tabs, activeTab.Render(fmt.Sprintf("Block: %s", m.Block))) 221 | content = m.BlockHelp.View() 222 | } else { 223 | tabs = append(tabs, tab.Render(fmt.Sprintf("Block: %s", m.Block))) 224 | } 225 | if m.TargetHelp.Active { 226 | tabs = append(tabs, activeTab.Render(fmt.Sprintf("Target: %s", m.Target))) 227 | content = m.TargetHelp.View() 228 | } else { 229 | tabs = append(tabs, tab.Render(fmt.Sprintf("Target: %s", m.Target))) 230 | } 231 | 232 | row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) 233 | gap := tabGap.Render(strings.Repeat(" ", max(0, m.Width-lipgloss.Width(row)-2))) 234 | row = lipgloss.JoinHorizontal(lipgloss.Bottom, row, gap) 235 | 236 | return lipgloss.JoinVertical(lipgloss.Top, row, content) 237 | } 238 | 239 | func (m *ReadmeModel) ShortHelp() []key.Binding { 240 | kb := []key.Binding{ 241 | m.KeyMap.Up, 242 | m.KeyMap.Down, 243 | m.KeyMap.HalfPageUp, 244 | m.KeyMap.HalfPageDown, 245 | m.KeyMap.CycleTab, 246 | m.KeyMap.CloseReadme, 247 | } 248 | return kb 249 | } 250 | 251 | func (m *ReadmeModel) FullHelp() [][]key.Binding { 252 | kb := [][]key.Binding{{}} 253 | return kb 254 | } 255 | func max(a, b int) int { 256 | if a > b { 257 | return a 258 | } 259 | return b 260 | } 261 | -------------------------------------------------------------------------------- /src/styles/styles.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | ) 6 | 7 | var ( 8 | Highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} 9 | AppStyle = lipgloss.NewStyle().Padding(1, 2) 10 | 11 | ErrorStyle = lipgloss.NewStyle(). 12 | BorderStyle(lipgloss.NormalBorder()). 13 | BorderForeground(Highlight).Padding(0, 1) 14 | 15 | TargetStyle = lipgloss.NewStyle(). 16 | BorderStyle(lipgloss.NormalBorder()). 17 | BorderForeground(Highlight) 18 | 19 | ActionInspectionStyle = lipgloss.NewStyle(). 20 | BorderStyle(lipgloss.NormalBorder()). 21 | BorderForeground(Highlight).Padding(0, 1).Faint(true) 22 | 23 | ActionStyle = lipgloss.NewStyle(). 24 | BorderStyle(lipgloss.NormalBorder()). 25 | BorderForeground(Highlight) 26 | 27 | ReadmeStyle = lipgloss.NewStyle(). 28 | // BorderStyle(lipgloss.NormalBorder()). 29 | BorderForeground(Highlight) 30 | 31 | LegendStyle = lipgloss.NewStyle().Padding(1, 0, 0, 2) 32 | 33 | TitleStyle = lipgloss.NewStyle(). 34 | Foreground(Highlight).Bold(true). 35 | Padding(1, 1) 36 | 37 | CacheWarning = lipgloss.NewStyle(). 38 | Foreground(Highlight).Bold(true).MarginLeft(4) 39 | ) 40 | -------------------------------------------------------------------------------- /src/tui.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "strings" 8 | 9 | "github.com/aymanbagabas/go-osc52" 10 | "github.com/charmbracelet/bubbles/help" 11 | "github.com/charmbracelet/bubbles/key" 12 | "github.com/charmbracelet/bubbles/list" 13 | "github.com/charmbracelet/bubbles/spinner" 14 | tea "github.com/charmbracelet/bubbletea" 15 | "github.com/charmbracelet/lipgloss" 16 | 17 | "github.com/paisano-nix/paisano/data" 18 | "github.com/paisano-nix/paisano/flake" 19 | "github.com/paisano-nix/paisano/keys" 20 | "github.com/paisano-nix/paisano/models" 21 | "github.com/paisano-nix/paisano/styles" 22 | ) 23 | 24 | type Focus int64 25 | type Loaded int64 26 | 27 | const ( 28 | Left Focus = iota 29 | Right 30 | Readme 31 | Inspect 32 | 33 | FromFlake Loaded = iota 34 | FromCache 35 | Loading 36 | ) 37 | 38 | var ( 39 | cmdTemplate = func(target, action string) string { return fmt.Sprintf("%[1]s %[2]s:%[3]s", argv0, target, action) } 40 | ) 41 | 42 | func (s Focus) String() string { 43 | switch s { 44 | case Left: 45 | return "left focus" 46 | case Right: 47 | return "right focus" 48 | } 49 | return "unknown" 50 | } 51 | 52 | type Targets = list.Model 53 | 54 | type TargetItem struct { 55 | r *data.Root 56 | CellIdx int 57 | BlockIdx int 58 | TargetIdx int 59 | } 60 | 61 | func (i TargetItem) Title() string { return i.r.TargetTitle(i.CellIdx, i.BlockIdx, i.TargetIdx) } 62 | func (i TargetItem) Description() string { 63 | return i.r.TargetDescription(i.CellIdx, i.BlockIdx, i.TargetIdx) 64 | } 65 | func (i TargetItem) FilterValue() string { return i.Title() } 66 | 67 | type Actions = list.Model 68 | 69 | type ActionItem struct { 70 | r *data.Root 71 | CellIdx int 72 | BlockIdx int 73 | TargetIdx int 74 | ActionIdx int 75 | } 76 | 77 | func (i ActionItem) Title() string { 78 | return i.r.ActionTitle(i.CellIdx, i.BlockIdx, i.TargetIdx, i.ActionIdx) 79 | } 80 | func (i ActionItem) Description() string { 81 | return i.r.ActionDescription(i.CellIdx, i.BlockIdx, i.TargetIdx, i.ActionIdx) 82 | } 83 | func (i ActionItem) FilterValue() string { return i.Title() } 84 | 85 | type Tui struct { 86 | r *data.Root 87 | 88 | Left Targets 89 | Right Actions 90 | Readme *models.ReadmeModel 91 | Legend help.Model 92 | Keys *keys.AppKeyMap 93 | Title string 94 | InspectAction string 95 | ExecveCommand *flake.RunActionCmd 96 | Spinner spinner.Model 97 | FatalError error 98 | Loaded 99 | Focus 100 | Width int 101 | Height int 102 | } 103 | 104 | func (m *Tui) LoadTargets() tea.Cmd { 105 | var ( 106 | numItems = m.r.Len() 107 | counter = 0 108 | ) 109 | // Make list of actions 110 | items := make([]list.Item, numItems) 111 | for ci, c := range m.r.Cells { 112 | for bi, b := range c.Blocks { 113 | for ti := range b.Targets { 114 | items[counter] = &TargetItem{m.r, ci, bi, ti} 115 | counter += 1 116 | } 117 | } 118 | } 119 | cmd := m.Left.SetItems(items) 120 | if m.Left.SelectedItem() != nil { 121 | target := m.Left.SelectedItem().(*TargetItem) 122 | cmd = tea.Batch(cmd, m.LoadActions(target)) 123 | } 124 | return cmd 125 | } 126 | 127 | func (m *Tui) LoadActions(i *TargetItem) tea.Cmd { 128 | _, _, t := m.r.Select(i.CellIdx, i.BlockIdx, i.TargetIdx) 129 | var numItems = len(t.Actions) 130 | // Make list of actions 131 | items := make([]list.Item, numItems) 132 | for j := 0; j < numItems; j++ { 133 | items[j] = &ActionItem{m.r, i.CellIdx, i.BlockIdx, i.TargetIdx, j} 134 | } 135 | return m.Right.SetItems(items) 136 | } 137 | 138 | func (m *Tui) SetTitle() { 139 | 140 | if m.Right.SelectedItem() != nil { 141 | m.Title = cmdTemplate( 142 | m.Left.SelectedItem().(*TargetItem).Title(), 143 | m.Right.SelectedItem().(*ActionItem).Title(), 144 | ) 145 | } else { 146 | m.Title = lipgloss.NewStyle().Faint(true).Render(cmdTemplate(m.Left.SelectedItem().(*TargetItem).Title(), "n/a")) 147 | } 148 | } 149 | 150 | func (m *Tui) SetInspect() (tea.Model, tea.Cmd) { 151 | if i, ok := m.Right.SelectedItem().(*ActionItem); ok { 152 | cmd := flake.RunActionCmd{ 153 | ShowCmdStr: false, 154 | CmdStr: "", 155 | System: "", // unlike in the CLI, we don't implement to specifiy system in the TUI 156 | Cell: i.r.CellName(i.CellIdx, i.BlockIdx, i.TargetIdx), 157 | Block: i.r.BlockName(i.CellIdx, i.BlockIdx, i.TargetIdx), 158 | Target: i.r.TargetName(i.CellIdx, i.BlockIdx, i.TargetIdx), 159 | Action: i.r.ActionTitle(i.CellIdx, i.BlockIdx, i.TargetIdx, i.ActionIdx), 160 | RequiresArgs: i.r.ActionRequiresArgs(i.CellIdx, i.BlockIdx, i.TargetIdx, i.ActionIdx), 161 | } 162 | _, args, _ := cmd.Assemble(nil) 163 | m.InspectAction = "nix build " + strings.Join(args, " \\\n") 164 | return m, nil 165 | } else { 166 | m.InspectAction = "" 167 | return m, nil 168 | } 169 | } 170 | 171 | type cellLoadedFromCacheMsg struct{ root *data.Root } 172 | type cellLoadedMsg struct{ root *data.Root } 173 | type cellLoadingFatalErrMsg struct{ err error } 174 | 175 | func (m *Tui) Init() tea.Cmd { 176 | var cmds []tea.Cmd 177 | c, key, cmd, buf, err := flake.LoadFlakeCmd() 178 | if err != nil { 179 | return func() tea.Msg { return cellLoadingFatalErrMsg{err} } 180 | } 181 | cached, _, err := c.GetBytes(*key) 182 | if err == nil { 183 | // a cache hit ... 184 | // ... load the cache 185 | cmds = append(cmds, func() tea.Msg { 186 | root, err := LoadJson(bytes.NewReader(cached)) 187 | if err != nil { 188 | return cellLoadingFatalErrMsg{err} 189 | } 190 | return cellLoadedFromCacheMsg{root} 191 | }) 192 | // ... re-load the flake with non-blocking i/o 193 | cmds = append(cmds, func() tea.Msg { 194 | stderr := new(bytes.Buffer) 195 | cmd.Stderr = stderr 196 | err = cmd.Run() 197 | if err != nil { 198 | return cellLoadingFatalErrMsg{fmt.Errorf(stderr.String())} 199 | } 200 | bufA := &bytes.Buffer{} 201 | r := io.TeeReader(buf, bufA) 202 | root, err := LoadJson(r) 203 | // renew cache under all circumstances (might have updated) 204 | c.PutBytes(*key, bufA.Bytes()) 205 | if err != nil { 206 | return cellLoadingFatalErrMsg{err} 207 | } 208 | return cellLoadedMsg{root} 209 | }) 210 | } else { 211 | // on cache miss ... 212 | // ... load the flake with blocking i/o 213 | cmds = append(cmds, tea.ExecProcess(cmd, func(err error) tea.Msg { 214 | if err != nil { 215 | return cellLoadingFatalErrMsg{err} 216 | } 217 | bufA := &bytes.Buffer{} 218 | r := io.TeeReader(buf, bufA) 219 | root, err := LoadJson(r) 220 | c.PutBytes(*key, bufA.Bytes()) 221 | if err != nil { 222 | return cellLoadingFatalErrMsg{err} 223 | } 224 | return cellLoadedMsg{root} 225 | })) 226 | } 227 | cmds = append(cmds, m.Spinner.Tick) 228 | return tea.Batch(cmds...) 229 | } 230 | 231 | func (m *Tui) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 232 | var ( 233 | cmds []tea.Cmd 234 | cmd tea.Cmd 235 | actionKeys = keys.NewActionDelegateKeyMap() 236 | ) 237 | switch msg := msg.(type) { 238 | case cellLoadedMsg: 239 | m.r = msg.root 240 | m.Loaded = FromFlake 241 | return m, tea.Batch( 242 | m.LoadTargets(), 243 | m.Left.StartSpinner(), 244 | ) 245 | 246 | case cellLoadedFromCacheMsg: 247 | m.r = msg.root 248 | m.Loaded = FromCache 249 | return m, tea.Batch( 250 | m.LoadTargets(), 251 | m.Left.StartSpinner(), 252 | ) 253 | 254 | case cellLoadingFatalErrMsg: 255 | m.FatalError = msg.err 256 | return m, tea.Quit 257 | 258 | case spinner.TickMsg: 259 | if m.Loaded != Loading { 260 | m.Spinner, cmd = m.Spinner.Update(msg) 261 | return m, cmd 262 | } 263 | m.Left, cmd = m.Left.Update(msg) 264 | cmds = append(cmds, cmd) 265 | m.Right, cmd = m.Right.Update(msg) 266 | cmds = append(cmds, cmd) 267 | return m, tea.Batch(cmds...) 268 | case tea.KeyMsg: 269 | // quit even during filtering 270 | if key.Matches(msg, m.Keys.ForceQuit) { 271 | return m, tea.Quit 272 | } 273 | // Quit action inspection if enabled. 274 | if m.Focus == Inspect && key.Matches(msg, actionKeys.QuitInspect) { 275 | m.Focus = Right 276 | return m, nil 277 | } 278 | // Don't match any of the keys below if we're actively filtering. 279 | if m.Left.FilterState() == list.Filtering { 280 | break 281 | } 282 | if key.Matches(msg, m.Keys.Quit) { 283 | return m, tea.Quit 284 | } 285 | // Don't match any of the keys below if no target is selected. 286 | if m.Left.SelectedItem() == nil { 287 | return m, nil 288 | } 289 | switch { 290 | case m.Focus == Right && key.Matches(msg, actionKeys.Exec): 291 | if i, ok := m.Right.SelectedItem().(*ActionItem); ok { 292 | m.ExecveCommand = &flake.RunActionCmd{ 293 | ShowCmdStr: true, 294 | CmdStr: cmdTemplate( 295 | m.Left.SelectedItem().(*TargetItem).Title(), 296 | i.Title(), 297 | ), 298 | System: "", // unlike in the CLI, we don't implement to specify system in the TUI 299 | Cell: i.r.CellName(i.CellIdx, i.BlockIdx, i.TargetIdx), 300 | Block: i.r.BlockName(i.CellIdx, i.BlockIdx, i.TargetIdx), 301 | Target: i.r.TargetName(i.CellIdx, i.BlockIdx, i.TargetIdx), 302 | Action: i.r.ActionTitle(i.CellIdx, i.BlockIdx, i.TargetIdx, i.ActionIdx), 303 | RequiresArgs: i.r.ActionRequiresArgs(i.CellIdx, i.BlockIdx, i.TargetIdx, i.ActionIdx), 304 | } 305 | return m, tea.Quit 306 | } 307 | case m.Focus == Right && key.Matches(msg, actionKeys.Copy): 308 | if _, ok := m.Right.SelectedItem().(*ActionItem); ok { 309 | osc52.Copy(cmdTemplate( 310 | m.Left.SelectedItem().(*TargetItem).Title(), 311 | m.Right.SelectedItem().(*ActionItem).Title(), 312 | )) 313 | return m, nil 314 | } 315 | case m.Focus == Inspect && key.Matches(msg, actionKeys.Copy): 316 | osc52.Copy(m.InspectAction) 317 | return m, nil 318 | // toggle the help 319 | case key.Matches(msg, m.Keys.ShowReadme): 320 | if m.Focus == Left { 321 | m.Focus = Readme 322 | var t = m.Left.SelectedItem().(*TargetItem) 323 | cmd = m.Readme.RenderMarkdown(m.r, t.CellIdx, t.BlockIdx, t.TargetIdx) 324 | return m, cmd 325 | } 326 | if m.Focus == Right { 327 | m.Focus = Inspect 328 | return m.SetInspect() 329 | } 330 | fallthrough 331 | case key.Matches(msg, m.Readme.KeyMap.CloseReadme): 332 | if m.Focus == Readme { 333 | m.Focus = Left 334 | m.Readme.CellHelp.SetIsActive(false) 335 | m.Readme.BlockHelp.SetIsActive(false) 336 | m.Readme.TargetHelp.SetIsActive(false) 337 | return m, nil 338 | } 339 | 340 | // toggle the focus 341 | case key.Matches(msg, m.Keys.ToggleFocus, m.Keys.FocusLeft, m.Keys.FocusRight): 342 | // Don't toggle the focus if we're showing the help. 343 | if m.Focus == Readme || m.Focus == Inspect { 344 | break 345 | } 346 | if m.Focus == Left { 347 | if key.Matches(msg, m.Keys.FocusLeft) { 348 | return m, nil 349 | } 350 | m.Focus = Right 351 | m.SetTitle() 352 | } else { 353 | if key.Matches(msg, m.Keys.FocusRight) { 354 | return m, nil 355 | } 356 | m.Focus = Left 357 | m.Title = "" 358 | } 359 | cmd = m.Left.ToggleSpinner() 360 | cmds = append(cmds, cmd) 361 | cmd = m.Right.ToggleSpinner() 362 | cmds = append(cmds, cmd) 363 | return m, tea.Batch(cmds...) 364 | } 365 | case tea.WindowSizeMsg: 366 | m.Width = msg.Width 367 | m.Height = msg.Height 368 | // size Target 369 | m.Left.SetHeight(msg.Height - 10) 370 | m.Left.SetWidth(msg.Width*2/3 - 10) 371 | // size Action 372 | m.Right.SetHeight(msg.Height - 10) 373 | m.Right.SetWidth(msg.Width*1/3 - 10) 374 | // size Readme 375 | m.Readme.Height = msg.Height - 10 376 | m.Readme.Width = msg.Width - 10 377 | m.Readme, _ = m.Readme.Update(msg) 378 | return m, nil 379 | } 380 | // route all other messages according to state 381 | if m.Focus == Readme { 382 | m.Readme, cmd = m.Readme.Update(msg) 383 | cmds = append(cmds, cmd) 384 | } else if m.Focus == Left { 385 | m.Left, cmd = m.Left.Update(msg) 386 | cmds = append(cmds, cmd) 387 | if m.Left.SelectedItem() != nil { 388 | var target = m.Left.SelectedItem().(*TargetItem) 389 | cmds = append(cmds, m.LoadActions(target)) 390 | } else { 391 | cmds = append(cmds, m.Right.SetItems([]list.Item{})) 392 | } 393 | } else { 394 | m.Right, cmd = m.Right.Update(msg) 395 | m.SetTitle() 396 | cmds = append(cmds, cmd) 397 | _, cmd = m.SetInspect() 398 | cmds = append(cmds, cmd) 399 | } 400 | return m, tea.Batch(cmds...) 401 | } 402 | 403 | func (m *Tui) View() string { 404 | var title string 405 | var cacheWarning string 406 | if m.Loaded == Loading { 407 | title = styles.TitleStyle.Render("Loading " + m.Spinner.View()) 408 | } else if m.Loaded == FromCache { 409 | title = styles.TitleStyle.Render(m.Title) 410 | cacheWarning = styles.CacheWarning.Render("Using cache, refreshing: " + m.Spinner.View()) 411 | } else if m.Loaded == FromFlake { 412 | title = styles.TitleStyle.Render(m.Title) 413 | } 414 | 415 | placementClosure := func(s string) string { 416 | return lipgloss.Place( 417 | m.Width, 418 | m.Height, 419 | lipgloss.Center, 420 | lipgloss.Center, 421 | styles.AppStyle.MaxWidth(m.Width).MaxHeight(m.Height).Render(s), 422 | ) 423 | } 424 | 425 | if m.Loaded == Loading { 426 | return placementClosure(title) 427 | } 428 | if m.Focus == Readme { 429 | return placementClosure( 430 | lipgloss.JoinVertical( 431 | lipgloss.Center, 432 | title, 433 | styles.ReadmeStyle.Render(m.Readme.View()), 434 | styles.LegendStyle.Render(m.Legend.View(m)), 435 | ), 436 | ) 437 | } 438 | 439 | if m.Focus == Inspect { 440 | return placementClosure( 441 | lipgloss.JoinVertical( 442 | lipgloss.Center, 443 | title, 444 | lipgloss.JoinHorizontal( 445 | lipgloss.Left, 446 | styles.ActionInspectionStyle.Width(m.Left.Width()).Height(m.Left.Height()).Render(m.InspectAction), 447 | styles.ActionStyle.Render(m.Right.View()), 448 | ), 449 | lipgloss.JoinHorizontal( 450 | lipgloss.Center, 451 | styles.LegendStyle.Render(m.Legend.View(m)), 452 | cacheWarning, 453 | ), 454 | ), 455 | ) 456 | } 457 | 458 | return placementClosure( 459 | lipgloss.JoinVertical( 460 | lipgloss.Center, 461 | title, 462 | lipgloss.JoinHorizontal( 463 | lipgloss.Left, 464 | styles.TargetStyle.Width(m.Left.Width()).Height(m.Left.Height()).Render(m.Left.View()), 465 | styles.ActionStyle.Width(m.Right.Width()).Height(m.Right.Height()).Render(m.Right.View()), 466 | ), 467 | lipgloss.JoinHorizontal( 468 | lipgloss.Center, 469 | styles.LegendStyle.Render(m.Legend.View(m)), 470 | cacheWarning, 471 | ), 472 | ), 473 | ) 474 | } 475 | 476 | func (m *Tui) ShortHelp() []key.Binding { 477 | if m.Focus == Readme { 478 | return append(m.Readme.ShortHelp(), []key.Binding{ 479 | m.Keys.Quit, 480 | }...) 481 | } 482 | if m.Focus == Left { 483 | // switch off the list's help 484 | m.Left.KeyMap.ShowFullHelp.SetEnabled(false) 485 | m.Left.KeyMap.CloseFullHelp.SetEnabled(false) 486 | if m.Left.FilterState() == list.Filtering { 487 | return m.Left.ShortHelp() 488 | } else { 489 | return append(m.Left.ShortHelp(), []key.Binding{ 490 | m.Keys.ToggleFocus, 491 | m.Keys.ShowReadme, 492 | m.Keys.Quit, 493 | }...) 494 | } 495 | } else { 496 | // switch off the list's help 497 | m.Right.KeyMap.ShowFullHelp.SetEnabled(false) 498 | m.Right.KeyMap.CloseFullHelp.SetEnabled(false) 499 | return append(m.Right.ShortHelp(), []key.Binding{ 500 | m.Keys.ToggleFocus, 501 | m.Keys.ShowReadme, 502 | m.Keys.Quit, 503 | }...) 504 | } 505 | } 506 | 507 | func (m *Tui) FullHelp() [][]key.Binding { 508 | kb := [][]key.Binding{{}} 509 | return kb 510 | } 511 | 512 | func InitialPage() *Tui { 513 | 514 | spin := spinner.New() 515 | spin.Spinner = spinner.Points 516 | 517 | return &Tui{ 518 | Left: InitialTargets(), 519 | Right: NewActions(), 520 | Keys: keys.NewAppKeyMap(), 521 | Focus: Left, 522 | Readme: models.NewReadme(), 523 | Legend: help.New(), 524 | Loaded: Loading, 525 | Spinner: spin, 526 | } 527 | } 528 | 529 | func InitialTargets() Targets { 530 | 531 | targetList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) 532 | targetList.Title = "Target" 533 | targetList.KeyMap = keys.DefaultListKeyMap() 534 | targetList.SetFilteringEnabled(true) 535 | targetList.StartSpinner() 536 | targetList.DisableQuitKeybindings() 537 | targetList.SetShowHelp(false) 538 | 539 | return targetList 540 | } 541 | 542 | func NewActions() Actions { 543 | 544 | newActionDelegate := func(keys *keys.ActionDelegateKeyMap) list.DefaultDelegate { 545 | d := list.NewDefaultDelegate() 546 | 547 | d.UpdateFunc = func(msg tea.Msg, m *list.Model) tea.Cmd { return nil } 548 | 549 | help := []key.Binding{keys.Exec, keys.Copy} 550 | d.ShortHelpFunc = func() []key.Binding { return help } 551 | d.FullHelpFunc = func() [][]key.Binding { return [][]key.Binding{} } 552 | 553 | return d 554 | } 555 | 556 | actionDelegateKeys := keys.NewActionDelegateKeyMap() 557 | delegate := newActionDelegate(actionDelegateKeys) 558 | actionList := list.New([]list.Item{}, delegate, 0, 0) 559 | actionList.Title = "Actions" 560 | actionList.KeyMap = keys.DefaultListKeyMap() 561 | actionList.SetShowPagination(false) 562 | actionList.SetShowHelp(false) 563 | actionList.SetShowStatusBar(false) 564 | actionList.SetFilteringEnabled(false) 565 | actionList.DisableQuitKeybindings() 566 | 567 | return actionList 568 | } 569 | --------------------------------------------------------------------------------