├── .github └── workflows │ ├── go.yml │ ├── nix.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── cmd └── jjui │ └── main.go ├── default-config.toml ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── internal ├── config │ ├── config.go │ ├── config_test.go │ └── keys.go ├── jj │ ├── bookmark_parser.go │ ├── bookmark_parser_test.go │ ├── commands.go │ └── commit.go ├── screen │ ├── ansi_parser.go │ ├── cell_buffer.go │ └── segment.go └── ui │ ├── bookmarks │ ├── bookmarks.go │ └── bookmarks_test.go │ ├── common │ ├── focusable.go │ ├── msgs.go │ ├── sizable.go │ └── styles.go │ ├── confirmation │ └── confirmation.go │ ├── context │ ├── context.go │ └── main_context.go │ ├── custom_commands │ ├── command_manager.go │ ├── custom_command.go │ └── custom_commands.go │ ├── diff │ └── diff.go │ ├── git │ ├── git.go │ └── git_test.go │ ├── graph │ ├── default_row_decorator.go │ ├── log_parser.go │ ├── renderer.go │ ├── row.go │ ├── streaming_log_parser.go │ └── streaming_log_parser_test.go │ ├── helppage │ └── help.go │ ├── operations │ ├── abandon │ │ ├── abandon.go │ │ └── abandon_test.go │ ├── bookmark │ │ ├── set_bookmark.go │ │ └── set_bookmark_test.go │ ├── default_operation.go │ ├── details │ │ ├── details.go │ │ ├── details_test.go │ │ └── show_details_operation.go │ ├── evolog │ │ └── evolog_operation.go │ ├── operation.go │ ├── rebase │ │ └── rebase_operation.go │ └── squash │ │ └── squash_operation.go │ ├── oplog │ ├── operation_log.go │ ├── oplog_parser.go │ ├── renderer.go │ └── row.go │ ├── preview │ └── preview.go │ ├── revisions │ ├── revisions.go │ └── revisions_test.go │ ├── revset │ ├── revset.go │ └── revset_test.go │ ├── status │ └── status.go │ ├── ui.go │ └── undo │ ├── undo.go │ └── undo_test.go ├── nix ├── default.nix └── vendor-hash └── test ├── log_builder.go ├── log_parser_test.go ├── operation_host.go ├── shell.go ├── test_context.go └── testdata ├── commit-id.log ├── conflicted-change-id.log ├── no-commit-id.log ├── output.log ├── short-id.log └── single-line-with-description.log /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: '1.24' 20 | 21 | - name: Test 22 | run: go test -v ./... 23 | -------------------------------------------------------------------------------- /.github/workflows/nix.yml: -------------------------------------------------------------------------------- 1 | name: nix flake check 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | flake-check: 10 | name: nix flake check 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: cachix/install-nix-action@v30 15 | - run: nix flake check 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release Go Binaries 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | name: Build and Publish 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | os: [darwin, windows, linux] 14 | arch: [arm64, amd64] 15 | include: 16 | - os: windows 17 | extension: .exe 18 | permissions: 19 | contents: write 20 | actions: write 21 | id-token: write 22 | runs-on: ubuntu-22.04 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | 27 | - name: Set up Go 28 | uses: actions/setup-go@v5 29 | with: 30 | go-version: "1.24" 31 | 32 | - name: Get release version 33 | id: get_version 34 | run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT 35 | 36 | - name: Build binaries 37 | env: 38 | VERSION: ${{ steps.get_version.outputs.VERSION }} 39 | GOOS: ${{ matrix.os }} 40 | GOARCH: ${{ matrix.arch }} 41 | run: | 42 | go build -ldflags="-X 'main.Version=$VERSION'" -o release/jjui-$VERSION-${{ matrix.os }}-${{ matrix.arch }}${{matrix.extension}} cmd/jjui/main.go 43 | zip -j release/jjui-$VERSION-${{ matrix.os }}-${{ matrix.arch }}.zip release/jjui-$VERSION-${{ matrix.os }}-${{ matrix.arch }}${{matrix.extension}} 44 | 45 | - name: Upload release binaries 46 | uses: softprops/action-gh-release@v2 47 | with: 48 | files: 49 | release/*.zip 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 ibrahim dursun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build & Test](https://github.com/idursun/jjui/actions/workflows/go.yml/badge.svg)](https://github.com/idursun/jjui/actions/workflows/go.yml) 2 | 3 | # Jujutsu UI 4 | 5 | `jjui` is a terminal user interface for working with [Jujutsu version control system](https://github.com/jj-vcs/jj). I have built it according to my own needs and will keep adding new features as I need them. I am open to feature requests and contributions. 6 | 7 | ## Features 8 | 9 | Currently, you can: 10 | 11 | ### Change revset with auto-complete 12 | You can change revset while enjoying auto-complete and signature help while typing. 13 | 14 | ![GIF](https://github.com/idursun/jjui/wiki/gifs/jjui_revset.gif) 15 | 16 | ### Rebase 17 | You can rebase a revision or a branch onto another revision in the revision tree. 18 | 19 | ![GIF](https://github.com/idursun/jjui/wiki/gifs/jjui_rebase.gif) 20 | 21 | See [Rebase](https://github.com/idursun/jjui/wiki/Rebase) for detailed information. 22 | 23 | ### Squash 24 | You can squash revisions into one revision, by pressing `S`. The following revision will be automatically selected. However, you can change the selection using `j` and `k`. 25 | 26 | ![GIF](https://github.com/idursun/jjui/wiki/gifs/jjui_squash.gif) 27 | 28 | ### Show revision details 29 | 30 | Pressing `l` (as in going right into the details of a revision) will open the details view of the revision you selected. 31 | 32 | In this mode, you can: 33 | - Split selected files using `s` 34 | - Restore selected files using `r` 35 | - View diffs of the highlighted by pressing `d` 36 | 37 | ![GIF](https://github.com/idursun/jjui/wiki/gifs/jjui_details.gif) 38 | 39 | For detailed information, see [Details](https://github.com/idursun/jjui/wiki/Details) wiki page. 40 | 41 | ### Bookmarks 42 | You can move bookmarks to the revision you selected. 43 | 44 | ![GIF](https://github.com/idursun/jjui/wiki/gifs/jjui_bookmarks.gif) 45 | 46 | 47 | ### Op Log 48 | You can switch to op log view by pressing `o`. Pressing `r` restores the selected operation. For more information, see [Op log](https://github.com/idursun/jjui/wiki/Oplog) wiki page. 49 | 50 | ![GIF](https://github.com/idursun/jjui/wiki/gifs/jjui_oplog.gif) 51 | 52 | ### Preview 53 | You can open the preview window by pressing `p`. If the selected item is a revision, then the output of `jj show` command is displayed. Similarly, `jj diff` output is displayed for selected files, and `jj op show` output is displayed for selected operations. 54 | 55 | While the preview window is showing, you can press; `ctrl+n` to scroll one line down, `ctrl+p` to scroll one line up, `ctrl+d` to scroll half a page down, `ctrl+u` to scroll half a page up. 56 | 57 | Additionally, you can press `d` to show the contents of preview in diff view. 58 | 59 | For detailed information, see [Preview](https://github.com/idursun/jjui/wiki/Preview) wiki page. 60 | 61 | ![GIF](https://github.com/idursun/jjui/wiki/gifs/jjui_preview.gif) 62 | 63 | Additionally, 64 | * View the diff of a revision by pressing `d`. 65 | * Edit the description of a revision by pressing `D` 66 | * Create a _new_ revision by pressing `n` 67 | * Split a revision by pressing `s`. 68 | * Abandon a revision by pressing `a`. 69 | * Absorb a revision by pressing `A`. 70 | * _Edit_ a revision by pressing `e` 71 | * Git _push_/_fetch_ by pressing `g` 72 | * Undo the last change by pressing `u` 73 | * Show evolog of a revision by pressing `v` 74 | 75 | ## Configuration 76 | 77 | See [configuration](https://github.com/idursun/jjui/wiki/Configuration) section in the wiki. 78 | 79 | ## Installation 80 | 81 | ### Homebrew 82 | 83 | The latest release of `jjui` is available on Homebrew core: 84 | 85 | ```shell 86 | brew install jjui 87 | ``` 88 | 89 | ### Archlinux 90 | 91 | The built `jjui` binary from latest release is available on the AUR: 92 | 93 | ```shell 94 | paru -S jjui-bin 95 | # OR 96 | yay -S jjui-bin 97 | ``` 98 | 99 | ### Nix 100 | 101 | You can install `jjui` using nix from the unstable channel. 102 | 103 | ```shell 104 | nix-env -iA nixpkgs.jjui 105 | ``` 106 | 107 | If you need to use a particular branch/revision that 108 | has not yet landed into nixpkgs. You can install it via 109 | our provided flake. 110 | 111 | ```shell 112 | nix profile install github:idursun/jjui/main 113 | ``` 114 | 115 | ### From go install 116 | 117 | To install the latest released (or pre-released) version: 118 | 119 | ```shell 120 | go install github.com/idursun/jjui/cmd/jjui@latest 121 | ``` 122 | 123 | To install the latest commit from `main`: 124 | 125 | ```shell 126 | go install github.com/idursun/jjui/cmd/jjui@HEAD 127 | ``` 128 | To install the latest commit from `main` bypassing the local cache: 129 | 130 | ```shell 131 | GOPROXY=direct go install github.com/idursun/jjui/cmd/jjui@HEAD 132 | ``` 133 | 134 | ### From source 135 | 136 | You can build `jjui` from source. 137 | 138 | ```shell 139 | git clone https://github.com/idursun/jjui.git 140 | cd jjui 141 | go install ./... 142 | ``` 143 | 144 | 145 | ### From pre-built binaries 146 | You can download pre-built binaries from the [releases](https://github.com/idursun/jjui/releases) page. 147 | 148 | ## Compatibility 149 | 150 | Minimum supported `jj` version is **v0.21**+. 151 | 152 | ## Contributing 153 | 154 | Feel free to submit a pull request. 155 | -------------------------------------------------------------------------------- /cmd/jjui/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "runtime/debug" 9 | "strings" 10 | 11 | "github.com/idursun/jjui/internal/config" 12 | "github.com/idursun/jjui/internal/ui/context" 13 | 14 | tea "github.com/charmbracelet/bubbletea" 15 | "github.com/idursun/jjui/internal/ui" 16 | ) 17 | 18 | var Version string 19 | 20 | func getVersion() string { 21 | if Version != "" { 22 | // set explicitly from build flags 23 | return Version 24 | } 25 | if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" { 26 | // obtained by go build, usually from VCS 27 | return info.Main.Version 28 | } 29 | return "unknown" 30 | } 31 | 32 | var ( 33 | revset string 34 | version bool 35 | editConfig bool 36 | help bool 37 | ) 38 | 39 | func init() { 40 | flag.StringVar(&revset, "revset", "", "Set default revset") 41 | flag.StringVar(&revset, "r", "", "Set default revset (same as --revset)") 42 | flag.BoolVar(&version, "version", false, "Show version information") 43 | flag.BoolVar(&editConfig, "config", false, "Open configuration file in $EDITOR") 44 | flag.BoolVar(&help, "help", false, "Show help information") 45 | 46 | flag.Usage = func() { 47 | fmt.Printf("Usage: jjui [flags] [location]\n") 48 | fmt.Println("Flags:") 49 | flag.PrintDefaults() 50 | } 51 | } 52 | 53 | func getJJRootDir(location string) (string, error) { 54 | cmd := exec.Command("jj", "root") 55 | cmd.Dir = location 56 | output, err := cmd.Output() 57 | if err != nil { 58 | return "", err 59 | } 60 | return strings.TrimSpace(string(output)), nil 61 | } 62 | 63 | func main() { 64 | flag.Parse() 65 | switch { 66 | case help: 67 | flag.Usage() 68 | os.Exit(0) 69 | case version: 70 | fmt.Println(getVersion()) 71 | os.Exit(0) 72 | case editConfig: 73 | exitCode := config.Edit() 74 | os.Exit(exitCode) 75 | } 76 | 77 | var location string 78 | if args := flag.Args(); len(args) > 0 { 79 | location = args[0] 80 | } 81 | 82 | if location == "" { 83 | location = os.Getenv("PWD") 84 | } 85 | 86 | rootLocation, err := getJJRootDir(location) 87 | if err != nil { 88 | fmt.Fprintf(os.Stderr, "Error: There is no jj repo in \"%s\".\n", location) 89 | os.Exit(1) 90 | } 91 | 92 | appContext := context.NewAppContext(rootLocation) 93 | 94 | p := tea.NewProgram(ui.New(appContext, revset), tea.WithAltScreen()) 95 | if _, err := p.Run(); err != nil { 96 | fmt.Printf("Error running program: %v\n", err) 97 | os.Exit(1) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /default-config.toml: -------------------------------------------------------------------------------- 1 | [keys] 2 | up = ["up", "k"] 3 | down = ["down", "j"] 4 | apply = ["enter"] 5 | cancel = ["esc"] 6 | toggle_select = [" "] 7 | new = ["n"] 8 | commit = ["c"] 9 | refresh = ["ctrl+r"] 10 | abandon = ["a"] 11 | diff = ["d"] 12 | quit = ["q"] 13 | help = ["?"] 14 | describe = ["D"] 15 | edit = ["e"] 16 | diffedit = ["E"] 17 | absorb = ["A"] 18 | split = ["s"] 19 | squash = ["S"] 20 | undo = ["u"] 21 | evolog = ["v"] 22 | revset = ["L"] 23 | quick_search = ["/"] 24 | quick_search_cycle = ["'"] 25 | custom_commands = ["x"] 26 | [keys.rebase] 27 | mode = ["r"] 28 | revision = ["r"] 29 | source = ["s"] 30 | branch = ["B"] 31 | after = ["a"] 32 | before = ["b"] 33 | onto = ["d"] 34 | insert = ["i"] 35 | [keys.details] 36 | mode = ["l"] 37 | close = ["h"] 38 | split = ["s"] 39 | restore = ["r"] 40 | diff = ["d"] 41 | select = ["m", " "] 42 | revisions_changing_file = ["*"] 43 | [keys.preview] 44 | mode = ["p"] 45 | scroll_up = ["ctrl+p"] 46 | scroll_down = ["ctrl+n"] 47 | half_page_down = ["ctrl+d"] 48 | half_page_up = ["ctrl+u"] 49 | expand = ["ctrl+h"] 50 | shrink = ["ctrl+l"] 51 | [keys.bookmark] 52 | mode = ["b"] 53 | set = ["B"] 54 | delete = ["d"] 55 | move = ["m"] 56 | forget = ["f"] 57 | track = ["t"] 58 | untrack = ["u"] 59 | [keys.git] 60 | mode = ["g"] 61 | push = ["p"] 62 | fetch = ["f"] 63 | [keys.oplog] 64 | mode = ["o"] 65 | restore = ["r"] 66 | 67 | [ui] 68 | highlight_light = "#a0a0a0" 69 | highlight_dark = "#282a36" 70 | auto_refresh_interval = 0 71 | 72 | [preview] 73 | extra_args = [] 74 | show_at_start = false 75 | width_percentage = 50.0 76 | width_increment_percentage = 5.0 77 | 78 | [oplog] 79 | limit = 200 80 | 81 | [custom_commands] 82 | 83 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-parts": { 4 | "inputs": { 5 | "nixpkgs-lib": "nixpkgs-lib" 6 | }, 7 | "locked": { 8 | "lastModified": 1743550720, 9 | "narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=", 10 | "owner": "hercules-ci", 11 | "repo": "flake-parts", 12 | "rev": "c621e8422220273271f52058f618c94e405bb0f5", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "hercules-ci", 17 | "repo": "flake-parts", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1743938762, 24 | "narHash": "sha256-UgFYn8sGv9B8PoFpUfCa43CjMZBl1x/ShQhRDHBFQdI=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "74a40410369a1c35ee09b8a1abee6f4acbedc059", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixpkgs-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "nixpkgs-lib": { 38 | "locked": { 39 | "lastModified": 1743296961, 40 | "narHash": "sha256-b1EdN3cULCqtorQ4QeWgLMrd5ZGOjLSLemfa00heasc=", 41 | "owner": "nix-community", 42 | "repo": "nixpkgs.lib", 43 | "rev": "e4822aea2a6d1cdd36653c134cacfd64c97ff4fa", 44 | "type": "github" 45 | }, 46 | "original": { 47 | "owner": "nix-community", 48 | "repo": "nixpkgs.lib", 49 | "type": "github" 50 | } 51 | }, 52 | "root": { 53 | "inputs": { 54 | "flake-parts": "flake-parts", 55 | "nixpkgs": "nixpkgs", 56 | "systems": "systems" 57 | } 58 | }, 59 | "systems": { 60 | "locked": { 61 | "lastModified": 1681028828, 62 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 63 | "owner": "nix-systems", 64 | "repo": "default", 65 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 66 | "type": "github" 67 | }, 68 | "original": { 69 | "owner": "nix-systems", 70 | "repo": "default", 71 | "type": "github" 72 | } 73 | } 74 | }, 75 | "root": "root", 76 | "version": 7 77 | } 78 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | systems.url = "github:nix-systems/default"; 4 | nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 5 | flake-parts.url = "github:hercules-ci/flake-parts"; 6 | }; 7 | 8 | outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } ./nix; 9 | } 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/idursun/jjui 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/BurntSushi/toml v1.5.0 9 | github.com/charmbracelet/bubbletea v1.3.4 10 | github.com/charmbracelet/lipgloss v1.1.0 11 | github.com/charmbracelet/x/exp/teatest v0.0.0-20250131172436-6251e772efa1 12 | github.com/stretchr/testify v1.10.0 13 | ) 14 | 15 | require ( 16 | github.com/atotto/clipboard v0.1.4 // indirect 17 | github.com/aymanbagabas/go-udiff v0.2.0 // indirect 18 | github.com/charmbracelet/colorprofile v0.3.0 // indirect 19 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 20 | github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b // indirect 21 | github.com/sahilm/fuzzy v0.1.1 // indirect 22 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 23 | ) 24 | 25 | require ( 26 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 27 | github.com/charmbracelet/bubbles v0.20.0 28 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 29 | github.com/charmbracelet/x/term v0.2.1 // indirect 30 | github.com/davecgh/go-spew v1.1.1 // indirect 31 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 32 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 33 | github.com/mattn/go-isatty v0.0.20 // indirect 34 | github.com/mattn/go-localereader v0.0.1 // indirect 35 | github.com/mattn/go-runewidth v0.0.16 // indirect 36 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 37 | github.com/muesli/cancelreader v0.2.2 // indirect 38 | github.com/muesli/termenv v0.16.0 39 | github.com/pmezard/go-difflib v1.0.0 // indirect 40 | github.com/rivo/uniseg v0.4.7 41 | golang.org/x/sync v0.12.0 // indirect 42 | golang.org/x/sys v0.31.0 // indirect 43 | golang.org/x/text v0.23.0 // indirect 44 | gopkg.in/yaml.v3 v3.0.1 // indirect 45 | ) 46 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 2 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 4 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 5 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 6 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 7 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 9 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 10 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 11 | github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= 12 | github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= 13 | github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= 14 | github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= 15 | github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ= 16 | github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0= 17 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 18 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 19 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 20 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 21 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 22 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 23 | github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= 24 | github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 25 | github.com/charmbracelet/x/exp/teatest v0.0.0-20250131172436-6251e772efa1 h1:Hbbic6DWWP/6Xmvmhpqar+nCxtAzfGaLfMMFdukohXw= 26 | github.com/charmbracelet/x/exp/teatest v0.0.0-20250131172436-6251e772efa1/go.mod h1:ag+SpTUkiN/UuUGYPX3Ci4fR1oF3XX97PpGhiXK7i6U= 27 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 28 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 29 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 30 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 31 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 32 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 33 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 34 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 35 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 36 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 37 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 38 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 39 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 40 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 41 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 42 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 43 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 44 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 45 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 46 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 47 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 48 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 49 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 50 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 51 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 52 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 53 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 54 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 55 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 56 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 57 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 58 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 59 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 60 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 61 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 62 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 63 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 64 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 65 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 66 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 67 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 68 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 69 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 70 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 71 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 72 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 73 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 74 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "path" 9 | "path/filepath" 10 | "runtime" 11 | 12 | "github.com/BurntSushi/toml" 13 | ) 14 | 15 | var Current = &Config{ 16 | Keys: DefaultKeyMappings, 17 | UI: UIConfig{ 18 | HighlightLight: "#a0a0a0", 19 | HighlightDark: "#282a36", 20 | }, 21 | Preview: PreviewConfig{ 22 | ExtraArgs: []string{}, 23 | ShowAtStart: false, 24 | WidthPercentage: 50, 25 | WidthIncrementPercentage: 5, 26 | }, 27 | OpLog: OpLogConfig{ 28 | Limit: 200, 29 | }, 30 | CustomCommands: map[string]CustomCommandDefinition{}, 31 | ExperimentalLogBatchingEnabled: false, 32 | } 33 | 34 | type Config struct { 35 | Keys KeyMappings[keys] `toml:"keys"` 36 | UI UIConfig `toml:"ui"` 37 | Preview PreviewConfig `toml:"preview"` 38 | OpLog OpLogConfig `toml:"oplog"` 39 | CustomCommands map[string]CustomCommandDefinition `toml:"custom_commands"` 40 | ExperimentalLogBatchingEnabled bool `toml:"experimental_log_batching_enabled"` 41 | } 42 | 43 | type UIConfig struct { 44 | HighlightLight string `toml:"highlight_light"` 45 | HighlightDark string `toml:"highlight_dark"` 46 | AutoRefreshInterval int `toml:"auto_refresh_interval"` 47 | } 48 | 49 | type PreviewConfig struct { 50 | ExtraArgs []string `toml:"extra_args"` 51 | ShowAtStart bool `toml:"show_at_start"` 52 | WidthPercentage float64 `toml:"width_percentage"` 53 | WidthIncrementPercentage float64 `toml:"width_increment_percentage"` 54 | } 55 | 56 | type OpLogConfig struct { 57 | Limit int `toml:"limit"` 58 | } 59 | 60 | type ShowOption string 61 | 62 | const ( 63 | ShowOptionDiff ShowOption = "diff" 64 | ShowOptionInteractive ShowOption = "interactive" 65 | ) 66 | 67 | type CustomCommandDefinition struct { 68 | Key []string `toml:"key"` 69 | Args []string `toml:"args"` 70 | Show ShowOption `toml:"show"` 71 | } 72 | 73 | func (s *ShowOption) UnmarshalText(text []byte) error { 74 | val := string(text) 75 | switch val { 76 | case string(ShowOptionDiff), 77 | string(ShowOptionInteractive): 78 | *s = ShowOption(val) 79 | return nil 80 | default: 81 | return fmt.Errorf("invalid value for 'show': %q. Allowed: none, interactive, and diff", val) 82 | } 83 | } 84 | 85 | func getConfigFilePath() string { 86 | var configDirs []string 87 | 88 | configDir, err := os.UserConfigDir() 89 | if err != nil { 90 | return "" 91 | } 92 | configDirs = append(configDirs, configDir) 93 | 94 | // os.UserConfigDir() already does this for linux leaving darwin to handle 95 | if runtime.GOOS == "darwin" { 96 | configDirs = append(configDirs, path.Join(os.Getenv("HOME"), ".config")) 97 | xdgConfigDir := os.Getenv("XDG_CONFIG_HOME") 98 | if xdgConfigDir != "" { 99 | configDirs = append(configDirs, xdgConfigDir) 100 | } 101 | } 102 | 103 | var resolvedConfigPath string 104 | for _, dir := range configDirs { 105 | configPath := filepath.Join(dir, "jjui", "config.toml") 106 | if _, err := os.Stat(configPath); err == nil { 107 | resolvedConfigPath = configPath 108 | } 109 | } 110 | 111 | return resolvedConfigPath 112 | } 113 | 114 | func getDefaultEditor() string { 115 | editor := os.Getenv("EDITOR") 116 | if editor == "" { 117 | editor = os.Getenv("VISUAL") 118 | } 119 | 120 | // Fallback to common editors if not set 121 | if editor == "" { 122 | candidates := []string{"nano", "vim", "vi", "notepad.exe"} // Windows fallback 123 | for _, candidate := range candidates { 124 | if p, err := exec.LookPath(candidate); err == nil { 125 | editor = p 126 | break 127 | } 128 | } 129 | } 130 | 131 | return editor 132 | } 133 | 134 | func load(data string) *Config { 135 | if _, err := toml.Decode(data, &Current); err != nil { 136 | return Current 137 | } 138 | return Current 139 | } 140 | 141 | func Load() *Config { 142 | configFile := getConfigFilePath() 143 | _, err := os.Stat(configFile) 144 | if err != nil { 145 | return Current 146 | } 147 | data, _ := os.ReadFile(configFile) 148 | return load(string(data)) 149 | } 150 | 151 | func Edit() int { 152 | configFile := getConfigFilePath() 153 | _, err := os.Stat(configFile) 154 | if os.IsNotExist(err) { 155 | configPath := path.Dir(configFile) 156 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 157 | err = os.MkdirAll(configPath, 0o755) 158 | if err != nil { 159 | log.Fatal(err) 160 | return -1 161 | } 162 | } 163 | if _, err := os.Stat(configFile); os.IsNotExist(err) { 164 | _, err := os.Create(configFile) 165 | if err != nil { 166 | log.Fatal(err) 167 | return -1 168 | } 169 | } 170 | } 171 | 172 | editor := getDefaultEditor() 173 | if editor == "" { 174 | log.Fatal("No editor found. Please set $EDITOR or $VISUAL") 175 | } 176 | 177 | cmd := exec.Command(editor, configFile) 178 | cmd.Stdin = os.Stdin 179 | cmd.Stdout = os.Stdout 180 | cmd.Stderr = os.Stderr 181 | _ = cmd.Run() 182 | return cmd.ProcessState.ExitCode() 183 | } 184 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestLoad(t *testing.T) { 9 | content := ` 10 | [ui] 11 | highlight_light = "#a0a0a0" 12 | ` 13 | config := load(content) 14 | assert.Equal(t, "#a0a0a0", config.UI.HighlightLight) 15 | } 16 | 17 | func TestLoad_CustomCommands(t *testing.T) { 18 | content := ` 19 | [custom_commands] 20 | "show diff" = { key = ["ctrl+d"], args = ["diff", "-r", "$revision", "--color", "--always"], show = "diff" } 21 | "restore evolog" = { key = ["ctrl+e"], args = ["op", "restore", "-r", "$revision"] } 22 | "resolve vscode" = { key = ["ctrl+r"], args = ["resolve", "--tool", "vscode"], show = "interactive" } 23 | ` 24 | config := load(content) 25 | assert.Len(t, config.CustomCommands, 3) 26 | 27 | testCases := []struct { 28 | name string 29 | commandName string 30 | expected CustomCommandDefinition 31 | }{ 32 | { 33 | name: "diff command", 34 | commandName: "show diff", 35 | expected: CustomCommandDefinition{ 36 | Key: []string{"ctrl+d"}, 37 | Args: []string{"diff", "-r", "$revision", "--color", "--always"}, 38 | Show: ShowOptionDiff, 39 | }, 40 | }, 41 | { 42 | name: "restore command", 43 | commandName: "restore evolog", 44 | expected: CustomCommandDefinition{ 45 | Key: []string{"ctrl+e"}, 46 | Args: []string{"op", "restore", "-r", "$revision"}, 47 | Show: "", 48 | }, 49 | }, 50 | { 51 | name: "resolve command", 52 | commandName: "resolve vscode", 53 | expected: CustomCommandDefinition{ 54 | Key: []string{"ctrl+r"}, 55 | Args: []string{"resolve", "--tool", "vscode"}, 56 | Show: ShowOptionInteractive, 57 | }, 58 | }, 59 | } 60 | 61 | for _, tc := range testCases { 62 | t.Run(tc.name, func(t *testing.T) { 63 | cmd := config.CustomCommands[tc.commandName] 64 | assert.Equal(t, tc.expected.Key, cmd.Key) 65 | assert.Equal(t, tc.expected.Args, cmd.Args) 66 | assert.Equal(t, tc.expected.Show, cmd.Show) 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /internal/config/keys.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/bubbles/key" 7 | ) 8 | 9 | var DefaultKeyMappings = KeyMappings[keys]{ 10 | Up: []string{"up", "k"}, 11 | Down: []string{"down", "j"}, 12 | JumpToParent: []string{"J"}, 13 | Apply: []string{"enter"}, 14 | Cancel: []string{"esc"}, 15 | ToggleSelect: []string{" "}, 16 | New: []string{"n"}, 17 | Commit: []string{"c"}, 18 | Refresh: []string{"ctrl+r"}, 19 | Quit: []string{"q"}, 20 | Undo: []string{"u"}, 21 | Describe: []string{"D"}, 22 | Abandon: []string{"a"}, 23 | Edit: []string{"e"}, 24 | Diff: []string{"d"}, 25 | Diffedit: []string{"E"}, 26 | Absorb: []string{"A"}, 27 | Split: []string{"s"}, 28 | Squash: []string{"S"}, 29 | Evolog: []string{"v"}, 30 | Help: []string{"?"}, 31 | Revset: []string{"L"}, 32 | QuickSearch: []string{"/"}, 33 | QuickSearchCycle: []string{"'"}, 34 | CustomCommands: []string{"x"}, 35 | Rebase: rebaseModeKeys[keys]{ 36 | Mode: []string{"r"}, 37 | Revision: []string{"r"}, 38 | Source: []string{"s"}, 39 | Branch: []string{"B"}, 40 | After: []string{"a"}, 41 | Before: []string{"b"}, 42 | Onto: []string{"d"}, 43 | Insert: []string{"i"}, 44 | }, 45 | Details: detailsModeKeys[keys]{ 46 | Mode: []string{"l"}, 47 | Close: []string{"h"}, 48 | Split: []string{"s"}, 49 | Restore: []string{"r"}, 50 | Diff: []string{"d"}, 51 | ToggleSelect: []string{"m", " "}, 52 | RevisionsChangingFile: []string{"*"}, 53 | }, 54 | Preview: previewModeKeys[keys]{ 55 | Mode: []string{"p"}, 56 | ScrollUp: []string{"ctrl+p"}, 57 | ScrollDown: []string{"ctrl+n"}, 58 | HalfPageDown: []string{"ctrl+d"}, 59 | HalfPageUp: []string{"ctrl+u"}, 60 | Expand: []string{"ctrl+h"}, 61 | Shrink: []string{"ctrl+l"}, 62 | }, 63 | Bookmark: bookmarkModeKeys[keys]{ 64 | Mode: []string{"b"}, 65 | Set: []string{"B"}, 66 | Delete: []string{"d"}, 67 | Move: []string{"m"}, 68 | Forget: []string{"f"}, 69 | Track: []string{"t"}, 70 | Untrack: []string{"u"}, 71 | }, 72 | Git: gitModeKeys[keys]{ 73 | Mode: []string{"g"}, 74 | Push: []string{"p"}, 75 | Fetch: []string{"f"}, 76 | }, 77 | OpLog: opLogModeKeys[keys]{ 78 | Mode: []string{"o"}, 79 | Restore: []string{"r"}, 80 | }, 81 | } 82 | 83 | func Convert(m KeyMappings[keys]) KeyMappings[key.Binding] { 84 | return KeyMappings[key.Binding]{ 85 | Up: key.NewBinding(key.WithKeys(m.Up...), key.WithHelp(JoinKeys(m.Up), "up")), 86 | Down: key.NewBinding(key.WithKeys(m.Down...), key.WithHelp(JoinKeys(m.Down), "down")), 87 | JumpToParent: key.NewBinding(key.WithKeys(m.JumpToParent...), key.WithHelp(JoinKeys(m.JumpToParent), "jump to parent")), 88 | Apply: key.NewBinding(key.WithKeys(m.Apply...), key.WithHelp(JoinKeys(m.Apply), "apply")), 89 | Cancel: key.NewBinding(key.WithKeys(m.Cancel...), key.WithHelp(JoinKeys(m.Cancel), "cancel")), 90 | ToggleSelect: key.NewBinding(key.WithKeys(m.ToggleSelect...), key.WithHelp(JoinKeys(m.ToggleSelect), "toggle selection")), 91 | New: key.NewBinding(key.WithKeys(m.New...), key.WithHelp(JoinKeys(m.New), "new")), 92 | Commit: key.NewBinding(key.WithKeys(m.Commit...), key.WithHelp(JoinKeys(m.Commit), "commit")), 93 | Refresh: key.NewBinding(key.WithKeys(m.Refresh...), key.WithHelp(JoinKeys(m.Refresh), "refresh")), 94 | Quit: key.NewBinding(key.WithKeys(m.Quit...), key.WithHelp(JoinKeys(m.Quit), "quit")), 95 | Diff: key.NewBinding(key.WithKeys(m.Diff...), key.WithHelp(JoinKeys(m.Diff), "diff")), 96 | Describe: key.NewBinding(key.WithKeys(m.Describe...), key.WithHelp(JoinKeys(m.Describe), "describe")), 97 | Undo: key.NewBinding(key.WithKeys(m.Undo...), key.WithHelp(JoinKeys(m.Undo), "undo")), 98 | Abandon: key.NewBinding(key.WithKeys(m.Abandon...), key.WithHelp(JoinKeys(m.Abandon), "abandon")), 99 | Edit: key.NewBinding(key.WithKeys(m.Edit...), key.WithHelp(JoinKeys(m.Edit), "edit")), 100 | Diffedit: key.NewBinding(key.WithKeys(m.Diffedit...), key.WithHelp(JoinKeys(m.Diffedit), "diff edit")), 101 | Absorb: key.NewBinding(key.WithKeys(m.Absorb...), key.WithHelp(JoinKeys(m.Absorb), "absorb")), 102 | Split: key.NewBinding(key.WithKeys(m.Split...), key.WithHelp(JoinKeys(m.Split), "split")), 103 | Squash: key.NewBinding(key.WithKeys(m.Squash...), key.WithHelp(JoinKeys(m.Squash), "squash")), 104 | Help: key.NewBinding(key.WithKeys(m.Help...), key.WithHelp(JoinKeys(m.Help), "help")), 105 | Evolog: key.NewBinding(key.WithKeys(m.Evolog...), key.WithHelp(JoinKeys(m.Evolog), "evolog")), 106 | Revset: key.NewBinding(key.WithKeys(m.Revset...), key.WithHelp(JoinKeys(m.Revset), "revset")), 107 | QuickSearch: key.NewBinding(key.WithKeys(m.QuickSearch...), key.WithHelp(JoinKeys(m.QuickSearch), "quick search")), 108 | QuickSearchCycle: key.NewBinding(key.WithKeys(m.QuickSearchCycle...), key.WithHelp(JoinKeys(m.QuickSearchCycle), "locate next match")), 109 | CustomCommands: key.NewBinding(key.WithKeys(m.CustomCommands...), key.WithHelp(JoinKeys(m.CustomCommands), "custom commands menu")), 110 | Rebase: rebaseModeKeys[key.Binding]{ 111 | Mode: key.NewBinding(key.WithKeys(m.Rebase.Mode...), key.WithHelp(JoinKeys(m.Rebase.Mode), "rebase")), 112 | Revision: key.NewBinding(key.WithKeys(m.Rebase.Revision...), key.WithHelp(JoinKeys(m.Rebase.Revision), "revision")), 113 | Source: key.NewBinding(key.WithKeys(m.Rebase.Source...), key.WithHelp(JoinKeys(m.Rebase.Source), "source")), 114 | Branch: key.NewBinding(key.WithKeys(m.Rebase.Branch...), key.WithHelp(JoinKeys(m.Rebase.Branch), "branch")), 115 | After: key.NewBinding(key.WithKeys(m.Rebase.After...), key.WithHelp(JoinKeys(m.Rebase.After), "insert after")), 116 | Before: key.NewBinding(key.WithKeys(m.Rebase.Before...), key.WithHelp(JoinKeys(m.Rebase.Before), "insert before")), 117 | Onto: key.NewBinding(key.WithKeys(m.Rebase.Onto...), key.WithHelp(JoinKeys(m.Rebase.Onto), "onto")), 118 | Insert: key.NewBinding(key.WithKeys(m.Rebase.Insert...), key.WithHelp(JoinKeys(m.Rebase.Insert), "insert between")), 119 | }, 120 | Details: detailsModeKeys[key.Binding]{ 121 | Mode: key.NewBinding(key.WithKeys(m.Details.Mode...), key.WithHelp(JoinKeys(m.Details.Mode), "details")), 122 | Close: key.NewBinding(key.WithKeys(m.Details.Close...), key.WithHelp(JoinKeys(m.Details.Close), "close")), 123 | Split: key.NewBinding(key.WithKeys(m.Details.Split...), key.WithHelp(JoinKeys(m.Details.Split), "details split")), 124 | Restore: key.NewBinding(key.WithKeys(m.Details.Restore...), key.WithHelp(JoinKeys(m.Details.Restore), "details restore")), 125 | Diff: key.NewBinding(key.WithKeys(m.Details.Diff...), key.WithHelp(JoinKeys(m.Details.Diff), "details diff")), 126 | ToggleSelect: key.NewBinding(key.WithKeys(m.Details.ToggleSelect...), key.WithHelp(JoinKeys(m.Details.ToggleSelect), "details toggle select")), 127 | RevisionsChangingFile: key.NewBinding(key.WithKeys(m.Details.RevisionsChangingFile...), key.WithHelp(JoinKeys(m.Details.RevisionsChangingFile), "show revisions changing file")), 128 | }, 129 | Bookmark: bookmarkModeKeys[key.Binding]{ 130 | Mode: key.NewBinding(key.WithKeys(m.Bookmark.Mode...), key.WithHelp(JoinKeys(m.Bookmark.Mode), "bookmarks")), 131 | Set: key.NewBinding(key.WithKeys(m.Bookmark.Set...), key.WithHelp(JoinKeys(m.Bookmark.Set), "set bookmark")), 132 | Delete: key.NewBinding(key.WithKeys(m.Bookmark.Delete...), key.WithHelp(JoinKeys(m.Bookmark.Delete), "delete")), 133 | Move: key.NewBinding(key.WithKeys(m.Bookmark.Move...), key.WithHelp(JoinKeys(m.Bookmark.Move), "move")), 134 | Forget: key.NewBinding(key.WithKeys(m.Bookmark.Forget...), key.WithHelp(JoinKeys(m.Bookmark.Forget), "forget")), 135 | Track: key.NewBinding(key.WithKeys(m.Bookmark.Track...), key.WithHelp(JoinKeys(m.Bookmark.Track), "track")), 136 | Untrack: key.NewBinding(key.WithKeys(m.Bookmark.Untrack...), key.WithHelp(JoinKeys(m.Bookmark.Untrack), "untrack")), 137 | }, 138 | Preview: previewModeKeys[key.Binding]{ 139 | Mode: key.NewBinding(key.WithKeys(m.Preview.Mode...), key.WithHelp(JoinKeys(m.Preview.Mode), "preview")), 140 | ScrollUp: key.NewBinding(key.WithKeys(m.Preview.ScrollUp...), key.WithHelp(JoinKeys(m.Preview.ScrollUp), "preview scroll up")), 141 | ScrollDown: key.NewBinding(key.WithKeys(m.Preview.ScrollDown...), key.WithHelp(JoinKeys(m.Preview.ScrollDown), "preview scroll down")), 142 | HalfPageDown: key.NewBinding(key.WithKeys(m.Preview.HalfPageDown...), key.WithHelp(JoinKeys(m.Preview.HalfPageDown), "preview half page down")), 143 | HalfPageUp: key.NewBinding(key.WithKeys(m.Preview.HalfPageUp...), key.WithHelp(JoinKeys(m.Preview.HalfPageUp), "preview half page up")), 144 | Expand: key.NewBinding(key.WithKeys(m.Preview.Expand...), key.WithHelp(JoinKeys(m.Preview.Expand), "expand width")), 145 | Shrink: key.NewBinding(key.WithKeys(m.Preview.Shrink...), key.WithHelp(JoinKeys(m.Preview.Shrink), "shrink width")), 146 | }, 147 | Git: gitModeKeys[key.Binding]{ 148 | Mode: key.NewBinding(key.WithKeys(m.Git.Mode...), key.WithHelp(JoinKeys(m.Git.Mode), "git")), 149 | Push: key.NewBinding(key.WithKeys(m.Git.Push...), key.WithHelp(JoinKeys(m.Git.Push), "git push")), 150 | Fetch: key.NewBinding(key.WithKeys(m.Git.Fetch...), key.WithHelp(JoinKeys(m.Git.Fetch), "git fetch")), 151 | }, 152 | OpLog: opLogModeKeys[key.Binding]{ 153 | Mode: key.NewBinding(key.WithKeys(m.OpLog.Mode...), key.WithHelp(JoinKeys(m.OpLog.Mode), "oplog")), 154 | Restore: key.NewBinding(key.WithKeys(m.OpLog.Restore...), key.WithHelp(JoinKeys(m.OpLog.Restore), "restore")), 155 | }, 156 | } 157 | } 158 | 159 | func (c *Config) GetKeyMap() KeyMappings[key.Binding] { 160 | return Convert(c.Keys) 161 | } 162 | 163 | func JoinKeys(keys []string) string { 164 | var joined []string 165 | for _, key := range keys { 166 | k := key 167 | switch key { 168 | case "up": 169 | k = "↑" 170 | case "down": 171 | k = "↓" 172 | case " ": 173 | k = "space" 174 | } 175 | joined = append(joined, k) 176 | } 177 | return strings.Join(joined, "/") 178 | } 179 | 180 | type keys []string 181 | 182 | type KeyMappings[T any] struct { 183 | Up T `toml:"up"` 184 | Down T `toml:"down"` 185 | JumpToParent T `toml:"jump_to_parent"` 186 | Apply T `toml:"apply"` 187 | Cancel T `toml:"cancel"` 188 | ToggleSelect T `toml:"toggle_select"` 189 | New T `toml:"new"` 190 | Commit T `toml:"commit"` 191 | Refresh T `toml:"refresh"` 192 | Abandon T `toml:"abandon"` 193 | Diff T `toml:"diff"` 194 | Quit T `toml:"quit"` 195 | Help T `toml:"help"` 196 | Describe T `toml:"describe"` 197 | Edit T `toml:"edit"` 198 | Diffedit T `toml:"diffedit"` 199 | Absorb T `toml:"absorb"` 200 | Split T `toml:"split"` 201 | Squash T `toml:"squash"` 202 | Undo T `toml:"undo"` 203 | Evolog T `toml:"evolog"` 204 | Revset T `toml:"revset"` 205 | QuickSearch T `toml:"quick_search"` 206 | QuickSearchCycle T `toml:"quick_search_cycle"` 207 | CustomCommands T `toml:"custom_commands"` 208 | Rebase rebaseModeKeys[T] `toml:"rebase"` 209 | Details detailsModeKeys[T] `toml:"details"` 210 | Preview previewModeKeys[T] `toml:"preview"` 211 | Bookmark bookmarkModeKeys[T] `toml:"bookmark"` 212 | Git gitModeKeys[T] `toml:"git"` 213 | OpLog opLogModeKeys[T] `toml:"oplog"` 214 | } 215 | 216 | type bookmarkModeKeys[T any] struct { 217 | Mode T `toml:"mode"` 218 | Set T `toml:"set"` 219 | Delete T `toml:"delete"` 220 | Move T `toml:"move"` 221 | Forget T `toml:"forget"` 222 | Track T `toml:"track"` 223 | Untrack T `toml:"untrack"` 224 | } 225 | 226 | type rebaseModeKeys[T any] struct { 227 | Mode T `toml:"mode"` 228 | Revision T `toml:"revision"` 229 | Source T `toml:"source"` 230 | Branch T `toml:"branch"` 231 | After T `toml:"after"` 232 | Before T `toml:"before"` 233 | Onto T `toml:"onto"` 234 | Insert T `toml:"insert"` 235 | } 236 | 237 | type detailsModeKeys[T any] struct { 238 | Mode T `toml:"mode"` 239 | Close T `toml:"close"` 240 | Split T `toml:"split"` 241 | Restore T `toml:"restore"` 242 | Diff T `toml:"diff"` 243 | ToggleSelect T `toml:"select"` 244 | RevisionsChangingFile T `toml:"revisions_changing_file"` 245 | } 246 | 247 | type gitModeKeys[T any] struct { 248 | Mode T `toml:"mode"` 249 | Push T `toml:"push"` 250 | Fetch T `toml:"fetch"` 251 | } 252 | 253 | type previewModeKeys[T any] struct { 254 | Mode T `toml:"mode"` 255 | ScrollUp T `toml:"scroll_up"` 256 | ScrollDown T `toml:"scroll_down"` 257 | HalfPageDown T `toml:"half_page_down"` 258 | HalfPageUp T `toml:"half_page_up"` 259 | Expand T `toml:"expand"` 260 | Shrink T `toml:"shrink"` 261 | } 262 | 263 | type opLogModeKeys[T any] struct { 264 | Mode T `toml:"mode"` 265 | Restore T `toml:"restore"` 266 | } 267 | -------------------------------------------------------------------------------- /internal/jj/bookmark_parser.go: -------------------------------------------------------------------------------- 1 | package jj 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | const moveBookmarkTemplate = `separate(";", name, if(remote, "remote", "."), tracked, conflict, normal_target.contained_in("%s"), normal_target.commit_id().shortest(1)) ++ "\n"` 8 | const allBookmarkTemplate = `separate(";", name, if(remote, remote, "."), tracked, conflict, 'false', normal_target.commit_id().shortest(1)) ++ "\n"` 9 | 10 | type BookmarkRemote struct { 11 | Remote string 12 | CommitId string 13 | Tracked bool 14 | } 15 | 16 | type Bookmark struct { 17 | Name string 18 | Remotes []BookmarkRemote 19 | Conflict bool 20 | Backwards bool 21 | CommitId string 22 | } 23 | 24 | func (b Bookmark) IsLocal() bool { 25 | return len(b.Remotes) == 0 26 | } 27 | 28 | func ParseBookmarkListOutput(output string) []Bookmark { 29 | bookmarks := strings.Split(output, "\n") 30 | var result []Bookmark 31 | for _, b := range bookmarks { 32 | parts := strings.Split(b, ";") 33 | if len(parts) < 5 { 34 | continue 35 | } else { 36 | name := parts[0] 37 | remote := parts[1] 38 | tracked := parts[2] == "true" 39 | conflict := parts[3] == "true" 40 | backwards := parts[4] == "true" 41 | commitId := parts[5] 42 | if remote == "." { 43 | bookmark := Bookmark{ 44 | Name: name, 45 | Conflict: conflict, 46 | Backwards: backwards, 47 | CommitId: commitId, 48 | } 49 | result = append(result, bookmark) 50 | } else if len(result) > 0 { 51 | previous := &result[len(result)-1] 52 | remote := BookmarkRemote{ 53 | Remote: remote, 54 | Tracked: tracked, 55 | CommitId: commitId, 56 | } 57 | previous.Remotes = append(previous.Remotes, remote) 58 | } 59 | } 60 | } 61 | return result 62 | 63 | } 64 | -------------------------------------------------------------------------------- /internal/jj/bookmark_parser_test.go: -------------------------------------------------------------------------------- 1 | package jj 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestParseBookmarkListOutput(t *testing.T) { 9 | type args struct { 10 | output string 11 | } 12 | tests := []struct { 13 | name string 14 | args args 15 | want []Bookmark 16 | }{ 17 | { 18 | name: "empty", 19 | args: args{ 20 | output: "", 21 | }, 22 | want: nil, 23 | }, 24 | { 25 | name: "single", 26 | args: args{ 27 | output: "feat-1;.;false;false;false;9", 28 | }, 29 | want: []Bookmark{ 30 | { 31 | Name: "feat-1", 32 | Remotes: nil, 33 | Conflict: false, 34 | Backwards: false, 35 | CommitId: "9", 36 | }, 37 | }, 38 | }, 39 | { 40 | name: "remote", 41 | args: args{ 42 | output: `feature;.;false;false;false;b 43 | feature;origin;true;false;false;b`, 44 | }, 45 | want: []Bookmark{ 46 | { 47 | Name: "feature", 48 | Remotes: []BookmarkRemote{ 49 | {"origin", "b", true}, 50 | }, 51 | Conflict: false, 52 | Backwards: false, 53 | CommitId: "b", 54 | }, 55 | }, 56 | }, 57 | } 58 | for _, tt := range tests { 59 | t.Run(tt.name, func(t *testing.T) { 60 | assert.Equalf(t, tt.want, ParseBookmarkListOutput(tt.args.output), "ParseBookmarkListOutput(%v)", tt.args.output) 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /internal/jj/commands.go: -------------------------------------------------------------------------------- 1 | package jj 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | type CommandArgs []string 9 | 10 | func ConfigGet(key string) CommandArgs { 11 | return []string{"config", "get", key} 12 | } 13 | 14 | func Log(revset string) CommandArgs { 15 | args := []string{"log", "--color", "always", "--quiet"} 16 | if revset != "" { 17 | args = append(args, "-r", revset) 18 | } 19 | return args 20 | } 21 | 22 | func New(revisions ...string) CommandArgs { 23 | args := []string{"new"} 24 | for _, revision := range revisions { 25 | args = append(args, "-r", revision) 26 | } 27 | return args 28 | } 29 | 30 | func CommitWorkingCopy() CommandArgs { 31 | return []string{"commit"} 32 | } 33 | 34 | func Edit(changeId string) CommandArgs { 35 | return []string{"edit", "-r", changeId} 36 | } 37 | 38 | func DiffEdit(changeId string) CommandArgs { 39 | return []string{"diffedit", "-r", changeId} 40 | } 41 | 42 | func Split(revision string, files []string) CommandArgs { 43 | args := []string{"split", "-r", revision} 44 | args = append(args, files...) 45 | return args 46 | } 47 | 48 | func Describe(revision string) CommandArgs { 49 | return []string{"describe", "-r", revision, "--edit"} 50 | } 51 | 52 | func Abandon(revision ...string) CommandArgs { 53 | args := []string{"abandon"} 54 | for _, rev := range revision { 55 | args = append(args, "-r", rev) 56 | } 57 | return args 58 | } 59 | 60 | func Diff(revision string, fileName string, extraArgs ...string) CommandArgs { 61 | args := []string{"diff", "-r", revision, "--color", "always"} 62 | if fileName != "" { 63 | args = append(args, fileName) 64 | } 65 | if extraArgs != nil { 66 | args = append(args, extraArgs...) 67 | } 68 | return args 69 | } 70 | 71 | func Restore(revision string, files []string) CommandArgs { 72 | args := []string{"restore", "-c", revision} 73 | args = append(args, files...) 74 | return args 75 | } 76 | 77 | func Undo() CommandArgs { 78 | return []string{"undo"} 79 | } 80 | 81 | func Snapshot() CommandArgs { 82 | return []string{"debug", "snapshot"} 83 | } 84 | 85 | func Status(revision string) CommandArgs { 86 | return []string{"log", "-r", revision, "--summary", "--no-graph", "--color", "never", "--quiet", "--template", "", "--ignore-working-copy"} 87 | } 88 | 89 | func BookmarkSet(revision string, name string) CommandArgs { 90 | return []string{"bookmark", "set", "-r", revision, name} 91 | } 92 | 93 | func BookmarkMove(revision string, bookmark string, extraFlags ...string) CommandArgs { 94 | args := []string{"bookmark", "move", bookmark, "--to", revision} 95 | if extraFlags != nil { 96 | args = append(args, extraFlags...) 97 | } 98 | return args 99 | } 100 | 101 | func BookmarkDelete(name string) CommandArgs { 102 | return []string{"bookmark", "delete", name} 103 | } 104 | 105 | func BookmarkForget(name string) CommandArgs { 106 | return []string{"bookmark", "forget", name} 107 | } 108 | 109 | func BookmarkTrack(name string) CommandArgs { 110 | return []string{"bookmark", "track", name} 111 | } 112 | 113 | func BookmarkUntrack(name string) CommandArgs { 114 | return []string{"bookmark", "untrack", name} 115 | } 116 | 117 | func Squash(from string, destination string) CommandArgs { 118 | return []string{"squash", "--from", from, "--into", destination} 119 | } 120 | 121 | func BookmarkList(revset string) CommandArgs { 122 | const template = `separate(";", name, if(remote, remote, "."), tracked, conflict, 'false', normal_target.commit_id().shortest(1)) ++ "\n"` 123 | return []string{"bookmark", "list", "-a", "-r", revset, "--template", template, "--color", "never"} 124 | } 125 | 126 | func BookmarkListMovable(revision string) CommandArgs { 127 | revsetBefore := fmt.Sprintf("::%s", revision) 128 | revsetAfter := fmt.Sprintf("%s::", revision) 129 | revset := fmt.Sprintf("%s | %s", revsetBefore, revsetAfter) 130 | template := fmt.Sprintf(moveBookmarkTemplate, revsetAfter) 131 | return []string{"bookmark", "list", "-r", revset, "--template", template, "--color", "never"} 132 | } 133 | 134 | func BookmarkListAll() CommandArgs { 135 | return []string{"bookmark", "list", "-a", "--template", allBookmarkTemplate, "--color", "never"} 136 | } 137 | 138 | func GitFetch(flags ...string) CommandArgs { 139 | args := []string{"git", "fetch"} 140 | if flags != nil { 141 | args = append(args, flags...) 142 | } 143 | return args 144 | } 145 | 146 | func GitPush(flags ...string) CommandArgs { 147 | args := []string{"git", "push"} 148 | if flags != nil { 149 | args = append(args, flags...) 150 | } 151 | return args 152 | } 153 | 154 | func Show(revision string, extraArgs ...string) CommandArgs { 155 | args := []string{"show", "-r", revision, "--color", "always"} 156 | if extraArgs != nil { 157 | args = append(args, extraArgs...) 158 | } 159 | return args 160 | } 161 | 162 | func Rebase(from string, to string, source string, target string) CommandArgs { 163 | return []string{"rebase", source, from, target, to} 164 | } 165 | 166 | func RebaseInsert(from string, insertAfter string, insertBefore string) CommandArgs { 167 | return []string{"rebase", "--revisions", from, "--insert-before", insertBefore, "--insert-after", insertAfter} 168 | } 169 | 170 | func Evolog(revision string) CommandArgs { 171 | return []string{"evolog", "-r", revision, "--color", "always", "--quiet"} 172 | } 173 | 174 | func Args(args ...string) CommandArgs { 175 | return args 176 | } 177 | 178 | func Absorb(changeId string) CommandArgs { 179 | return []string{"absorb", "--from", changeId} 180 | } 181 | 182 | func OpLog(limit int) CommandArgs { 183 | args := []string{"op", "log", "--color", "always", "--quiet", "--ignore-working-copy"} 184 | if limit > 0 { 185 | args = append(args, "--limit", strconv.Itoa(limit)) 186 | } 187 | return args 188 | } 189 | 190 | func OpShow(operationId string) CommandArgs { 191 | return []string{"op", "show", operationId, "--color", "always"} 192 | } 193 | 194 | func OpRestore(operationId string) CommandArgs { 195 | return []string{"op", "restore", operationId} 196 | } 197 | 198 | func GetParent(revision string) CommandArgs { 199 | return []string{"log", "-r", fmt.Sprintf("%s-", revision), "--color", "never", "--no-graph", "--quiet", "--ignore-working-copy", "--template", "commit_id.shortest()"} 200 | } 201 | -------------------------------------------------------------------------------- /internal/jj/commit.go: -------------------------------------------------------------------------------- 1 | package jj 2 | 3 | import "strings" 4 | 5 | const ( 6 | RootChangeId = "zzzzzzzz" 7 | ) 8 | 9 | type Commit struct { 10 | ChangeId string 11 | IsWorkingCopy bool 12 | Hidden bool 13 | CommitId string 14 | } 15 | 16 | func (c Commit) IsRoot() bool { 17 | return c.ChangeId == RootChangeId 18 | } 19 | 20 | func (c Commit) GetChangeId() string { 21 | if c.Hidden || strings.HasSuffix(c.ChangeId, "??") { 22 | return c.CommitId 23 | } 24 | return c.ChangeId 25 | } 26 | -------------------------------------------------------------------------------- /internal/screen/ansi_parser.go: -------------------------------------------------------------------------------- 1 | package screen 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | ) 8 | 9 | func Parse(raw []byte) []Segment { 10 | var segments []Segment 11 | for segment := range ParseFromReader(bytes.NewReader(raw)) { 12 | segments = append(segments, *segment) 13 | } 14 | return segments 15 | } 16 | 17 | func ParseFromReader(r io.Reader) <-chan *Segment { 18 | ch := make(chan *Segment) 19 | go func() { 20 | defer close(ch) 21 | var buffer bytes.Buffer 22 | currentParams := "" 23 | reader := bufio.NewReader(r) 24 | 25 | for { 26 | b, err := reader.ReadByte() 27 | if err == io.EOF { 28 | break 29 | } 30 | if err != nil { 31 | break 32 | } 33 | 34 | if b == 0x1B { 35 | peekBytes, err := reader.Peek(1) 36 | if err != nil { 37 | buffer.WriteByte(b) 38 | break 39 | } 40 | 41 | if len(peekBytes) >= 1 && peekBytes[0] == '[' { 42 | _, _ = reader.Discard(1) 43 | if buffer.Len() > 0 { 44 | ch <- &Segment{ 45 | Text: buffer.String(), 46 | Params: currentParams, 47 | } 48 | buffer.Reset() 49 | } 50 | 51 | var seq bytes.Buffer 52 | for { 53 | c, err := reader.ReadByte() 54 | if err != nil || c == 'm' { 55 | break 56 | } 57 | seq.WriteByte(c) 58 | } 59 | 60 | paramStr := seq.String() 61 | if paramStr == "0" { 62 | currentParams = "" 63 | } else { 64 | currentParams = paramStr 65 | } 66 | } else { 67 | buffer.WriteByte(b) 68 | if len(peekBytes) >= 1 { 69 | nextByte, _ := reader.ReadByte() 70 | buffer.WriteByte(nextByte) 71 | } 72 | } 73 | } else { 74 | buffer.WriteByte(b) 75 | } 76 | } 77 | 78 | if buffer.Len() > 0 { 79 | ch <- &Segment{ 80 | Text: buffer.String(), 81 | Params: currentParams, 82 | } 83 | } 84 | }() 85 | return ch 86 | } 87 | -------------------------------------------------------------------------------- /internal/screen/cell_buffer.go: -------------------------------------------------------------------------------- 1 | package screen 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | 7 | "github.com/rivo/uniseg" 8 | ) 9 | 10 | type cellBuffer struct { 11 | grid [][]Segment 12 | } 13 | 14 | func Stacked(view1, view2 string, x, y int) string { 15 | if x < 0 { 16 | x = 0 17 | } 18 | if y < 0 { 19 | y = 0 20 | } 21 | buf := &cellBuffer{} 22 | 23 | // Parse and apply base view 24 | buf.applyANSI([]byte(view1), 0, 0) 25 | buf.applyANSI([]byte(view2), x, y) 26 | 27 | return buf.String() 28 | } 29 | 30 | func (b *cellBuffer) applyANSI(input []byte, offsetX, offsetY int) { 31 | parsed := Parse(input) 32 | 33 | currentLine := offsetY 34 | currentCol := offsetX 35 | for _, st := range parsed { 36 | gr := uniseg.NewGraphemes(st.Text) 37 | for gr.Next() { 38 | cluster := gr.Str() 39 | if cluster == "\n" { 40 | currentLine++ 41 | currentCol = offsetX 42 | continue 43 | } 44 | 45 | // Expand buffer as needed 46 | for currentLine >= len(b.grid) { 47 | b.grid = append(b.grid, []Segment{}) 48 | } 49 | for currentCol >= len(b.grid[currentLine]) { 50 | b.grid[currentLine] = append(b.grid[currentLine], Segment{Text: string(' ')}) 51 | } 52 | 53 | // Overwrite cell 54 | if currentCol < 0 || currentLine < 0 { 55 | log.Fatalf("line: %d, col: %d", currentLine, currentCol) 56 | } 57 | b.grid[currentLine][currentCol] = Segment{ 58 | Text: cluster, 59 | Params: st.Params, 60 | } 61 | currentCol++ 62 | } 63 | } 64 | } 65 | 66 | func (b *cellBuffer) String() string { 67 | var segments [][]*Segment 68 | 69 | for _, line := range b.grid { 70 | var lineSegments []*Segment 71 | var lastSegment *Segment 72 | for _, c := range line { 73 | if lastSegment == nil || !lastSegment.StyleEqual(c) { 74 | if lastSegment != nil { 75 | lineSegments = append(lineSegments, lastSegment) 76 | } 77 | lastSegment = &Segment{ 78 | Text: c.Text, 79 | Params: c.Params, 80 | } 81 | } else { 82 | lastSegment.Text += c.Text 83 | } 84 | } 85 | lineSegments = append(lineSegments, lastSegment) 86 | segments = append(segments, lineSegments) 87 | } 88 | 89 | var sb strings.Builder 90 | for lineNum, lineStyles := range segments { 91 | if lineNum > 0 { 92 | sb.WriteByte('\n') 93 | } 94 | for _, style := range lineStyles { 95 | sb.WriteString(style.String()) 96 | } 97 | } 98 | return sb.String() 99 | } 100 | -------------------------------------------------------------------------------- /internal/screen/segment.go: -------------------------------------------------------------------------------- 1 | package screen 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | type Segment struct { 10 | Text string 11 | Params string 12 | Reversed bool 13 | } 14 | 15 | func splitString(str, searchString string) []string { 16 | index := strings.Index(str, searchString) 17 | if index == -1 { 18 | return []string{str} 19 | } 20 | 21 | before := str[:index] 22 | after := str[index+len(searchString):] 23 | 24 | return []string{before, searchString, after} 25 | } 26 | 27 | func (s Segment) Reverse(text string) []*Segment { 28 | ret := make([]*Segment, 0) 29 | for _, part := range splitString(s.Text, text) { 30 | if part == "" { 31 | continue 32 | } 33 | ret = append(ret, &Segment{ 34 | Text: part, 35 | Params: s.Params, 36 | Reversed: part == text, 37 | }) 38 | } 39 | return ret 40 | } 41 | 42 | func (s Segment) String() string { 43 | if s.Text == "\n" { 44 | return s.Text 45 | } 46 | if s.Params == "" { 47 | return s.Text 48 | } 49 | if s.Reversed { 50 | return fmt.Sprintf("\x1b[%sm\x1b[7m%s\x1b[0m", s.Params, s.Text) 51 | } 52 | return fmt.Sprintf("\x1b[%sm%s\x1b[0m", s.Params, s.Text) 53 | } 54 | 55 | func (s Segment) WithBackground(bg string) *Segment { 56 | var newParts []string 57 | parts := strings.Split(s.Params, ";") 58 | 59 | i := 0 60 | for i < len(parts) { 61 | part := parts[i] 62 | num, err := strconv.Atoi(part) 63 | if err != nil { 64 | i++ 65 | continue 66 | } 67 | p := num 68 | 69 | isBg := false 70 | if (p >= 40 && p <= 49) || (p >= 100 && p <= 109) { 71 | isBg = true 72 | } 73 | 74 | if !isBg { 75 | newParts = append(newParts, part) 76 | } 77 | i++ 78 | } 79 | 80 | for _, part := range strings.Split(bg, ";") { 81 | if _, err := strconv.Atoi(part); err != nil { 82 | panic(fmt.Sprintf("invalid background parameter %q", part)) 83 | } 84 | newParts = append(newParts, part) 85 | } 86 | 87 | return &Segment{ 88 | Text: s.Text, 89 | Params: strings.Join(newParts, ";"), 90 | } 91 | } 92 | 93 | func (s Segment) StyleEqual(other Segment) bool { 94 | return s.Params == other.Params 95 | } 96 | 97 | // BreakNewLinesIter group segments into lines by breaking segments at new lines 98 | func BreakNewLinesIter(rawSegments <-chan *Segment) <-chan []*Segment { 99 | output := make(chan []*Segment) 100 | go func() { 101 | defer close(output) 102 | currentLine := make([]*Segment, 0) 103 | for rawSegment := range rawSegments { 104 | idx := strings.IndexByte(rawSegment.Text, '\n') 105 | for idx != -1 { 106 | text := rawSegment.Text[:idx] 107 | if len(text) > 0 { 108 | currentLine = append(currentLine, &Segment{ 109 | Text: text, 110 | Params: rawSegment.Params, 111 | }) 112 | } 113 | output <- currentLine 114 | currentLine = make([]*Segment, 0) 115 | rawSegment.Text = rawSegment.Text[idx+1:] 116 | idx = strings.IndexByte(rawSegment.Text, '\n') 117 | } 118 | if len(rawSegment.Text) > 0 { 119 | currentLine = append(currentLine, rawSegment) 120 | } 121 | } 122 | if len(currentLine) > 0 { 123 | output <- currentLine 124 | } 125 | }() 126 | return output 127 | } 128 | -------------------------------------------------------------------------------- /internal/ui/bookmarks/bookmarks.go: -------------------------------------------------------------------------------- 1 | package bookmarks 2 | 3 | import ( 4 | "fmt" 5 | "github.com/charmbracelet/bubbles/key" 6 | "github.com/charmbracelet/bubbles/list" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/idursun/jjui/internal/config" 10 | "github.com/idursun/jjui/internal/jj" 11 | "github.com/idursun/jjui/internal/ui/common" 12 | "github.com/idursun/jjui/internal/ui/context" 13 | "math" 14 | "slices" 15 | "strings" 16 | ) 17 | 18 | type updateItemsMsg struct { 19 | items []list.Item 20 | } 21 | 22 | type Model struct { 23 | context context.AppContext 24 | current *jj.Commit 25 | filter string 26 | list list.Model 27 | items []list.Item 28 | keymap config.KeyMappings[key.Binding] 29 | width int 30 | height int 31 | distanceMap map[string]int 32 | } 33 | 34 | func (m *Model) Width() int { 35 | return m.width 36 | } 37 | 38 | func (m *Model) Height() int { 39 | return m.height 40 | } 41 | 42 | func (m *Model) SetWidth(w int) { 43 | maxWidth, minWidth := 80, 40 44 | m.width = max(min(maxWidth, w-4), minWidth) 45 | m.list.SetWidth(m.width - 8) 46 | } 47 | 48 | func (m *Model) SetHeight(h int) { 49 | maxHeight, minHeight := 30, 10 50 | m.height = max(min(maxHeight, h-4), minHeight) 51 | m.list.SetHeight(m.height - 6) 52 | } 53 | 54 | type commandType int 55 | 56 | // defines the order of actions in the list 57 | const ( 58 | moveCommand commandType = iota 59 | deleteCommand 60 | trackCommand 61 | untrackCommand 62 | forgetCommand 63 | ) 64 | 65 | type item struct { 66 | name string 67 | priority commandType 68 | dist int 69 | args []string 70 | } 71 | 72 | func (i item) FilterValue() string { 73 | return i.name 74 | } 75 | 76 | func (i item) Title() string { 77 | return i.name 78 | } 79 | 80 | func (i item) Description() string { 81 | desc := strings.Join(i.args, " ") 82 | return desc 83 | } 84 | 85 | func (m *Model) Init() tea.Cmd { 86 | return tea.Batch(m.loadAll, m.loadMovables) 87 | } 88 | 89 | func (m *Model) filtered(filter string) (tea.Model, tea.Cmd) { 90 | m.filter = filter 91 | if m.filter == "" { 92 | return m, m.list.SetItems(m.items) 93 | } 94 | var filtered []list.Item 95 | for _, i := range m.items { 96 | if strings.HasPrefix(i.FilterValue(), m.filter) { 97 | filtered = append(filtered, i) 98 | } 99 | } 100 | m.list.ResetSelected() 101 | return m, m.list.SetItems(filtered) 102 | } 103 | 104 | func (m *Model) loadMovables() tea.Msg { 105 | output, _ := m.context.RunCommandImmediate(jj.BookmarkListMovable(m.current.GetChangeId())) 106 | var bookmarkItems []list.Item 107 | bookmarks := jj.ParseBookmarkListOutput(string(output)) 108 | for _, b := range bookmarks { 109 | if !b.Conflict && b.CommitId == m.current.CommitId { 110 | continue 111 | } 112 | 113 | name := fmt.Sprintf("move '%s' to %s", b.Name, m.current.GetChangeId()) 114 | if b.Conflict { 115 | name = fmt.Sprintf("move conflicted '%s' to %s", b.Name, m.current.GetChangeId()) 116 | } 117 | var extraFlags []string 118 | if b.Backwards { 119 | name = fmt.Sprintf("move '%s' backwards to %s", b.Name, m.current.GetChangeId()) 120 | extraFlags = append(extraFlags, "--allow-backwards") 121 | } 122 | bookmarkItems = append(bookmarkItems, item{ 123 | name: name, 124 | priority: moveCommand, 125 | args: jj.BookmarkMove(m.current.GetChangeId(), b.Name, extraFlags...), 126 | dist: m.distance(b.CommitId), 127 | }) 128 | } 129 | return updateItemsMsg{items: bookmarkItems} 130 | } 131 | 132 | func (m *Model) loadAll() tea.Msg { 133 | if output, err := m.context.RunCommandImmediate(jj.BookmarkListAll()); err != nil { 134 | return nil 135 | } else { 136 | bookmarks := jj.ParseBookmarkListOutput(string(output)) 137 | 138 | items := make([]list.Item, 0) 139 | for _, b := range bookmarks { 140 | weight := m.distance(b.CommitId) 141 | if b.IsLocal() { 142 | items = append(items, item{ 143 | name: fmt.Sprintf("delete '%s'", b.Name), 144 | priority: deleteCommand, 145 | dist: weight, 146 | args: jj.BookmarkDelete(b.Name), 147 | }) 148 | } 149 | 150 | items = append(items, item{ 151 | name: fmt.Sprintf("forget '%s'", b.Name), 152 | priority: forgetCommand, 153 | dist: weight, 154 | args: jj.BookmarkForget(b.Name), 155 | }) 156 | 157 | for _, remote := range b.Remotes { 158 | nameWithRemote := fmt.Sprintf("%s@%s", b.Name, remote.Remote) 159 | if remote.Tracked { 160 | items = append(items, item{ 161 | name: fmt.Sprintf("untrack '%s'", nameWithRemote), 162 | priority: untrackCommand, 163 | dist: weight, 164 | args: jj.BookmarkUntrack(nameWithRemote), 165 | }) 166 | } else { 167 | items = append(items, item{ 168 | name: fmt.Sprintf("track '%s'", nameWithRemote), 169 | priority: trackCommand, 170 | dist: weight, 171 | args: jj.BookmarkTrack(nameWithRemote), 172 | }) 173 | } 174 | } 175 | 176 | } 177 | return updateItemsMsg{items: items} 178 | } 179 | } 180 | 181 | func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 182 | switch msg := msg.(type) { 183 | case tea.KeyMsg: 184 | if m.list.SettingFilter() { 185 | break 186 | } 187 | switch { 188 | case key.Matches(msg, m.keymap.Cancel): 189 | if m.filter != "" || m.list.IsFiltered() { 190 | m.list.ResetFilter() 191 | return m.filtered("") 192 | } 193 | return m, common.Close 194 | case key.Matches(msg, m.keymap.Apply): 195 | if m.list.SelectedItem() == nil { 196 | break 197 | } 198 | action := m.list.SelectedItem().(item) 199 | return m, m.context.RunCommand(action.args, common.Refresh, common.Close) 200 | case key.Matches(msg, m.keymap.Bookmark.Move): 201 | return m.filtered("move") 202 | case key.Matches(msg, m.keymap.Bookmark.Delete): 203 | return m.filtered("delete") 204 | case key.Matches(msg, m.keymap.Bookmark.Forget): 205 | return m.filtered("forget") 206 | case key.Matches(msg, m.keymap.Bookmark.Track): 207 | return m.filtered("track") 208 | case key.Matches(msg, m.keymap.Bookmark.Untrack): 209 | return m.filtered("untrack") 210 | } 211 | case updateItemsMsg: 212 | m.items = append(m.items, msg.items...) 213 | slices.SortFunc(m.items, itemSorter) 214 | return m, m.list.SetItems(m.items) 215 | } 216 | var cmd tea.Cmd 217 | m.list, cmd = m.list.Update(msg) 218 | return m, cmd 219 | } 220 | 221 | func itemSorter(a list.Item, b list.Item) int { 222 | ia := a.(item) 223 | ib := b.(item) 224 | if ia.priority != ib.priority { 225 | return int(ia.priority) - int(ib.priority) 226 | } 227 | if ia.dist == ib.dist { 228 | return strings.Compare(ia.name, ib.name) 229 | } 230 | if ia.dist > 0 && ib.dist > 0 { 231 | return ia.dist - ib.dist 232 | } 233 | if ia.dist < 0 && ib.dist < 0 { 234 | return ib.dist - ia.dist 235 | } 236 | return ib.dist - ia.dist 237 | } 238 | 239 | var filterStyle = common.DefaultPalette.ChangeId.PaddingLeft(2) 240 | var filterValueStyle = common.DefaultPalette.Normal.Bold(true) 241 | 242 | func (m *Model) View() string { 243 | title := m.list.Styles.Title.Render(m.list.Title) 244 | filterView := lipgloss.JoinHorizontal(0, filterStyle.Render("Showing "), filterValueStyle.Render("all")) 245 | if m.filter != "" { 246 | filterView = lipgloss.JoinHorizontal(0, filterStyle.Render("Showing only "), filterValueStyle.Render(m.filter)) 247 | } 248 | listView := m.list.View() 249 | helpView := m.helpView() 250 | content := lipgloss.JoinVertical(0, title, "", filterView, listView, "", helpView) 251 | content = lipgloss.Place(m.Width(), m.Height(), 0, 0, content) 252 | return lipgloss.NewStyle().Border(lipgloss.NormalBorder()).Render(content) 253 | } 254 | 255 | func renderKey(k key.Binding) string { 256 | if !k.Enabled() { 257 | return "" 258 | } 259 | return lipgloss.JoinHorizontal(0, common.DefaultPalette.ChangeId.Render(k.Help().Key, ""), common.DefaultPalette.Dimmed.Render(k.Help().Desc, "")) 260 | } 261 | 262 | func (m *Model) helpView() string { 263 | if m.list.SettingFilter() { 264 | return "" 265 | } 266 | bindings := []string{ 267 | renderKey(m.keymap.Bookmark.Move), 268 | renderKey(m.keymap.Bookmark.Delete), 269 | renderKey(m.keymap.Bookmark.Forget), 270 | renderKey(m.keymap.Bookmark.Track), 271 | renderKey(m.keymap.Bookmark.Untrack), 272 | } 273 | if m.list.IsFiltered() { 274 | bindings = append(bindings, renderKey(m.keymap.Cancel)) 275 | } else { 276 | bindings = append(bindings, renderKey(m.list.KeyMap.Filter)) 277 | } 278 | 279 | return " " + lipgloss.JoinHorizontal(0, bindings...) 280 | } 281 | 282 | func (m *Model) distance(commitId string) int { 283 | if dist, ok := m.distanceMap[commitId]; ok { 284 | return dist 285 | } 286 | return math.MinInt32 287 | } 288 | 289 | func NewModel(c context.AppContext, current *jj.Commit, commitIds []string, width int, height int) *Model { 290 | var items []list.Item 291 | l := list.New(items, list.NewDefaultDelegate(), 0, 0) 292 | l.Title = "Bookmark operations" 293 | l.SetShowTitle(false) 294 | l.SetShowStatusBar(false) 295 | l.SetShowFilter(true) 296 | l.SetShowPagination(true) 297 | l.SetFilteringEnabled(true) 298 | l.SetShowHelp(false) 299 | l.DisableQuitKeybindings() 300 | 301 | m := &Model{ 302 | context: c, 303 | keymap: c.KeyMap(), 304 | list: l, 305 | current: current, 306 | distanceMap: calcDistanceMap(current.CommitId, commitIds), 307 | } 308 | m.SetWidth(width) 309 | m.SetHeight(height) 310 | return m 311 | } 312 | 313 | func calcDistanceMap(current string, commitIds []string) map[string]int { 314 | distanceMap := make(map[string]int) 315 | currentPos := -1 316 | for i, id := range commitIds { 317 | if id == current { 318 | currentPos = i 319 | break 320 | } 321 | } 322 | for i, id := range commitIds { 323 | dist := i - currentPos 324 | distanceMap[id] = dist 325 | } 326 | return distanceMap 327 | } 328 | -------------------------------------------------------------------------------- /internal/ui/bookmarks/bookmarks_test.go: -------------------------------------------------------------------------------- 1 | package bookmarks 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/list" 5 | "github.com/stretchr/testify/assert" 6 | "slices" 7 | "testing" 8 | ) 9 | 10 | func TestDistanceMap(t *testing.T) { 11 | selectedCommitId := "x" 12 | changeIds := []string{"a", "x", "b", "c", "d"} 13 | distanceMap := calcDistanceMap(selectedCommitId, changeIds) 14 | assert.Equal(t, 0, distanceMap["x"]) 15 | assert.Equal(t, -1, distanceMap["a"]) 16 | assert.Equal(t, 1, distanceMap["b"]) 17 | assert.Equal(t, 2, distanceMap["c"]) 18 | assert.Equal(t, 3, distanceMap["d"]) 19 | assert.Equal(t, 0, distanceMap["nonexistent"]) 20 | } 21 | 22 | func Test_Sorting_MoveCommands(t *testing.T) { 23 | items := []list.Item{ 24 | item{name: "move feature", dist: 5, priority: moveCommand}, 25 | item{name: "move main", dist: 1, priority: moveCommand}, 26 | item{name: "move very-old-feature", dist: 15, priority: moveCommand}, 27 | item{name: "move backwards", dist: -2, priority: moveCommand}, 28 | } 29 | slices.SortFunc(items, itemSorter) 30 | var sorted []string 31 | for _, i := range items { 32 | sorted = append(sorted, i.(item).name) 33 | } 34 | assert.Equal(t, []string{"move main", "move feature", "move very-old-feature", "move backwards"}, sorted) 35 | } 36 | -------------------------------------------------------------------------------- /internal/ui/common/focusable.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | type Focusable interface { 4 | IsFocused() bool 5 | } 6 | -------------------------------------------------------------------------------- /internal/ui/common/msgs.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "strings" 6 | ) 7 | 8 | type ( 9 | CloseViewMsg struct{} 10 | ToggleHelpMsg struct{} 11 | RefreshMsg struct { 12 | SelectedRevision string 13 | } 14 | ShowDiffMsg string 15 | UpdateRevisionsFailedMsg struct { 16 | Output string 17 | Err error 18 | } 19 | UpdateBookmarksMsg struct { 20 | Bookmarks []string 21 | Revision string 22 | } 23 | CommandRunningMsg string 24 | CommandCompletedMsg struct { 25 | Output string 26 | Err error 27 | } 28 | SelectionChangedMsg struct{} 29 | QuickSearchMsg string 30 | ) 31 | 32 | type State int 33 | 34 | const ( 35 | Loading State = iota 36 | Ready 37 | Error 38 | ) 39 | 40 | func Close() tea.Msg { 41 | return CloseViewMsg{} 42 | } 43 | 44 | func SelectionChanged() tea.Msg { 45 | return SelectionChangedMsg{} 46 | } 47 | 48 | func RefreshAndSelect(selectedRevision string) tea.Cmd { 49 | return func() tea.Msg { 50 | return RefreshMsg{SelectedRevision: selectedRevision} 51 | } 52 | } 53 | 54 | func Refresh() tea.Msg { 55 | return RefreshMsg{} 56 | } 57 | 58 | func ToggleHelp() tea.Msg { 59 | return ToggleHelpMsg{} 60 | } 61 | 62 | func CommandRunning(args []string) tea.Cmd { 63 | return func() tea.Msg { 64 | command := "jj " + strings.Join(args, " ") 65 | return CommandRunningMsg(command) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /internal/ui/common/sizable.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | type Sizable interface { 4 | Width() int 5 | Height() int 6 | SetWidth(w int) 7 | SetHeight(h int) 8 | } 9 | -------------------------------------------------------------------------------- /internal/ui/common/styles.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | ) 6 | 7 | var ( 8 | Black = lipgloss.Color("0") 9 | Red = lipgloss.Color("1") 10 | Green = lipgloss.Color("2") 11 | Yellow = lipgloss.Color("3") 12 | Blue = lipgloss.Color("4") 13 | Magenta = lipgloss.Color("5") 14 | Cyan = lipgloss.Color("6") 15 | White = lipgloss.Color("7") 16 | IntenseBlack = lipgloss.Color("8") 17 | IntenseRed = lipgloss.Color("9") 18 | IntenseGreen = lipgloss.Color("10") 19 | IntenseYellow = lipgloss.Color("11") 20 | IntenseBlue = lipgloss.Color("12") 21 | IntenseMagenta = lipgloss.Color("13") 22 | IntenseCyan = lipgloss.Color("14") 23 | IntenseWhite = lipgloss.Color("15") 24 | ) 25 | 26 | var DropStyle = lipgloss.NewStyle(). 27 | Bold(true). 28 | Foreground(Black). 29 | Background(Red) 30 | 31 | var DefaultPalette = Palette{ 32 | Normal: lipgloss.NewStyle(), 33 | ChangeId: lipgloss.NewStyle().Foreground(Magenta).Bold(true), 34 | CommitId: lipgloss.NewStyle().Foreground(Blue).Bold(true), 35 | Dimmed: lipgloss.NewStyle().Foreground(IntenseBlack), 36 | EmptyPlaceholder: lipgloss.NewStyle().Foreground(Green).Bold(true), 37 | ConfirmationText: lipgloss.NewStyle().Foreground(Magenta).Bold(true), 38 | Button: lipgloss.NewStyle().Foreground(White).PaddingLeft(2).PaddingRight(2), 39 | FocusedButton: lipgloss.NewStyle().Foreground(IntenseWhite).Background(Blue).PaddingLeft(2).PaddingRight(2), 40 | Added: lipgloss.NewStyle().Foreground(Green), 41 | Deleted: lipgloss.NewStyle().Foreground(Red), 42 | Modified: lipgloss.NewStyle().Foreground(Cyan), 43 | Renamed: lipgloss.NewStyle().Foreground(Cyan), 44 | Hint: lipgloss.NewStyle().Foreground(IntenseBlack).PaddingLeft(1), 45 | StatusSuccess: lipgloss.NewStyle().Foreground(Green), 46 | StatusError: lipgloss.NewStyle().Foreground(Red), 47 | StatusMode: lipgloss.NewStyle().Foreground(Black).Bold(true).Background(Magenta), 48 | } 49 | 50 | type Palette struct { 51 | Normal lipgloss.Style 52 | ChangeId lipgloss.Style 53 | CommitId lipgloss.Style 54 | Dimmed lipgloss.Style 55 | EmptyPlaceholder lipgloss.Style 56 | ConfirmationText lipgloss.Style 57 | Button lipgloss.Style 58 | FocusedButton lipgloss.Style 59 | ListTitle lipgloss.Style 60 | ListItem lipgloss.Style 61 | Added lipgloss.Style 62 | Deleted lipgloss.Style 63 | Modified lipgloss.Style 64 | Renamed lipgloss.Style 65 | Hint lipgloss.Style 66 | StatusMode lipgloss.Style 67 | StatusSuccess lipgloss.Style 68 | StatusError lipgloss.Style 69 | } 70 | -------------------------------------------------------------------------------- /internal/ui/confirmation/confirmation.go: -------------------------------------------------------------------------------- 1 | package confirmation 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "strings" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/idursun/jjui/internal/ui/common" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | ) 12 | 13 | var ( 14 | right = key.NewBinding(key.WithKeys("right", "l")) 15 | left = key.NewBinding(key.WithKeys("left", "h")) 16 | enter = key.NewBinding(key.WithKeys("enter")) 17 | ) 18 | 19 | type CloseMsg struct{} 20 | 21 | type option struct { 22 | label string 23 | cmd tea.Cmd 24 | keyBinding key.Binding 25 | } 26 | 27 | type Model struct { 28 | message string 29 | options []option 30 | selected int 31 | borderStyle lipgloss.Style 32 | } 33 | 34 | func (m *Model) Init() tea.Cmd { 35 | return nil 36 | } 37 | 38 | func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 39 | switch msg := msg.(type) { 40 | case tea.KeyMsg: 41 | switch { 42 | case key.Matches(msg, left): 43 | if m.selected > 0 { 44 | m.selected-- 45 | } 46 | case key.Matches(msg, right): 47 | if m.selected < len(m.options) { 48 | m.selected++ 49 | } 50 | case key.Matches(msg, enter): 51 | selectedOption := m.options[m.selected] 52 | return m, selectedOption.cmd 53 | default: 54 | for _, option := range m.options { 55 | if key.Matches(msg, option.keyBinding) { 56 | return m, option.cmd 57 | } 58 | } 59 | } 60 | } 61 | return m, nil 62 | } 63 | 64 | func (m *Model) View() string { 65 | w := strings.Builder{} 66 | w.WriteString(common.DefaultPalette.ConfirmationText.Render(m.message)) 67 | for i, option := range m.options { 68 | w.WriteString(" ") 69 | if i == m.selected { 70 | w.WriteString(common.DefaultPalette.FocusedButton.Render(option.label)) 71 | } else { 72 | w.WriteString(common.DefaultPalette.Button.Render(option.label)) 73 | } 74 | } 75 | return m.borderStyle.Render(w.String()) 76 | } 77 | 78 | func (m *Model) AddOption(label string, cmd tea.Cmd, keyBinding key.Binding) { 79 | m.options = append(m.options, option{label, cmd, keyBinding}) 80 | } 81 | 82 | func (m *Model) SetBorderStyle(style lipgloss.Style) { 83 | m.borderStyle = style 84 | } 85 | 86 | func New(message string) Model { 87 | return Model{ 88 | message: message, 89 | options: []option{}, 90 | selected: 0, 91 | borderStyle: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1, 0, 1), 92 | } 93 | } 94 | 95 | func Close() tea.Msg { 96 | return CloseMsg{} 97 | } 98 | -------------------------------------------------------------------------------- /internal/ui/context/context.go: -------------------------------------------------------------------------------- 1 | package context 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbletea" 6 | "github.com/idursun/jjui/internal/config" 7 | "io" 8 | ) 9 | 10 | type AppContext interface { 11 | Location() string 12 | KeyMap() config.KeyMappings[key.Binding] 13 | SelectedItem() SelectedItem 14 | SetSelectedItem(item SelectedItem) tea.Cmd 15 | RunCommandImmediate(args []string) ([]byte, error) 16 | RunCommandStreaming(args []string) (io.Reader, error) 17 | RunCommand(args []string, continuations ...tea.Cmd) tea.Cmd 18 | RunInteractiveCommand(args []string, continuation tea.Cmd) tea.Cmd 19 | } 20 | -------------------------------------------------------------------------------- /internal/ui/context/main_context.go: -------------------------------------------------------------------------------- 1 | package context 2 | 3 | import ( 4 | "bytes" 5 | "github.com/charmbracelet/bubbles/key" 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/idursun/jjui/internal/config" 8 | "github.com/idursun/jjui/internal/ui/common" 9 | "io" 10 | "os/exec" 11 | ) 12 | 13 | type SelectedItem interface { 14 | Equal(other SelectedItem) bool 15 | } 16 | 17 | type SelectedRevision struct { 18 | ChangeId string 19 | } 20 | 21 | func (s SelectedRevision) Equal(other SelectedItem) bool { 22 | if o, ok := other.(SelectedRevision); ok { 23 | return s.ChangeId == o.ChangeId 24 | } 25 | return false 26 | } 27 | 28 | type SelectedFile struct { 29 | ChangeId string 30 | File string 31 | } 32 | 33 | func (s SelectedFile) Equal(other SelectedItem) bool { 34 | if o, ok := other.(SelectedFile); ok { 35 | return s.ChangeId == o.ChangeId && s.File == o.File 36 | } 37 | return false 38 | } 39 | 40 | type SelectedOperation struct { 41 | OperationId string 42 | } 43 | 44 | func (s SelectedOperation) Equal(other SelectedItem) bool { 45 | if o, ok := other.(SelectedOperation); ok { 46 | return s.OperationId == o.OperationId 47 | } 48 | return false 49 | } 50 | 51 | type MainContext struct { 52 | selectedItem SelectedItem 53 | location string 54 | config *config.Config 55 | } 56 | 57 | func (a *MainContext) Location() string { 58 | return a.location 59 | } 60 | 61 | func (a *MainContext) KeyMap() config.KeyMappings[key.Binding] { 62 | return a.config.GetKeyMap() 63 | } 64 | 65 | func (a *MainContext) SelectedItem() SelectedItem { 66 | return a.selectedItem 67 | } 68 | 69 | func (a *MainContext) SetSelectedItem(item SelectedItem) tea.Cmd { 70 | if item == nil { 71 | return nil 72 | } 73 | if item.Equal(a.selectedItem) { 74 | return nil 75 | } 76 | a.selectedItem = item 77 | return common.SelectionChanged 78 | } 79 | 80 | func (a *MainContext) RunCommandImmediate(args []string) ([]byte, error) { 81 | c := exec.Command("jj", args...) 82 | c.Dir = a.location 83 | output, err := c.CombinedOutput() 84 | return bytes.Trim(output, "\n"), err 85 | } 86 | 87 | func (a *MainContext) RunCommandStreaming(args []string) (io.Reader, error) { 88 | c := exec.Command("jj", args...) 89 | c.Dir = a.location 90 | pipe, _ := c.StdoutPipe() 91 | err := c.Start() 92 | return pipe, err 93 | } 94 | 95 | func (a *MainContext) RunCommand(args []string, continuations ...tea.Cmd) tea.Cmd { 96 | commands := make([]tea.Cmd, 0) 97 | commands = append(commands, 98 | func() tea.Msg { 99 | c := exec.Command("jj", args...) 100 | c.Dir = a.location 101 | output, err := c.CombinedOutput() 102 | return common.CommandCompletedMsg{ 103 | Output: string(output), 104 | Err: err, 105 | } 106 | }) 107 | commands = append(commands, continuations...) 108 | return tea.Batch( 109 | common.CommandRunning(args), 110 | tea.Sequence(commands...), 111 | ) 112 | } 113 | 114 | func (a *MainContext) RunInteractiveCommand(args []string, continuation tea.Cmd) tea.Cmd { 115 | c := exec.Command("jj", args...) 116 | errBuffer := &bytes.Buffer{} 117 | c.Stderr = errBuffer 118 | c.Dir = a.location 119 | return tea.Batch( 120 | common.CommandRunning(args), 121 | tea.ExecProcess(c, func(err error) tea.Msg { 122 | if err != nil { 123 | return common.CommandCompletedMsg{Err: err, Output: errBuffer.String()} 124 | } 125 | return tea.Batch(continuation, func() tea.Msg { 126 | return common.CommandCompletedMsg{Err: nil} 127 | })() 128 | }), 129 | ) 130 | } 131 | 132 | func NewAppContext(location string) AppContext { 133 | configuration := config.Load() 134 | return &MainContext{ 135 | location: location, 136 | config: configuration, 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /internal/ui/custom_commands/command_manager.go: -------------------------------------------------------------------------------- 1 | package customcommands 2 | 3 | import ( 4 | "iter" 5 | "sync" 6 | 7 | "github.com/charmbracelet/bubbles/key" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/idursun/jjui/internal/config" 10 | "github.com/idursun/jjui/internal/ui/context" 11 | ) 12 | 13 | var ( 14 | commandManager *CommandManager 15 | commandManagerOnce sync.Once 16 | ) 17 | 18 | type CommandManager struct { 19 | commands []CustomCommand 20 | } 21 | 22 | func (cm *CommandManager) IterApplicable(ctx context.AppContext) iter.Seq[CustomCommand] { 23 | return func(yield func(CustomCommand) bool) { 24 | for _, command := range cm.commands { 25 | if !command.applicableTo(ctx.SelectedItem()) { 26 | continue 27 | } 28 | if !yield(command) { 29 | return 30 | } 31 | } 32 | } 33 | } 34 | 35 | func (cm *CommandManager) Iter() iter.Seq[CustomCommand] { 36 | return func(yield func(CustomCommand) bool) { 37 | for _, command := range cm.commands { 38 | if !yield(command) { 39 | return 40 | } 41 | } 42 | } 43 | } 44 | 45 | func GetCommandManager() *CommandManager { 46 | commandManagerOnce.Do(func() { 47 | var commands []CustomCommand 48 | for name, def := range config.Current.CustomCommands { 49 | commands = append(commands, newCustomCommand(name, def)) 50 | } 51 | commandManager = &CommandManager{commands: commands} 52 | }) 53 | return commandManager 54 | } 55 | 56 | func Matches(msg tea.KeyMsg) *CustomCommand { 57 | for _, v := range GetCommandManager().commands { 58 | if key.Matches(msg, v.Key) { 59 | return &v 60 | } 61 | } 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /internal/ui/custom_commands/custom_command.go: -------------------------------------------------------------------------------- 1 | package customcommands 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbletea" 6 | "github.com/idursun/jjui/internal/config" 7 | "github.com/idursun/jjui/internal/jj" 8 | "github.com/idursun/jjui/internal/ui/common" 9 | "github.com/idursun/jjui/internal/ui/context" 10 | "strings" 11 | ) 12 | 13 | type CustomCommand struct { 14 | Name string 15 | Key key.Binding 16 | Args []string 17 | Show config.ShowOption 18 | hasChangeId bool 19 | hasFile bool 20 | hasOperationId bool 21 | } 22 | 23 | type InvokableCustomCommand struct { 24 | args []string 25 | show config.ShowOption 26 | } 27 | 28 | const ( 29 | ChangeIdPlaceholder = "$change_id" 30 | FilePlaceholder = "$file" 31 | OperationIdPlaceholder = "$operation_id" 32 | ) 33 | 34 | func newCustomCommand(name string, definition config.CustomCommandDefinition) CustomCommand { 35 | var hasChangeId, hasFile, hasOperationId bool 36 | for _, arg := range definition.Args { 37 | if strings.Contains(arg, ChangeIdPlaceholder) { 38 | hasChangeId = true 39 | } 40 | if strings.Contains(arg, FilePlaceholder) { 41 | hasFile = true 42 | } 43 | if strings.Contains(arg, OperationIdPlaceholder) { 44 | hasOperationId = true 45 | } 46 | } 47 | 48 | binding := key.NewBinding(key.WithKeys(definition.Key...), key.WithHelp(config.JoinKeys(definition.Key), name)) 49 | return CustomCommand{ 50 | Name: name, 51 | Key: binding, 52 | Args: definition.Args, 53 | Show: definition.Show, 54 | hasChangeId: hasChangeId, 55 | hasFile: hasFile, 56 | hasOperationId: hasOperationId, 57 | } 58 | } 59 | 60 | func (cc CustomCommand) Prepare(ctx context.AppContext) InvokableCustomCommand { 61 | replacements := make(map[string]string) 62 | 63 | switch selectedItem := ctx.SelectedItem().(type) { 64 | case context.SelectedRevision: 65 | replacements[ChangeIdPlaceholder] = selectedItem.ChangeId 66 | case context.SelectedFile: 67 | replacements[ChangeIdPlaceholder] = selectedItem.ChangeId 68 | replacements[FilePlaceholder] = selectedItem.File 69 | case context.SelectedOperation: 70 | replacements[OperationIdPlaceholder] = selectedItem.OperationId 71 | } 72 | var args []string 73 | for _, arg := range cc.Args { 74 | for k, v := range replacements { 75 | arg = strings.ReplaceAll(arg, k, v) 76 | } 77 | args = append(args, arg) 78 | } 79 | 80 | return InvokableCustomCommand{ 81 | args: args, 82 | show: cc.Show, 83 | } 84 | } 85 | 86 | func (cc InvokableCustomCommand) Invoke(ctx context.AppContext) tea.Cmd { 87 | switch cc.show { 88 | case "": 89 | return ctx.RunCommand(jj.Args(cc.args...), common.Refresh) 90 | case config.ShowOptionDiff: 91 | output, _ := ctx.RunCommandImmediate(jj.Args(cc.args...)) 92 | return func() tea.Msg { 93 | return common.ShowDiffMsg(output) 94 | } 95 | case config.ShowOptionInteractive: 96 | return ctx.RunInteractiveCommand(jj.Args(cc.args...), common.Refresh) 97 | } 98 | return nil 99 | } 100 | 101 | func (cc CustomCommand) applicableTo(selectedItem context.SelectedItem) bool { 102 | if !cc.hasOperationId && !cc.hasFile && !cc.hasChangeId { 103 | return true 104 | } 105 | switch selectedItem.(type) { 106 | case context.SelectedRevision: 107 | return cc.hasChangeId 108 | case context.SelectedFile: 109 | return cc.hasFile 110 | case context.SelectedOperation: 111 | return cc.hasOperationId 112 | default: 113 | return false 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /internal/ui/custom_commands/custom_commands.go: -------------------------------------------------------------------------------- 1 | package customcommands 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/help" 5 | "github.com/charmbracelet/bubbles/key" 6 | "github.com/charmbracelet/bubbles/list" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/idursun/jjui/internal/config" 10 | "github.com/idursun/jjui/internal/ui/common" 11 | "github.com/idursun/jjui/internal/ui/context" 12 | "strings" 13 | ) 14 | 15 | type item struct { 16 | name string 17 | desc string 18 | command InvokableCustomCommand 19 | } 20 | 21 | func (i item) FilterValue() string { 22 | return i.name 23 | } 24 | 25 | func (i item) Title() string { 26 | return i.name 27 | } 28 | 29 | func (i item) Description() string { 30 | return i.desc 31 | } 32 | 33 | type Model struct { 34 | context context.AppContext 35 | commandManager *CommandManager 36 | keymap config.KeyMappings[key.Binding] 37 | list list.Model 38 | width int 39 | height int 40 | help help.Model 41 | } 42 | 43 | func (m *Model) Width() int { 44 | return m.width 45 | } 46 | 47 | func (m *Model) Height() int { 48 | return m.height 49 | } 50 | 51 | func (m *Model) SetWidth(w int) { 52 | maxWidth, minWidth := 80, 40 53 | m.width = max(min(maxWidth, w-4), minWidth) 54 | m.list.SetWidth(m.width - 8) 55 | } 56 | 57 | func (m *Model) SetHeight(h int) { 58 | maxHeight, minHeight := 30, 10 59 | m.height = max(min(maxHeight, h-4), minHeight) 60 | m.list.SetHeight(m.height - 4) 61 | } 62 | 63 | func (m *Model) Init() tea.Cmd { 64 | return nil 65 | } 66 | 67 | func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 68 | switch msg := msg.(type) { 69 | case tea.KeyMsg: 70 | if m.list.SettingFilter() { 71 | break 72 | } 73 | switch { 74 | case key.Matches(msg, m.keymap.Apply): 75 | if item, ok := m.list.SelectedItem().(item); ok { 76 | return m, tea.Batch(item.command.Invoke(m.context), common.Close) 77 | } 78 | case key.Matches(msg, m.keymap.Cancel): 79 | if m.list.IsFiltered() { 80 | m.list.ResetFilter() 81 | return m, nil 82 | } 83 | return m, common.Close 84 | } 85 | } 86 | var cmd tea.Cmd 87 | m.list, cmd = m.list.Update(msg) 88 | return m, cmd 89 | } 90 | 91 | func (m *Model) View() string { 92 | title := m.list.Styles.Title.Render(m.list.Title) 93 | listView := m.list.View() 94 | helpView := m.help.ShortHelpView([]key.Binding{m.keymap.Apply, m.keymap.Cancel, m.list.KeyMap.Filter}) 95 | content := lipgloss.JoinVertical(0, title, "", listView, " "+helpView) 96 | content = lipgloss.Place(m.width, m.height, 0, 0, content) 97 | return lipgloss.NewStyle().Border(lipgloss.NormalBorder()).Render(content) 98 | } 99 | 100 | func NewModel(ctx context.AppContext, width int, height int) *Model { 101 | var items []list.Item 102 | 103 | for command := range GetCommandManager().IterApplicable(ctx) { 104 | invokableCmd := command.Prepare(ctx) 105 | items = append(items, item{name: command.Name, desc: "jj " + strings.Join(invokableCmd.args, " "), command: invokableCmd}) 106 | } 107 | keyMap := ctx.KeyMap() 108 | l := list.New(items, list.NewDefaultDelegate(), 0, 0) 109 | l.Title = "Custom Commands" 110 | l.SetShowTitle(false) 111 | l.SetShowStatusBar(false) 112 | l.SetShowFilter(true) 113 | l.SetShowPagination(true) 114 | l.SetFilteringEnabled(true) 115 | l.SetShowHelp(false) 116 | l.DisableQuitKeybindings() 117 | 118 | h := help.New() 119 | h.Styles.ShortKey = common.DefaultPalette.ChangeId 120 | h.Styles.ShortDesc = common.DefaultPalette.Dimmed 121 | 122 | m := &Model{ 123 | context: ctx, 124 | commandManager: commandManager, 125 | keymap: keyMap, 126 | help: h, 127 | list: l, 128 | } 129 | m.SetWidth(width) 130 | m.SetHeight(height) 131 | return m 132 | } 133 | -------------------------------------------------------------------------------- /internal/ui/diff/diff.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "github.com/idursun/jjui/internal/config" 5 | "github.com/idursun/jjui/internal/ui/common" 6 | "github.com/idursun/jjui/internal/ui/context" 7 | 8 | "github.com/charmbracelet/bubbles/key" 9 | "github.com/charmbracelet/bubbles/viewport" 10 | tea "github.com/charmbracelet/bubbletea" 11 | ) 12 | 13 | type Model struct { 14 | view viewport.Model 15 | keymap config.KeyMappings[key.Binding] 16 | } 17 | 18 | func (m *Model) ShortHelp() []key.Binding { 19 | vkm := m.view.KeyMap 20 | return []key.Binding{ 21 | vkm.Up, vkm.Down, vkm.HalfPageDown, vkm.HalfPageUp, vkm.PageDown, vkm.PageUp, 22 | m.keymap.Cancel} 23 | } 24 | 25 | func (m *Model) FullHelp() [][]key.Binding { 26 | return [][]key.Binding{m.ShortHelp()} 27 | } 28 | 29 | func (m *Model) Init() tea.Cmd { 30 | return nil 31 | } 32 | 33 | func (m *Model) SetHeight(h int) { 34 | m.view.Height = h 35 | } 36 | 37 | func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) { 38 | switch msg := msg.(type) { 39 | case tea.KeyMsg: 40 | switch { 41 | case key.Matches(msg, m.keymap.Cancel): 42 | return m, common.Close 43 | } 44 | } 45 | var cmd tea.Cmd 46 | m.view, cmd = m.view.Update(msg) 47 | return m, cmd 48 | } 49 | 50 | func (m *Model) View() string { 51 | return m.view.View() 52 | } 53 | 54 | func New(context context.AppContext, output string, width int, height int) *Model { 55 | view := viewport.New(width, height) 56 | content := output 57 | if content == "" { 58 | content = "(empty)" 59 | } 60 | view.SetContent(content) 61 | return &Model{ 62 | view: view, 63 | keymap: context.KeyMap(), 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /internal/ui/git/git.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "github.com/charmbracelet/bubbles/key" 6 | "github.com/charmbracelet/bubbles/list" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/idursun/jjui/internal/config" 10 | "github.com/idursun/jjui/internal/jj" 11 | "github.com/idursun/jjui/internal/ui/common" 12 | "github.com/idursun/jjui/internal/ui/context" 13 | "strings" 14 | ) 15 | 16 | var filterStyle = common.DefaultPalette.ChangeId.PaddingLeft(2) 17 | var filterValueStyle = common.DefaultPalette.Normal.Bold(true) 18 | 19 | type item struct { 20 | key.Binding 21 | name string 22 | desc string 23 | command []string 24 | } 25 | 26 | func (i item) FilterValue() string { 27 | return i.name 28 | } 29 | 30 | func (i item) Title() string { 31 | return i.name 32 | } 33 | 34 | func (i item) Description() string { 35 | return i.desc 36 | } 37 | 38 | type Model struct { 39 | context context.AppContext 40 | keymap config.KeyMappings[key.Binding] 41 | list list.Model 42 | items []list.Item 43 | filter string 44 | width int 45 | height int 46 | } 47 | 48 | func (m *Model) Width() int { 49 | return m.width 50 | } 51 | 52 | func (m *Model) Height() int { 53 | return m.height 54 | } 55 | 56 | func (m *Model) SetWidth(w int) { 57 | maxWidth, minWidth := 80, 40 58 | m.width = max(min(maxWidth, w-4), minWidth) 59 | m.list.SetWidth(m.width - 8) 60 | } 61 | 62 | func (m *Model) SetHeight(h int) { 63 | maxHeight, minHeight := 30, 10 64 | m.height = max(min(maxHeight, h-4), minHeight) 65 | m.list.SetHeight(m.height - 6) 66 | } 67 | 68 | func (m *Model) Init() tea.Cmd { 69 | return nil 70 | } 71 | 72 | func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 73 | switch msg := msg.(type) { 74 | case tea.KeyMsg: 75 | if m.list.SettingFilter() { 76 | break 77 | } 78 | switch { 79 | case key.Matches(msg, m.keymap.Apply): 80 | action := m.list.SelectedItem().(item) 81 | return m, m.context.RunCommand(jj.Args(action.command...), common.Refresh, common.Close) 82 | case key.Matches(msg, m.keymap.Cancel): 83 | if m.filter != "" || m.list.IsFiltered() { 84 | m.list.ResetFilter() 85 | return m.filtered("") 86 | } 87 | return m, common.Close 88 | case key.Matches(msg, m.keymap.Git.Push): 89 | return m.filtered("push") 90 | case key.Matches(msg, m.keymap.Git.Fetch): 91 | return m.filtered("fetch") 92 | } 93 | } 94 | var cmd tea.Cmd 95 | m.list, cmd = m.list.Update(msg) 96 | return m, cmd 97 | } 98 | 99 | func (m *Model) View() string { 100 | title := m.list.Styles.Title.Render(m.list.Title) 101 | filterView := lipgloss.JoinHorizontal(0, filterStyle.Render("Showing "), filterValueStyle.Render("all")) 102 | if m.filter != "" { 103 | filterView = lipgloss.JoinHorizontal(0, filterStyle.Render("Showing only "), filterValueStyle.Render(m.filter)) 104 | } 105 | listView := m.list.View() 106 | helpView := m.helpView() 107 | content := lipgloss.JoinVertical(0, title, "", filterView, listView, "", helpView) 108 | content = lipgloss.Place(m.width, m.height, 0, 0, content) 109 | return lipgloss.NewStyle().Border(lipgloss.NormalBorder()).Render(content) 110 | } 111 | 112 | func renderKey(k key.Binding) string { 113 | if !k.Enabled() { 114 | return "" 115 | } 116 | return lipgloss.JoinHorizontal(0, common.DefaultPalette.ChangeId.Render(k.Help().Key, ""), common.DefaultPalette.Dimmed.Render(k.Help().Desc, "")) 117 | } 118 | 119 | func (m *Model) helpView() string { 120 | if m.list.SettingFilter() { 121 | return "" 122 | } 123 | bindings := []string{ 124 | renderKey(m.keymap.Git.Push), 125 | renderKey(m.keymap.Git.Fetch), 126 | } 127 | if m.list.IsFiltered() { 128 | bindings = append(bindings, renderKey(m.keymap.Cancel)) 129 | } else { 130 | bindings = append(bindings, renderKey(m.list.KeyMap.Filter)) 131 | } 132 | 133 | return " " + lipgloss.JoinHorizontal(0, bindings...) 134 | } 135 | 136 | func (m *Model) filtered(filter string) (tea.Model, tea.Cmd) { 137 | m.filter = filter 138 | if m.filter == "" { 139 | return m, m.list.SetItems(m.items) 140 | } 141 | var filtered []list.Item 142 | for _, i := range m.items { 143 | if strings.Contains(i.FilterValue(), m.filter) { 144 | filtered = append(filtered, i) 145 | } 146 | } 147 | m.list.ResetSelected() 148 | return m, m.list.SetItems(filtered) 149 | } 150 | 151 | func loadBookmarks(c context.AppContext, changeId string) []jj.Bookmark { 152 | bytes, _ := c.RunCommandImmediate(jj.BookmarkList(changeId)) 153 | bookmarks := jj.ParseBookmarkListOutput(string(bytes)) 154 | return bookmarks 155 | } 156 | 157 | func NewModel(c context.AppContext, commit *jj.Commit, width int, height int) *Model { 158 | var items []list.Item 159 | if commit != nil { 160 | bookmarks := loadBookmarks(c, commit.GetChangeId()) 161 | for _, b := range bookmarks { 162 | if b.Conflict { 163 | continue 164 | } 165 | for _, remote := range b.Remotes { 166 | bookmarkItem := item{ 167 | name: fmt.Sprintf("git push --bookmark %s --remote %s", b.Name, remote.Remote), 168 | desc: fmt.Sprintf("Git push bookmark %s to remote %s", b.Name, remote.Remote), 169 | command: jj.GitPush("--bookmark", b.Name, "--remote", remote.Remote), 170 | } 171 | items = append(items, bookmarkItem) 172 | } 173 | if b.IsLocal() { 174 | bookmarkItem := item{ 175 | name: fmt.Sprintf("git push --bookmark %s --allow-new", b.Name), 176 | desc: fmt.Sprintf("Git push new bookmark %s", b.Name), 177 | command: jj.GitPush("--bookmark", b.Name, "--allow-new"), 178 | } 179 | items = append(items, bookmarkItem) 180 | } 181 | } 182 | } 183 | items = append(items, 184 | item{name: "git push", desc: "Push tracking bookmarks in the current revset", command: jj.GitPush()}, 185 | item{name: "git push --all", desc: "Push all bookmarks (including new and deleted bookmarks)", command: jj.GitPush("--all")}, 186 | item{name: "git push --deleted", desc: "Push all deleted bookmarks", command: jj.GitPush("--deleted")}, 187 | item{name: "git push --tracked", desc: "Push all tracked bookmarks (including deleted bookmarks)", command: jj.GitPush("--tracked")}, 188 | item{name: "git push --allow-new", desc: "Allow pushing new bookmarks", command: jj.GitPush("--allow-new")}, 189 | item{name: "git fetch", desc: "Fetch from remote", command: jj.GitFetch()}, 190 | item{name: "git fetch --all-remotes", desc: "Fetch from all remotes", command: jj.GitFetch("--all-remotes")}, 191 | ) 192 | l := list.New(items, list.NewDefaultDelegate(), 0, 0) 193 | l.SetShowTitle(true) 194 | l.Title = "Git Operations" 195 | l.SetShowTitle(false) 196 | l.SetShowStatusBar(false) 197 | l.SetShowFilter(true) 198 | l.SetShowPagination(true) 199 | l.SetFilteringEnabled(true) 200 | l.SetShowHelp(false) 201 | l.DisableQuitKeybindings() 202 | m := &Model{ 203 | context: c, 204 | list: l, 205 | items: items, 206 | keymap: c.KeyMap(), 207 | } 208 | m.SetWidth(width) 209 | m.SetHeight(height) 210 | return m 211 | } 212 | -------------------------------------------------------------------------------- /internal/ui/git/git_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/x/exp/teatest" 6 | "github.com/idursun/jjui/internal/jj" 7 | "github.com/idursun/jjui/test" 8 | "github.com/stretchr/testify/assert" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func Test_Push(t *testing.T) { 14 | c := test.NewTestContext(t) 15 | c.Expect(jj.GitPush()) 16 | defer c.Verify() 17 | 18 | op := NewModel(c, nil, 0, 0) 19 | tm := teatest.NewTestModel(t, test.NewShell(op)) 20 | tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) 21 | tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second)) 22 | } 23 | 24 | func Test_Fetch(t *testing.T) { 25 | c := test.NewTestContext(t) 26 | c.Expect(jj.GitFetch()) 27 | defer c.Verify() 28 | 29 | op := NewModel(c, nil, 0, 0) 30 | tm := teatest.NewTestModel(t, test.NewShell(op)) 31 | tm.Type("/") 32 | tm.Type("fetch") 33 | tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) 34 | tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) 35 | tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second)) 36 | } 37 | 38 | func Test_loadBookmarks(t *testing.T) { 39 | const changeId = "changeid" 40 | c := test.NewTestContext(t) 41 | c.Expect(jj.BookmarkList(changeId)).SetOutput([]byte(` 42 | feat/allow-new-bookmarks;.;false;false;false;83 43 | feat/allow-new-bookmarks;origin;true;false;false;83 44 | main;.;false;false;false;86 45 | main;origin;true;false;false;86 46 | test;.;false;false;false;d0 47 | `)) 48 | defer c.Verify() 49 | 50 | bookmarks := loadBookmarks(c, changeId) 51 | assert.Len(t, bookmarks, 3) 52 | } 53 | -------------------------------------------------------------------------------- /internal/ui/graph/default_row_decorator.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/idursun/jjui/internal/ui/common" 6 | 7 | "github.com/idursun/jjui/internal/jj" 8 | "github.com/idursun/jjui/internal/ui/operations" 9 | ) 10 | 11 | type DefaultRowDecorator struct { 12 | Palette common.Palette 13 | HighlightBackground lipgloss.AdaptiveColor 14 | SearchText string 15 | IsHighlighted bool 16 | IsSelected bool 17 | Op operations.Operation 18 | Width int 19 | } 20 | 21 | func (s *DefaultRowDecorator) RenderBefore(*jj.Commit) string { 22 | if s.IsHighlighted && s.Op.RenderPosition() == operations.RenderPositionBefore { 23 | return s.Op.Render() 24 | } 25 | return "" 26 | } 27 | 28 | func (s *DefaultRowDecorator) RenderAfter(*jj.Commit) string { 29 | if s.IsHighlighted && s.Op.RenderPosition() == operations.RenderPositionAfter { 30 | return s.Op.Render() 31 | } 32 | return "" 33 | } 34 | 35 | func (s *DefaultRowDecorator) RenderBeforeChangeId() string { 36 | opMarker := "" 37 | if s.IsHighlighted { 38 | if s.Op.RenderPosition() == operations.RenderBeforeChangeId { 39 | opMarker = s.Op.Render() 40 | } 41 | } 42 | selectedMarker := "" 43 | if s.IsSelected { 44 | if s.IsHighlighted { 45 | selectedMarker = s.Palette.Added.Background(s.HighlightBackground).Render("✓ ") 46 | } else { 47 | selectedMarker = s.Palette.Added.Render("✓ ") 48 | } 49 | } 50 | return opMarker + selectedMarker 51 | } 52 | 53 | func (s *DefaultRowDecorator) RenderBeforeCommitId() string { 54 | if s.Op.RenderPosition() == operations.RenderBeforeCommitId { 55 | return s.Op.Render() 56 | } 57 | return "" 58 | } 59 | -------------------------------------------------------------------------------- /internal/ui/graph/log_parser.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | func ParseRows(reader io.Reader) []Row { 8 | var rows []Row 9 | controlChan := make(chan ControlMsg) 10 | defer close(controlChan) 11 | streamerChannel, err := ParseRowsStreaming(reader, controlChan, 50) 12 | if err != nil { 13 | return nil 14 | } 15 | for { 16 | controlChan <- RequestMore 17 | chunk := <-streamerChannel 18 | rows = append(rows, chunk.Rows...) 19 | if !chunk.HasMore { 20 | break 21 | } 22 | } 23 | return rows 24 | } 25 | -------------------------------------------------------------------------------- /internal/ui/graph/renderer.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "strings" 8 | 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/idursun/jjui/internal/ui/common" 11 | ) 12 | 13 | type Renderer struct { 14 | buffer bytes.Buffer 15 | skippedLineCount int 16 | lineCount int 17 | Width int 18 | } 19 | 20 | func (r *Renderer) SkipLines(amount int) { 21 | r.skippedLineCount = r.skippedLineCount + amount 22 | } 23 | 24 | func (r *Renderer) Write(p []byte) (n int, err error) { 25 | if len(p) == 0 { 26 | return 0, nil 27 | } 28 | r.lineCount += bytes.Count(p, []byte("\n")) 29 | return r.buffer.Write(p) 30 | } 31 | 32 | func (r *Renderer) LineCount() int { 33 | return r.lineCount + r.skippedLineCount 34 | } 35 | 36 | func (r *Renderer) String(start, end int) string { 37 | start = start - r.skippedLineCount 38 | end = end - r.skippedLineCount 39 | lines := strings.Split(r.buffer.String(), "\n") 40 | for i, line := range lines { 41 | lines[i] = strings.TrimSpace(line) 42 | } 43 | if start < 0 { 44 | start = 0 45 | } 46 | if end < start { 47 | end = start 48 | } 49 | for end > len(lines) { 50 | lines = append(lines, "") 51 | } 52 | return strings.Join(lines[start:end], "\n") 53 | } 54 | 55 | func (r *Renderer) Reset() { 56 | r.buffer.Reset() 57 | r.lineCount = 0 58 | } 59 | 60 | func RenderRow(r io.Writer, row Row, renderer DefaultRowDecorator) { 61 | // will render by extending the previous connections 62 | before := renderer.RenderBefore(row.Commit) 63 | if before != "" { 64 | extended := GraphRowLine{} 65 | if row.Previous != nil { 66 | extended = row.Previous.Last(Highlightable).Extend(row.Indent) 67 | } 68 | lines := strings.Split(before, "\n") 69 | for _, line := range lines { 70 | for _, segment := range extended.Segments { 71 | fmt.Fprint(r, segment.String()) 72 | } 73 | fmt.Fprintln(r, line) 74 | } 75 | } 76 | highlightColor := renderer.HighlightBackground.Light 77 | if lipgloss.HasDarkBackground() { 78 | highlightColor = renderer.HighlightBackground.Dark 79 | } 80 | highlightSeq := lipgloss.ColorProfile().Color(highlightColor).Sequence(true) 81 | var lastLine *GraphRowLine 82 | for segmentedLine := range row.RowLinesIter(Including(Highlightable)) { 83 | lastLine = segmentedLine 84 | lw := strings.Builder{} 85 | for i, segment := range segmentedLine.Segments { 86 | if i == segmentedLine.ChangeIdIdx { 87 | if decoration := renderer.RenderBeforeChangeId(); decoration != "" { 88 | fmt.Fprint(&lw, decoration) 89 | } 90 | } 91 | if renderer.IsHighlighted && i == segmentedLine.CommitIdIdx { 92 | if decoration := renderer.RenderBeforeCommitId(); decoration != "" { 93 | fmt.Fprint(&lw, decoration) 94 | } 95 | } 96 | if renderer.IsHighlighted { 97 | segment = segment.WithBackground(highlightSeq) 98 | } 99 | 100 | if renderer.IsHighlighted && renderer.SearchText != "" && strings.Contains(segment.Text, renderer.SearchText) { 101 | for _, part := range segment.Reverse(renderer.SearchText) { 102 | fmt.Fprint(&lw, part.String()) 103 | } 104 | } else { 105 | fmt.Fprint(&lw, segment.String()) 106 | } 107 | } 108 | if segmentedLine.Flags&Revision == Revision && row.IsAffected { 109 | style := common.DefaultPalette.Dimmed 110 | if renderer.IsHighlighted { 111 | style = common.DefaultPalette.Dimmed.Background(renderer.HighlightBackground) 112 | } 113 | fmt.Fprint(&lw, style.Render(" (affected by last operation)")) 114 | } 115 | line := lw.String() 116 | fmt.Fprint(r, line) 117 | if renderer.IsHighlighted { 118 | lineWidth := lipgloss.Width(line) 119 | gap := renderer.Width - lineWidth 120 | if gap > 0 { 121 | fmt.Fprintf(r, "\033[%sm%s\033[0m", highlightSeq, strings.Repeat(" ", gap)) 122 | } 123 | } 124 | fmt.Fprint(r, "\n") 125 | } 126 | 127 | if row.Commit.IsRoot() { 128 | return 129 | } 130 | 131 | afterSection := renderer.RenderAfter(row.Commit) 132 | if afterSection != "" && lastLine != nil { 133 | extended := lastLine.Extend(row.Indent) 134 | lines := strings.Split(afterSection, "\n") 135 | for _, line := range lines { 136 | for _, segment := range extended.Segments { 137 | fmt.Fprint(r, segment.String()) 138 | } 139 | fmt.Fprintln(r, line) 140 | } 141 | } 142 | 143 | for segmentedLine := range row.RowLinesIter(Excluding(Highlightable)) { 144 | for _, segment := range segmentedLine.Segments { 145 | fmt.Fprint(r, segment.String()) 146 | } 147 | fmt.Fprint(r, "\n") 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /internal/ui/graph/row.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "github.com/idursun/jjui/internal/jj" 5 | "github.com/idursun/jjui/internal/screen" 6 | "strings" 7 | "unicode" 8 | ) 9 | 10 | type Row struct { 11 | Commit *jj.Commit 12 | Lines []*GraphRowLine 13 | IsSelected bool 14 | IsAffected bool 15 | Indent int 16 | Previous *Row 17 | } 18 | 19 | type RowLineFlags int 20 | 21 | const ( 22 | Revision RowLineFlags = 1 << iota 23 | Highlightable 24 | Elided 25 | ) 26 | 27 | type GraphRowLine struct { 28 | Segments []*screen.Segment 29 | Flags RowLineFlags 30 | ChangeIdIdx int 31 | CommitIdIdx int 32 | } 33 | 34 | func NewGraphRowLine(segments []*screen.Segment) GraphRowLine { 35 | return GraphRowLine{ 36 | Segments: segments, 37 | ChangeIdIdx: -1, 38 | CommitIdIdx: -1, 39 | } 40 | } 41 | 42 | func (gr *GraphRowLine) Extend(indent int) GraphRowLine { 43 | ret := NewGraphRowLine(make([]*screen.Segment, 0)) 44 | for _, s := range gr.Segments { 45 | extended := screen.Segment{ 46 | Params: s.Params, 47 | } 48 | text := "" 49 | for _, p := range s.Text { 50 | if p == '│' || p == '╭' || p == '├' || p == '┐' || p == '┤' || p == '┌' { // curved, square style 51 | p = '│' 52 | } else if p == '|' { //ascii style 53 | p = '|' 54 | } else { 55 | p = ' ' 56 | } 57 | indent-- 58 | text += string(p) 59 | if indent <= 0 { 60 | break 61 | } 62 | } 63 | extended.Text = text 64 | ret.Segments = append(ret.Segments, &extended) 65 | if indent <= 0 { 66 | break 67 | } 68 | } 69 | for indent > 0 { 70 | ret.Segments[len(ret.Segments)-1].Text += " " 71 | indent-- 72 | } 73 | return ret 74 | } 75 | 76 | func (gr *GraphRowLine) ContainsRune(r rune, indent int) bool { 77 | for _, segment := range gr.Segments { 78 | text := segment.Text 79 | if len(segment.Text) > indent { 80 | text = segment.Text[:indent] 81 | } 82 | indent -= len(text) 83 | if strings.ContainsRune(text, r) { 84 | return true 85 | } 86 | } 87 | return false 88 | } 89 | 90 | func IsChangeIdLike(s string) bool { 91 | for _, r := range s { 92 | if !unicode.IsLetter(r) { 93 | return false 94 | } 95 | } 96 | return true 97 | } 98 | 99 | func IsHexLike(s string) bool { 100 | for _, r := range s { 101 | // Convert the rune to lowercase for case-insensitive comparison 102 | lowerChar := unicode.ToLower(r) 103 | if !(lowerChar >= 'a' && lowerChar <= 'f') && !(lowerChar >= '0' && lowerChar <= '9') { 104 | return false 105 | } 106 | } 107 | return true 108 | } 109 | 110 | func (gr *GraphRowLine) FindPossibleChangeIdIdx() int { 111 | for i, segment := range gr.Segments { 112 | if IsChangeIdLike(segment.Text) { 113 | return i 114 | } 115 | } 116 | 117 | return -1 118 | } 119 | 120 | func (gr *GraphRowLine) FindPossibleCommitIdIdx(after int) int { 121 | for i := after; i < len(gr.Segments); i++ { 122 | segment := gr.Segments[i] 123 | if IsHexLike(segment.Text) { 124 | return i 125 | } 126 | } 127 | return -1 128 | } 129 | 130 | func NewGraphRow() Row { 131 | return Row{ 132 | Commit: &jj.Commit{}, 133 | Lines: make([]*GraphRowLine, 0), 134 | } 135 | } 136 | 137 | func (r *Row) AddLine(line *GraphRowLine) { 138 | if r.Commit == nil { 139 | return 140 | } 141 | switch len(r.Lines) { 142 | case 0: 143 | line.Flags = Revision | Highlightable 144 | r.Commit.IsWorkingCopy = line.ContainsRune('@', r.Indent) 145 | for i := line.ChangeIdIdx; i < line.CommitIdIdx; i++ { 146 | segment := line.Segments[i] 147 | if strings.TrimSpace(segment.Text) == "hidden" { 148 | r.Commit.Hidden = true 149 | } 150 | } 151 | default: 152 | if line.ContainsRune('~', r.Indent) { 153 | line.Flags = Elided 154 | } else { 155 | if r.Commit.CommitId == "" { 156 | commitIdIdx := line.FindPossibleCommitIdIdx(0) 157 | if commitIdIdx != -1 { 158 | line.CommitIdIdx = commitIdIdx 159 | r.Commit.CommitId = line.Segments[commitIdIdx].Text 160 | line.Flags = Revision | Highlightable 161 | } 162 | } 163 | lastLine := r.Lines[len(r.Lines)-1] 164 | line.Flags = lastLine.Flags & ^Revision & ^Elided 165 | } 166 | } 167 | r.Lines = append(r.Lines, line) 168 | } 169 | 170 | func (r *Row) Last(flag RowLineFlags) *GraphRowLine { 171 | for i := len(r.Lines) - 1; i >= 0; i-- { 172 | if r.Lines[i].Flags&flag == flag { 173 | return r.Lines[i] 174 | } 175 | } 176 | return &GraphRowLine{} 177 | } 178 | 179 | type RowLinesIteratorPredicate func(f RowLineFlags) bool 180 | 181 | func Including(flags RowLineFlags) RowLinesIteratorPredicate { 182 | return func(f RowLineFlags) bool { 183 | return f&flags == flags 184 | } 185 | } 186 | 187 | func Excluding(flags RowLineFlags) RowLinesIteratorPredicate { 188 | return func(f RowLineFlags) bool { 189 | return f&flags != flags 190 | } 191 | } 192 | 193 | func (r *Row) RowLinesIter(predicate RowLinesIteratorPredicate) func(yield func(line *GraphRowLine) bool) { 194 | return func(yield func(line *GraphRowLine) bool) { 195 | for i := range r.Lines { 196 | line := r.Lines[i] 197 | if predicate(line.Flags) { 198 | if !yield(line) { 199 | return 200 | } 201 | } 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /internal/ui/graph/streaming_log_parser.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "github.com/idursun/jjui/internal/screen" 5 | "io" 6 | "strings" 7 | "unicode/utf8" 8 | ) 9 | 10 | type ControlMsg int 11 | 12 | const ( 13 | RequestMore ControlMsg = iota 14 | Close 15 | ) 16 | 17 | type RowBatch struct { 18 | Rows []Row 19 | HasMore bool 20 | } 21 | 22 | func ParseRowsStreaming(reader io.Reader, controlChannel <-chan ControlMsg, batchSize int) (<-chan RowBatch, error) { 23 | rowsChan := make(chan RowBatch, 1) 24 | go func() { 25 | defer close(rowsChan) 26 | var rows []Row 27 | var row Row 28 | rawSegments := screen.ParseFromReader(reader) 29 | for segmentedLine := range screen.BreakNewLinesIter(rawSegments) { 30 | rowLine := NewGraphRowLine(segmentedLine) 31 | if changeIdIdx := rowLine.FindPossibleChangeIdIdx(); changeIdIdx != -1 { 32 | rowLine.Flags = Revision | Highlightable 33 | previousRow := row 34 | if len(rows) > batchSize { 35 | select { 36 | case msg := <-controlChannel: 37 | switch msg { 38 | case Close: 39 | return 40 | case RequestMore: 41 | rowsChan <- RowBatch{Rows: rows, HasMore: true} 42 | rows = nil 43 | break 44 | } 45 | } 46 | } 47 | row = NewGraphRow() 48 | if previousRow.Commit != nil { 49 | rows = append(rows, previousRow) 50 | row.Previous = &previousRow 51 | } 52 | for j := 0; j < changeIdIdx; j++ { 53 | row.Indent += utf8.RuneCountInString(rowLine.Segments[j].Text) 54 | } 55 | rowLine.ChangeIdIdx = changeIdIdx 56 | row.Commit.ChangeId = rowLine.Segments[changeIdIdx].Text 57 | for nextIdx := changeIdIdx + 1; nextIdx < len(rowLine.Segments); nextIdx++ { 58 | nextSegment := rowLine.Segments[nextIdx] 59 | if strings.TrimSpace(nextSegment.Text) == "" || strings.ContainsAny(nextSegment.Text, "\n\t\r ") { 60 | break 61 | } 62 | row.Commit.ChangeId += nextSegment.Text 63 | } 64 | if commitIdIdx := rowLine.FindPossibleCommitIdIdx(changeIdIdx); commitIdIdx != -1 { 65 | rowLine.CommitIdIdx = commitIdIdx 66 | row.Commit.CommitId = rowLine.Segments[commitIdIdx].Text 67 | } 68 | } 69 | row.AddLine(&rowLine) 70 | } 71 | if row.Commit != nil { 72 | rows = append(rows, row) 73 | } 74 | if len(rows) > 0 { 75 | select { 76 | case msg := <-controlChannel: 77 | switch msg { 78 | case Close: 79 | return 80 | case RequestMore: 81 | rowsChan <- RowBatch{Rows: rows, HasMore: false} 82 | rows = nil 83 | break 84 | } 85 | } 86 | } 87 | }() 88 | return rowsChan, nil 89 | } 90 | -------------------------------------------------------------------------------- /internal/ui/graph/streaming_log_parser_test.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "github.com/idursun/jjui/test" 5 | "github.com/stretchr/testify/assert" 6 | "strconv" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestParseRowsStreaming_RequestMore(t *testing.T) { 12 | var lb test.LogBuilder 13 | for i := 0; i < 70; i++ { 14 | lb.Write("* id=abcde author=some@author id=xyrq") 15 | lb.Write("│ commit " + strconv.Itoa(i)) 16 | lb.Write("~\n") 17 | } 18 | 19 | reader := strings.NewReader(lb.String()) 20 | controlChannel := make(chan ControlMsg) 21 | receiver, err := ParseRowsStreaming(reader, controlChannel, 50) 22 | 23 | assert.NoError(t, err) 24 | var batch RowBatch 25 | controlChannel <- RequestMore 26 | batch = <-receiver 27 | assert.Len(t, batch.Rows, 51) 28 | assert.True(t, batch.HasMore, "expected more rows") 29 | 30 | controlChannel <- RequestMore 31 | batch = <-receiver 32 | assert.Len(t, batch.Rows, 19) 33 | assert.False(t, batch.HasMore, "expected no more rows") 34 | } 35 | 36 | func TestParseRowsStreaming_Close(t *testing.T) { 37 | var lb test.LogBuilder 38 | for i := 0; i < 70; i++ { 39 | lb.Write("* id=abcde author=some@author id=xyrq") 40 | lb.Write("│ commit " + strconv.Itoa(i)) 41 | lb.Write("~\n") 42 | } 43 | 44 | reader := strings.NewReader(lb.String()) 45 | controlChannel := make(chan ControlMsg) 46 | receiver, err := ParseRowsStreaming(reader, controlChannel, 50) 47 | assert.NoError(t, err) 48 | controlChannel <- Close 49 | _, received := <-receiver 50 | assert.False(t, received, "expected channel to be closed") 51 | } 52 | -------------------------------------------------------------------------------- /internal/ui/helppage/help.go: -------------------------------------------------------------------------------- 1 | package helppage 2 | 3 | import ( 4 | "fmt" 5 | "github.com/charmbracelet/bubbles/key" 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/idursun/jjui/internal/config" 9 | "github.com/idursun/jjui/internal/ui/common" 10 | "github.com/idursun/jjui/internal/ui/context" 11 | customcommands "github.com/idursun/jjui/internal/ui/custom_commands" 12 | ) 13 | 14 | type Model struct { 15 | width int 16 | height int 17 | keyMap config.KeyMappings[key.Binding] 18 | context context.AppContext 19 | } 20 | 21 | func (h *Model) Width() int { 22 | return h.width 23 | } 24 | 25 | func (h *Model) Height() int { 26 | return h.height 27 | } 28 | 29 | func (h *Model) SetWidth(w int) { 30 | h.width = w 31 | } 32 | 33 | func (h *Model) SetHeight(height int) { 34 | h.height = height 35 | } 36 | 37 | func (h *Model) ShortHelp() []key.Binding { 38 | return []key.Binding{h.keyMap.Help, h.keyMap.Cancel} 39 | } 40 | 41 | func (h *Model) FullHelp() [][]key.Binding { 42 | return [][]key.Binding{h.ShortHelp()} 43 | } 44 | 45 | func (h *Model) Init() tea.Cmd { 46 | return nil 47 | } 48 | 49 | func (h *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 50 | switch msg := msg.(type) { 51 | case tea.KeyMsg: 52 | switch { 53 | case key.Matches(msg, h.keyMap.Help), key.Matches(msg, h.keyMap.Cancel): 54 | return h, common.Close 55 | } 56 | } 57 | return h, nil 58 | } 59 | 60 | var ( 61 | keyStyle = common.DefaultPalette.ChangeId 62 | descStyle = common.DefaultPalette.Dimmed 63 | ) 64 | 65 | func printHelp(k key.Binding) string { 66 | return printHelpExt(k.Help().Key, k.Help().Desc) 67 | } 68 | 69 | func printHelpExt(key string, desc string) string { 70 | keyAligned := fmt.Sprintf("%9s", key) 71 | help := fmt.Sprintf("%s %s", keyStyle.Render(keyAligned), descStyle.Render(desc)) 72 | return help 73 | } 74 | 75 | func printHeader(header string) string { 76 | return common.DefaultPalette.EmptyPlaceholder.Render(header) 77 | } 78 | 79 | func printMode(key key.Binding, name string) string { 80 | keyAligned := fmt.Sprintf("%9s", key.Help().Key) 81 | help := fmt.Sprintf("%v %s", keyStyle.Render(keyAligned), common.DefaultPalette.EmptyPlaceholder.Render(name)) 82 | return help 83 | } 84 | 85 | var border = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).Padding(2) 86 | 87 | func (h *Model) View() string { 88 | leftView := lipgloss.JoinVertical(lipgloss.Left, 89 | printHeader("UI"), 90 | printHelp(h.keyMap.Refresh), 91 | printHelp(h.keyMap.Help), 92 | printHelp(h.keyMap.Cancel), 93 | printHelp(h.keyMap.Quit), 94 | printHelp(h.keyMap.Revset), 95 | printHeader("Revisions"), 96 | printHelp(h.keyMap.ToggleSelect), 97 | printHelp(h.keyMap.QuickSearch), 98 | printHelp(h.keyMap.QuickSearchCycle), 99 | printHelp(h.keyMap.JumpToParent), 100 | printHelp(h.keyMap.New), 101 | printHelp(h.keyMap.Commit), 102 | printHelp(h.keyMap.Describe), 103 | printHelp(h.keyMap.Edit), 104 | printHelp(h.keyMap.Diff), 105 | printHelp(h.keyMap.Diffedit), 106 | printHelp(h.keyMap.Split), 107 | printHelp(h.keyMap.Squash), 108 | printHelp(h.keyMap.Abandon), 109 | printHelp(h.keyMap.Absorb), 110 | printHelp(h.keyMap.Undo), 111 | printHelp(h.keyMap.Details.Mode), 112 | printHelp(h.keyMap.Evolog), 113 | printHelp(h.keyMap.Bookmark.Set), 114 | "", 115 | printMode(h.keyMap.Preview.Mode, "Preview"), 116 | printHelp(h.keyMap.Preview.ScrollUp), 117 | printHelp(h.keyMap.Preview.ScrollDown), 118 | printHelp(h.keyMap.Preview.HalfPageDown), 119 | printHelp(h.keyMap.Preview.HalfPageUp), 120 | printHelp(h.keyMap.Preview.Expand), 121 | printHelp(h.keyMap.Preview.Shrink), 122 | ) 123 | 124 | rightView := lipgloss.JoinVertical(lipgloss.Left, 125 | printMode(h.keyMap.Details.Mode, "Details"), 126 | printHelp(h.keyMap.Details.Close), 127 | printHelp(h.keyMap.Details.ToggleSelect), 128 | printHelp(h.keyMap.Details.Restore), 129 | printHelp(h.keyMap.Details.Split), 130 | printHelp(h.keyMap.Details.Diff), 131 | printHelp(h.keyMap.Details.RevisionsChangingFile), 132 | "", 133 | printMode(h.keyMap.Git.Mode, "Git"), 134 | printHelp(h.keyMap.Git.Push), 135 | printHelp(h.keyMap.Git.Fetch), 136 | "", 137 | printMode(h.keyMap.Bookmark.Mode, "Bookmarks"), 138 | printHelp(h.keyMap.Bookmark.Move), 139 | printHelp(h.keyMap.Bookmark.Delete), 140 | printHelp(h.keyMap.Bookmark.Untrack), 141 | printHelp(h.keyMap.Bookmark.Track), 142 | printHelp(h.keyMap.Bookmark.Forget), 143 | "", 144 | printMode(h.keyMap.Rebase.Mode, "Rebase"), 145 | printHelp(h.keyMap.Rebase.Revision), 146 | printHelp(h.keyMap.Rebase.Source), 147 | printHelp(h.keyMap.Rebase.Branch), 148 | printHelp(h.keyMap.Rebase.Before), 149 | printHelp(h.keyMap.Rebase.After), 150 | printHelp(h.keyMap.Rebase.Onto), 151 | printHelp(h.keyMap.Rebase.Insert), 152 | printHelp(h.keyMap.Apply), 153 | "", 154 | printMode(h.keyMap.OpLog.Mode, "Oplog"), 155 | printHelp(h.keyMap.Diff), 156 | printHelp(h.keyMap.OpLog.Restore), 157 | printMode(h.keyMap.CustomCommands, "Custom Commands"), 158 | ) 159 | 160 | var customCommands []string 161 | for command := range customcommands.GetCommandManager().Iter() { 162 | if command.Key.Enabled() { 163 | customCommands = append(customCommands, printHelp(command.Key)) 164 | } 165 | } 166 | if len(customCommands) > 0 { 167 | rightView = lipgloss.JoinVertical(lipgloss.Left, 168 | rightView, 169 | lipgloss.JoinVertical(lipgloss.Left, customCommands...), 170 | ) 171 | } 172 | 173 | content := lipgloss.JoinHorizontal(lipgloss.Left, leftView, " ", rightView) 174 | 175 | return border.Render(content) 176 | } 177 | 178 | func New(context context.AppContext) *Model { 179 | keyMap := context.KeyMap() 180 | return &Model{ 181 | context: context, 182 | keyMap: keyMap, 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /internal/ui/operations/abandon/abandon.go: -------------------------------------------------------------------------------- 1 | package abandon 2 | 3 | import ( 4 | "fmt" 5 | "github.com/charmbracelet/bubbles/key" 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/idursun/jjui/internal/jj" 8 | "github.com/idursun/jjui/internal/ui/common" 9 | "github.com/idursun/jjui/internal/ui/confirmation" 10 | "github.com/idursun/jjui/internal/ui/context" 11 | "github.com/idursun/jjui/internal/ui/operations" 12 | ) 13 | 14 | type Operation struct { 15 | model tea.Model 16 | context context.AppContext 17 | } 18 | 19 | func (a Operation) Update(msg tea.Msg) (operations.OperationWithOverlay, tea.Cmd) { 20 | var cmd tea.Cmd 21 | a.model, cmd = a.model.Update(msg) 22 | return a, cmd 23 | } 24 | 25 | func (a Operation) RenderPosition() operations.RenderPosition { 26 | return operations.RenderPositionAfter 27 | } 28 | 29 | func (a Operation) Render() string { 30 | return a.model.View() 31 | } 32 | 33 | func (a Operation) Name() string { 34 | return "abandon" 35 | } 36 | 37 | func NewOperation(context context.AppContext, selectedRevisions []string) operations.Operation { 38 | message := "Are you sure you want to abandon this revision?" 39 | if len(selectedRevisions) > 1 { 40 | message = fmt.Sprintf("Are you sure you want to abandon %d revisions?", len(selectedRevisions)) 41 | } 42 | model := confirmation.New(message) 43 | model.AddOption("Yes", context.RunCommand(jj.Abandon(selectedRevisions...), common.Refresh, common.Close), key.NewBinding(key.WithKeys("y"))) 44 | model.AddOption("No", common.Close, key.NewBinding(key.WithKeys("n", "esc"))) 45 | 46 | op := Operation{ 47 | model: &model, 48 | } 49 | return op 50 | } 51 | -------------------------------------------------------------------------------- /internal/ui/operations/abandon/abandon_test.go: -------------------------------------------------------------------------------- 1 | package abandon 2 | 3 | import ( 4 | "bytes" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/x/exp/teatest" 7 | "github.com/idursun/jjui/internal/jj" 8 | "github.com/idursun/jjui/test" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | var revisions = []string{"revision"} 14 | 15 | func Test_Accept(t *testing.T) { 16 | c := test.NewTestContext(t) 17 | c.Expect(jj.Abandon(revisions...)) 18 | defer c.Verify() 19 | 20 | model := test.OperationHost{Operation: NewOperation(c, revisions)} 21 | tm := teatest.NewTestModel(t, model) 22 | teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 23 | return bytes.Contains(bts, []byte("abandon")) 24 | }) 25 | 26 | tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) 27 | teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 28 | return bytes.Contains(bts, []byte("closed")) 29 | }) 30 | tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second)) 31 | } 32 | 33 | func Test_Cancel(t *testing.T) { 34 | c := test.NewTestContext(t) 35 | defer c.Verify() 36 | 37 | model := test.OperationHost{Operation: NewOperation(c, revisions)} 38 | tm := teatest.NewTestModel(t, model) 39 | tm.Send(tea.KeyMsg{Type: tea.KeyEsc}) 40 | teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 41 | return bytes.Contains(bts, []byte("closed")) 42 | }) 43 | tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second)) 44 | } 45 | -------------------------------------------------------------------------------- /internal/ui/operations/bookmark/set_bookmark.go: -------------------------------------------------------------------------------- 1 | package bookmark 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/textarea" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/idursun/jjui/internal/jj" 7 | "github.com/idursun/jjui/internal/ui/common" 8 | "github.com/idursun/jjui/internal/ui/context" 9 | "github.com/idursun/jjui/internal/ui/operations" 10 | ) 11 | 12 | type SetBookmarkOperation struct { 13 | context context.AppContext 14 | revision string 15 | name textarea.Model 16 | } 17 | 18 | func (s SetBookmarkOperation) Init() tea.Cmd { 19 | return textarea.Blink 20 | } 21 | 22 | func (s SetBookmarkOperation) View() string { 23 | return s.name.View() 24 | } 25 | 26 | func (s SetBookmarkOperation) IsFocused() bool { 27 | return true 28 | } 29 | 30 | func (s SetBookmarkOperation) Update(msg tea.Msg) (operations.OperationWithOverlay, tea.Cmd) { 31 | switch msg := msg.(type) { 32 | case tea.KeyMsg: 33 | switch msg.String() { 34 | case "esc": 35 | return s, common.Close 36 | case "enter": 37 | return s, s.context.RunCommand(jj.BookmarkSet(s.revision, s.name.Value()), common.Close, common.Refresh) 38 | } 39 | } 40 | var cmd tea.Cmd 41 | s.name, cmd = s.name.Update(msg) 42 | return s, cmd 43 | } 44 | 45 | func (s SetBookmarkOperation) Render() string { 46 | return s.name.View() 47 | } 48 | 49 | func (s SetBookmarkOperation) RenderPosition() operations.RenderPosition { 50 | return operations.RenderBeforeCommitId 51 | } 52 | 53 | func (s SetBookmarkOperation) Name() string { 54 | return "bookmark" 55 | } 56 | 57 | func NewSetBookmarkOperation(context context.AppContext, changeId string) (operations.Operation, tea.Cmd) { 58 | t := textarea.New() 59 | t.CharLimit = 120 60 | t.ShowLineNumbers = false 61 | t.SetValue("") 62 | t.SetWidth(20) 63 | t.SetHeight(1) 64 | t.Focus() 65 | 66 | op := SetBookmarkOperation{ 67 | name: t, 68 | revision: changeId, 69 | context: context, 70 | } 71 | return op, op.Init() 72 | } 73 | -------------------------------------------------------------------------------- /internal/ui/operations/bookmark/set_bookmark_test.go: -------------------------------------------------------------------------------- 1 | package bookmark 2 | 3 | import ( 4 | "github.com/idursun/jjui/internal/jj" 5 | "testing" 6 | "time" 7 | 8 | "github.com/idursun/jjui/test" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/x/exp/teatest" 12 | ) 13 | 14 | func TestSetBookmarkModel_Update(t *testing.T) { 15 | c := test.NewTestContext(t) 16 | c.Expect(jj.BookmarkSet("revision", "name")) 17 | defer c.Verify() 18 | 19 | op, _ := NewSetBookmarkOperation(c, "revision") 20 | host := test.OperationHost{Operation: op} 21 | tm := teatest.NewTestModel(t, host) 22 | tm.Type("name") 23 | tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) 24 | tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second)) 25 | } 26 | -------------------------------------------------------------------------------- /internal/ui/operations/default_operation.go: -------------------------------------------------------------------------------- 1 | package operations 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/idursun/jjui/internal/config" 6 | "github.com/idursun/jjui/internal/ui/context" 7 | ) 8 | 9 | type Default struct { 10 | keyMap config.KeyMappings[key.Binding] 11 | } 12 | 13 | func (n *Default) ShortHelp() []key.Binding { 14 | return []key.Binding{n.keyMap.Up, n.keyMap.Down, n.keyMap.Quit, n.keyMap.Help, n.keyMap.Refresh, n.keyMap.Preview.Mode, n.keyMap.Revset, n.keyMap.Details.Mode, n.keyMap.Evolog, n.keyMap.Rebase.Mode, n.keyMap.Squash, n.keyMap.Bookmark.Mode, n.keyMap.Git.Mode, n.keyMap.OpLog.Mode} 15 | } 16 | 17 | func (n *Default) FullHelp() [][]key.Binding { 18 | return [][]key.Binding{n.ShortHelp()} 19 | } 20 | 21 | func (n *Default) RenderPosition() RenderPosition { 22 | return RenderPositionNil 23 | } 24 | 25 | func (n *Default) Render() string { 26 | return "" 27 | } 28 | 29 | func (n *Default) Name() string { 30 | return "normal" 31 | } 32 | 33 | func NewDefault(c context.AppContext) *Default { 34 | return &Default{ 35 | keyMap: c.KeyMap(), 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /internal/ui/operations/details/details.go: -------------------------------------------------------------------------------- 1 | package details 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "path" 7 | "strings" 8 | 9 | "github.com/idursun/jjui/internal/config" 10 | "github.com/idursun/jjui/internal/jj" 11 | "github.com/idursun/jjui/internal/ui/context" 12 | "github.com/idursun/jjui/internal/ui/revset" 13 | 14 | "github.com/charmbracelet/bubbles/key" 15 | "github.com/charmbracelet/bubbles/list" 16 | tea "github.com/charmbracelet/bubbletea" 17 | "github.com/charmbracelet/lipgloss" 18 | "github.com/idursun/jjui/internal/ui/common" 19 | "github.com/idursun/jjui/internal/ui/confirmation" 20 | ) 21 | 22 | type status uint8 23 | 24 | var ( 25 | Added status = 0 26 | Deleted status = 1 27 | Modified status = 2 28 | Renamed status = 3 29 | ) 30 | 31 | type item struct { 32 | status status 33 | name string 34 | fileName string 35 | selected bool 36 | } 37 | 38 | func (f item) Title() string { 39 | status := "M" 40 | switch f.status { 41 | case Added: 42 | status = "A" 43 | case Deleted: 44 | status = "D" 45 | case Modified: 46 | status = "M" 47 | case Renamed: 48 | status = "R" 49 | } 50 | return fmt.Sprintf("%s %s", status, f.name) 51 | } 52 | func (f item) Description() string { return "" } 53 | func (f item) FilterValue() string { return f.name } 54 | 55 | type itemDelegate struct { 56 | selectedHint string 57 | unselectedHint string 58 | isVirtuallySelected bool 59 | } 60 | 61 | func (i itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { 62 | item, ok := listItem.(item) 63 | if !ok { 64 | return 65 | } 66 | var style lipgloss.Style 67 | switch item.status { 68 | case Added: 69 | style = common.DefaultPalette.Added 70 | case Deleted: 71 | style = common.DefaultPalette.Deleted 72 | case Modified: 73 | style = common.DefaultPalette.Modified 74 | case Renamed: 75 | style = common.DefaultPalette.Renamed 76 | } 77 | if index == m.Index() { 78 | style = style.Bold(true).Background(common.IntenseBlack) 79 | } 80 | 81 | title := item.Title() 82 | if item.selected { 83 | title = "✓" + title 84 | } else { 85 | title = " " + title 86 | } 87 | 88 | hint := "" 89 | if i.showHint() { 90 | hint = i.unselectedHint 91 | if item.selected || (i.isVirtuallySelected && index == m.Index()) { 92 | hint = i.selectedHint 93 | title = "✓" + item.Title() 94 | } 95 | } 96 | 97 | fmt.Fprint(w, style.PaddingRight(1).Render(title), common.DefaultPalette.Hint.Render(hint)) 98 | } 99 | 100 | func (i itemDelegate) Height() int { return 1 } 101 | func (i itemDelegate) Spacing() int { return 0 } 102 | func (i itemDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil } 103 | 104 | func (i itemDelegate) showHint() bool { 105 | return i.selectedHint != "" || i.unselectedHint != "" 106 | } 107 | 108 | type Model struct { 109 | revision string 110 | files list.Model 111 | height int 112 | confirmation tea.Model 113 | context context.AppContext 114 | keyMap config.KeyMappings[key.Binding] 115 | } 116 | 117 | type updateCommitStatusMsg []string 118 | 119 | func New(context context.AppContext, revision string) tea.Model { 120 | keyMap := context.KeyMap() 121 | l := list.New(nil, itemDelegate{}, 0, 0) 122 | l.SetFilteringEnabled(false) 123 | l.SetShowTitle(false) 124 | l.SetShowStatusBar(false) 125 | l.SetShowPagination(false) 126 | l.SetShowHelp(false) 127 | l.KeyMap.CursorUp = keyMap.Up 128 | l.KeyMap.CursorDown = keyMap.Down 129 | return Model{ 130 | revision: revision, 131 | files: l, 132 | context: context, 133 | keyMap: context.KeyMap(), 134 | } 135 | } 136 | 137 | func (m Model) Init() tea.Cmd { 138 | return tea.Batch(m.load(m.revision), tea.WindowSize()) 139 | } 140 | 141 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 142 | switch msg := msg.(type) { 143 | case tea.KeyMsg: 144 | if m.confirmation != nil { 145 | model, cmd := m.confirmation.Update(msg) 146 | m.confirmation = model 147 | return m, cmd 148 | } 149 | switch { 150 | case key.Matches(msg, m.keyMap.Cancel), key.Matches(msg, m.keyMap.Details.Close): 151 | return m, common.Close 152 | case key.Matches(msg, m.keyMap.Details.Diff): 153 | selected, ok := m.files.SelectedItem().(item) 154 | if !ok { 155 | return m, nil 156 | } 157 | return m, func() tea.Msg { 158 | output, _ := m.context.RunCommandImmediate(jj.Diff(m.revision, selected.fileName)) 159 | return common.ShowDiffMsg(output) 160 | } 161 | case key.Matches(msg, m.keyMap.Details.Split): 162 | selectedFiles, isVirtuallySelected := m.getSelectedFiles() 163 | m.files.SetDelegate(itemDelegate{ 164 | isVirtuallySelected: isVirtuallySelected, 165 | selectedHint: "stays as is", 166 | unselectedHint: "moves to the new revision", 167 | }) 168 | model := confirmation.New("Are you sure you want to split the selected files?") 169 | 170 | model.AddOption("Yes", tea.Batch(common.Close, m.context.RunInteractiveCommand(jj.Split(m.revision, selectedFiles), common.Refresh)), key.NewBinding(key.WithKeys("y"))) 171 | model.AddOption("No", confirmation.Close, key.NewBinding(key.WithKeys("n", "esc"))) 172 | m.confirmation = &model 173 | return m, m.confirmation.Init() 174 | case key.Matches(msg, m.keyMap.Details.Restore): 175 | selectedFiles, isVirtuallySelected := m.getSelectedFiles() 176 | m.files.SetDelegate(itemDelegate{ 177 | isVirtuallySelected: isVirtuallySelected, 178 | selectedHint: "gets restored", 179 | unselectedHint: "stays as is", 180 | }) 181 | model := confirmation.New("Are you sure you want to restore the selected files?") 182 | model.AddOption("Yes", m.context.RunCommand(jj.Restore(m.revision, selectedFiles), common.Refresh, common.Close), key.NewBinding(key.WithKeys("y"))) 183 | model.AddOption("No", confirmation.Close, key.NewBinding(key.WithKeys("n", "esc"))) 184 | m.confirmation = &model 185 | return m, m.confirmation.Init() 186 | case key.Matches(msg, m.keyMap.Details.ToggleSelect): 187 | if item, ok := m.files.SelectedItem().(item); ok { 188 | item.selected = !item.selected 189 | oldIndex := m.files.Index() 190 | m.files.CursorDown() 191 | return m, m.files.SetItem(oldIndex, item) 192 | } 193 | return m, nil 194 | case key.Matches(msg, m.keyMap.Details.RevisionsChangingFile): 195 | if item, ok := m.files.SelectedItem().(item); ok { 196 | return m, tea.Batch(common.Close, revset.UpdateRevSet(fmt.Sprintf("files(%s)", item.fileName))) 197 | } 198 | default: 199 | if len(m.files.Items()) > 0 { 200 | var cmd tea.Cmd 201 | m.files, cmd = m.files.Update(msg) 202 | curItem := m.files.SelectedItem().(item) 203 | return m, tea.Batch(cmd, m.context.SetSelectedItem(context.SelectedFile{ChangeId: m.revision, File: curItem.fileName})) 204 | } 205 | } 206 | case confirmation.CloseMsg: 207 | m.confirmation = nil 208 | m.files.SetDelegate(itemDelegate{}) 209 | return m, nil 210 | case common.RefreshMsg: 211 | return m, m.load(m.revision) 212 | case updateCommitStatusMsg: 213 | items := m.parseFiles(msg) 214 | var selectionChangedCmd tea.Cmd 215 | if len(items) > 0 { 216 | selectionChangedCmd = m.context.SetSelectedItem(context.SelectedFile{ChangeId: m.revision, File: items[0].(item).fileName}) 217 | } 218 | return m, tea.Batch(selectionChangedCmd, m.files.SetItems(items)) 219 | case tea.WindowSizeMsg: 220 | m.height = msg.Height 221 | } 222 | return m, nil 223 | } 224 | 225 | func (m Model) parseFiles(content []string) []list.Item { 226 | items := make([]list.Item, 0) 227 | for _, file := range content { 228 | if file == "" { 229 | continue 230 | } 231 | var status status 232 | switch file[0] { 233 | case 'A': 234 | status = Added 235 | case 'D': 236 | status = Deleted 237 | case 'M': 238 | status = Modified 239 | case 'R': 240 | status = Renamed 241 | } 242 | fileName := file[2:] 243 | 244 | actualFileName := fileName 245 | if status == Renamed && strings.Contains(actualFileName, "{") { 246 | for strings.Contains(actualFileName, "{") { 247 | start := strings.Index(actualFileName, "{") 248 | end := strings.Index(actualFileName, "}") 249 | if end == -1 { 250 | break 251 | } 252 | replacement := actualFileName[start+1 : end] 253 | parts := strings.Split(replacement, " => ") 254 | replacement = parts[1] 255 | actualFileName = path.Clean(actualFileName[:start] + replacement + actualFileName[end+1:]) 256 | } 257 | } 258 | items = append(items, item{ 259 | status: status, 260 | name: fileName, 261 | fileName: actualFileName, 262 | }) 263 | } 264 | return items 265 | } 266 | 267 | func (m Model) getSelectedFiles() ([]string, bool) { 268 | selectedFiles := make([]string, 0) 269 | isVirtuallySelected := false 270 | for _, f := range m.files.Items() { 271 | if f.(item).selected { 272 | selectedFiles = append(selectedFiles, f.(item).fileName) 273 | isVirtuallySelected = false 274 | } 275 | } 276 | if len(selectedFiles) == 0 { 277 | selectedFiles = append(selectedFiles, m.files.SelectedItem().(item).fileName) 278 | return selectedFiles, true 279 | } 280 | return selectedFiles, isVirtuallySelected 281 | } 282 | 283 | func (m Model) View() string { 284 | confirmationView := "" 285 | ch := 0 286 | if m.confirmation != nil { 287 | confirmationView = m.confirmation.View() 288 | ch = lipgloss.Height(confirmationView) 289 | } 290 | m.files.SetHeight(min(m.height-5-ch, len(m.files.Items()))) 291 | filesView := m.files.View() 292 | return lipgloss.JoinVertical(lipgloss.Top, filesView, confirmationView) 293 | } 294 | 295 | func (m Model) load(revision string) tea.Cmd { 296 | output, err := m.context.RunCommandImmediate(jj.Snapshot()) 297 | if err == nil { 298 | output, err = m.context.RunCommandImmediate(jj.Status(revision)) 299 | if err == nil { 300 | return func() tea.Msg { 301 | summary := strings.Split(strings.TrimSpace(string(output)), "\n") 302 | return updateCommitStatusMsg(summary) 303 | } 304 | } 305 | } 306 | return func() tea.Msg { 307 | return common.CommandCompletedMsg{ 308 | Output: string(output), 309 | Err: err, 310 | } 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /internal/ui/operations/details/details_test.go: -------------------------------------------------------------------------------- 1 | package details 2 | 3 | import ( 4 | "bytes" 5 | "github.com/idursun/jjui/internal/jj" 6 | "testing" 7 | "time" 8 | 9 | "github.com/idursun/jjui/test" 10 | 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/x/exp/teatest" 13 | ) 14 | 15 | const ( 16 | Revision = "ignored" 17 | StatusOutput = "M file.txt\nA newfile.txt\n" 18 | ) 19 | 20 | func TestModel_Init_ExecutesStatusCommand(t *testing.T) { 21 | context := test.NewTestContext(t) 22 | context.Expect(jj.Snapshot()) 23 | context.Expect(jj.Status(Revision)).SetOutput([]byte(StatusOutput)) 24 | defer context.Verify() 25 | 26 | tm := teatest.NewTestModel(t, New(context, Revision)) 27 | teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 28 | return bytes.Contains(bts, []byte("file.txt")) 29 | }) 30 | } 31 | 32 | func TestModel_Update_RestoresSelectedFiles(t *testing.T) { 33 | c := test.NewTestContext(t) 34 | c.Expect(jj.Snapshot()) 35 | c.Expect(jj.Status(Revision)).SetOutput([]byte(StatusOutput)) 36 | c.Expect(jj.Restore(Revision, []string{"file.txt"})) 37 | defer c.Verify() 38 | 39 | tm := teatest.NewTestModel(t, test.NewShell(New(c, Revision))) 40 | teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 41 | return bytes.Contains(bts, []byte("file.txt")) 42 | }) 43 | 44 | tm.Send(tea.KeyMsg{Type: tea.KeySpace}) 45 | tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")}) 46 | tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) 47 | tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second)) 48 | } 49 | 50 | func TestModel_Update_SplitsSelectedFiles(t *testing.T) { 51 | c := test.NewTestContext(t) 52 | c.Expect(jj.Snapshot()) 53 | c.Expect(jj.Status(Revision)).SetOutput([]byte(StatusOutput)) 54 | c.Expect(jj.Split(Revision, []string{"file.txt"})) 55 | defer c.Verify() 56 | 57 | tm := teatest.NewTestModel(t, test.NewShell(New(c, Revision))) 58 | teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 59 | return bytes.Contains(bts, []byte("file.txt")) 60 | }) 61 | 62 | tm.Send(tea.KeyMsg{Type: tea.KeySpace}) 63 | tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")}) 64 | tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) 65 | tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second)) 66 | } 67 | 68 | func TestModel_Update_HandlesMovedFiles(t *testing.T) { 69 | c := test.NewTestContext(t) 70 | c.Expect(jj.Snapshot()) 71 | c.Expect(jj.Status(Revision)).SetOutput([]byte("R internal/ui/{revisions => }/file.go\nR {file => sub/newfile}\n")) 72 | c.Expect(jj.Restore(Revision, []string{"internal/ui/file.go", "sub/newfile"})) 73 | defer c.Verify() 74 | 75 | tm := teatest.NewTestModel(t, test.NewShell(New(c, Revision))) 76 | teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 77 | return bytes.Contains(bts, []byte("file.go")) 78 | }) 79 | 80 | tm.Send(tea.KeyMsg{Type: tea.KeySpace}) 81 | tm.Send(tea.KeyMsg{Type: tea.KeySpace}) 82 | tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")}) 83 | tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) 84 | tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second)) 85 | } 86 | -------------------------------------------------------------------------------- /internal/ui/operations/details/show_details_operation.go: -------------------------------------------------------------------------------- 1 | package details 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/idursun/jjui/internal/config" 7 | "github.com/idursun/jjui/internal/jj" 8 | "github.com/idursun/jjui/internal/ui/context" 9 | "github.com/idursun/jjui/internal/ui/operations" 10 | ) 11 | 12 | type Operation struct { 13 | Overlay tea.Model 14 | keyMap config.KeyMappings[key.Binding] 15 | } 16 | 17 | func (s Operation) ShortHelp() []key.Binding { 18 | return []key.Binding{ 19 | s.keyMap.Up, 20 | s.keyMap.Down, 21 | s.keyMap.Cancel, 22 | s.keyMap.Details.Diff, 23 | s.keyMap.Details.ToggleSelect, 24 | s.keyMap.Details.Split, 25 | s.keyMap.Details.Restore, 26 | s.keyMap.Details.RevisionsChangingFile, 27 | } 28 | } 29 | 30 | func (s Operation) FullHelp() [][]key.Binding { 31 | return [][]key.Binding{s.ShortHelp()} 32 | } 33 | 34 | func (s Operation) Update(msg tea.Msg) (operations.OperationWithOverlay, tea.Cmd) { 35 | var cmd tea.Cmd 36 | s.Overlay, cmd = s.Overlay.Update(msg) 37 | return s, cmd 38 | } 39 | 40 | func (s Operation) Render() string { 41 | return s.Overlay.View() 42 | } 43 | 44 | func (s Operation) RenderPosition() operations.RenderPosition { 45 | return operations.RenderPositionAfter 46 | } 47 | 48 | func (s Operation) Name() string { 49 | return "details" 50 | } 51 | 52 | func NewOperation(context context.AppContext, selected *jj.Commit) (operations.Operation, tea.Cmd) { 53 | op := Operation{ 54 | Overlay: New(context, selected.GetChangeId()), 55 | keyMap: context.KeyMap(), 56 | } 57 | return op, op.Overlay.Init() 58 | } 59 | -------------------------------------------------------------------------------- /internal/ui/operations/evolog/evolog_operation.go: -------------------------------------------------------------------------------- 1 | package evolog 2 | 3 | import ( 4 | "bytes" 5 | 6 | "github.com/charmbracelet/bubbles/key" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/idursun/jjui/internal/config" 10 | "github.com/idursun/jjui/internal/jj" 11 | "github.com/idursun/jjui/internal/ui/common" 12 | "github.com/idursun/jjui/internal/ui/context" 13 | "github.com/idursun/jjui/internal/ui/graph" 14 | "github.com/idursun/jjui/internal/ui/operations" 15 | ) 16 | 17 | type viewRange struct { 18 | start int 19 | end int 20 | } 21 | 22 | type updateEvologMsg struct { 23 | rows []graph.Row 24 | } 25 | 26 | type Operation struct { 27 | context context.AppContext 28 | revision string 29 | rows []graph.Row 30 | viewRange *viewRange 31 | cursor int 32 | width int 33 | height int 34 | keyMap config.KeyMappings[key.Binding] 35 | } 36 | 37 | func (o Operation) ShortHelp() []key.Binding { 38 | return []key.Binding{o.keyMap.Up, o.keyMap.Down, o.keyMap.Cancel, o.keyMap.Diff} 39 | } 40 | 41 | func (o Operation) FullHelp() [][]key.Binding { 42 | return [][]key.Binding{o.ShortHelp()} 43 | } 44 | 45 | func (o Operation) Update(msg tea.Msg) (operations.OperationWithOverlay, tea.Cmd) { 46 | switch msg := msg.(type) { 47 | case updateEvologMsg: 48 | o.rows = msg.rows 49 | o.cursor = 0 50 | case tea.KeyMsg: 51 | switch { 52 | case key.Matches(msg, o.keyMap.Cancel): 53 | return o, common.Close 54 | case key.Matches(msg, o.keyMap.Diff): 55 | return o, func() tea.Msg { 56 | selectedCommitId := o.rows[o.cursor].Commit.CommitId 57 | output, _ := o.context.RunCommandImmediate(jj.Diff(selectedCommitId, "")) 58 | return common.ShowDiffMsg(output) 59 | } 60 | case key.Matches(msg, o.keyMap.Up): 61 | if o.cursor > 0 { 62 | o.cursor-- 63 | } 64 | case key.Matches(msg, o.keyMap.Down): 65 | if o.cursor < len(o.rows)-1 { 66 | o.cursor++ 67 | } 68 | } 69 | } 70 | return o, o.updateSelection() 71 | } 72 | 73 | func (o Operation) updateSelection() tea.Cmd { 74 | if o.rows == nil { 75 | return nil 76 | } 77 | 78 | return o.context.SetSelectedItem(context.SelectedRevision{ChangeId: o.rows[o.cursor].Commit.CommitId}) 79 | } 80 | 81 | func (o Operation) RenderPosition() operations.RenderPosition { 82 | return operations.RenderPositionAfter 83 | } 84 | 85 | func (o Operation) Render() string { 86 | if len(o.rows) == 0 { 87 | return "loading" 88 | } 89 | h := min(o.height-5, len(o.rows)*2) 90 | highlightBackground := lipgloss.AdaptiveColor{ 91 | Light: config.Current.UI.HighlightLight, 92 | Dark: config.Current.UI.HighlightDark, 93 | } 94 | var w graph.Renderer 95 | selectedLineStart := -1 96 | selectedLineEnd := -1 97 | for i, row := range o.rows { 98 | nodeRenderer := graph.DefaultRowDecorator{ 99 | Palette: common.DefaultPalette, 100 | HighlightBackground: highlightBackground, 101 | Op: &operations.Default{}, 102 | IsHighlighted: i == o.cursor, 103 | Width: o.width, 104 | } 105 | 106 | if i == o.cursor { 107 | selectedLineStart = w.LineCount() 108 | } 109 | graph.RenderRow(&w, row, nodeRenderer) 110 | if i == o.cursor { 111 | selectedLineEnd = w.LineCount() 112 | } 113 | if selectedLineEnd > 0 && w.LineCount() > h && w.LineCount() > o.viewRange.end { 114 | break 115 | } 116 | } 117 | 118 | if selectedLineStart <= o.viewRange.start { 119 | o.viewRange.start = selectedLineStart 120 | o.viewRange.end = selectedLineStart + h 121 | } else if selectedLineEnd > o.viewRange.end { 122 | o.viewRange.end = selectedLineEnd 123 | o.viewRange.start = selectedLineEnd - h 124 | } 125 | 126 | content := w.String(o.viewRange.start, o.viewRange.end) 127 | content = lipgloss.PlaceHorizontal(o.width, lipgloss.Left, content) 128 | return content 129 | } 130 | 131 | func (o Operation) Name() string { 132 | return "evolog" 133 | } 134 | 135 | func (o Operation) load() tea.Msg { 136 | output, _ := o.context.RunCommandImmediate(jj.Evolog(o.revision)) 137 | rows := graph.ParseRows(bytes.NewReader(output)) 138 | return updateEvologMsg{ 139 | rows: rows, 140 | } 141 | } 142 | 143 | func NewOperation(context context.AppContext, revision string, width int, height int) (*Operation, tea.Cmd) { 144 | v := viewRange{start: 0, end: 0} 145 | o := Operation{ 146 | context: context, 147 | keyMap: context.KeyMap(), 148 | revision: revision, 149 | rows: nil, 150 | viewRange: &v, 151 | cursor: 0, 152 | width: width, 153 | height: height, 154 | } 155 | return &o, o.load 156 | } 157 | -------------------------------------------------------------------------------- /internal/ui/operations/operation.go: -------------------------------------------------------------------------------- 1 | package operations 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/idursun/jjui/internal/jj" 6 | ) 7 | 8 | type RenderPosition int 9 | 10 | const ( 11 | RenderPositionNil RenderPosition = iota 12 | RenderPositionAfter 13 | RenderPositionBefore 14 | RenderBeforeChangeId 15 | RenderBeforeCommitId 16 | ) 17 | 18 | type Operation interface { 19 | RenderPosition() RenderPosition 20 | Render() string 21 | Name() string 22 | } 23 | 24 | type OperationWithOverlay interface { 25 | Operation 26 | Update(msg tea.Msg) (OperationWithOverlay, tea.Cmd) 27 | } 28 | 29 | type TracksSelectedRevision interface { 30 | SetSelectedRevision(commit *jj.Commit) 31 | } 32 | 33 | type HandleKey interface { 34 | HandleKey(msg tea.KeyMsg) tea.Cmd 35 | } 36 | -------------------------------------------------------------------------------- /internal/ui/operations/rebase/rebase_operation.go: -------------------------------------------------------------------------------- 1 | package rebase 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/idursun/jjui/internal/config" 8 | "github.com/idursun/jjui/internal/jj" 9 | "github.com/idursun/jjui/internal/ui/common" 10 | "github.com/idursun/jjui/internal/ui/context" 11 | "github.com/idursun/jjui/internal/ui/operations" 12 | ) 13 | 14 | type Source int 15 | 16 | const ( 17 | SourceRevision Source = iota 18 | SourceBranch 19 | SourceDescendants 20 | ) 21 | 22 | type Target int 23 | 24 | const ( 25 | TargetDestination Target = iota 26 | TargetAfter 27 | TargetBefore 28 | TargetInsert 29 | ) 30 | 31 | var ( 32 | sourceToFlags = map[Source]string{ 33 | SourceBranch: "--branch", 34 | SourceRevision: "--revisions", 35 | SourceDescendants: "--source", 36 | } 37 | targetToFlags = map[Target]string{ 38 | TargetAfter: "--insert-after", 39 | TargetBefore: "--insert-before", 40 | TargetDestination: "--destination", 41 | } 42 | ) 43 | 44 | type Operation struct { 45 | context context.AppContext 46 | From string 47 | InsertStart *jj.Commit 48 | To *jj.Commit 49 | Source Source 50 | Target Target 51 | keyMap config.KeyMappings[key.Binding] 52 | } 53 | 54 | func (r *Operation) HandleKey(msg tea.KeyMsg) tea.Cmd { 55 | switch { 56 | case key.Matches(msg, r.keyMap.Rebase.Revision): 57 | r.Source = SourceRevision 58 | case key.Matches(msg, r.keyMap.Rebase.Branch): 59 | r.Source = SourceBranch 60 | case key.Matches(msg, r.keyMap.Rebase.Source): 61 | r.Source = SourceDescendants 62 | case key.Matches(msg, r.keyMap.Rebase.Onto): 63 | r.Target = TargetDestination 64 | case key.Matches(msg, r.keyMap.Rebase.After): 65 | r.Target = TargetAfter 66 | case key.Matches(msg, r.keyMap.Rebase.Before): 67 | r.Target = TargetBefore 68 | case key.Matches(msg, r.keyMap.Rebase.Insert): 69 | r.Target = TargetInsert 70 | r.InsertStart = r.To 71 | case key.Matches(msg, r.keyMap.Apply): 72 | if r.Target == TargetInsert { 73 | return r.context.RunCommand(jj.RebaseInsert(r.From, r.InsertStart.GetChangeId(), r.To.GetChangeId()), common.RefreshAndSelect(r.From), common.Close) 74 | } else { 75 | source := sourceToFlags[r.Source] 76 | target := targetToFlags[r.Target] 77 | return r.context.RunCommand(jj.Rebase(r.From, r.To.GetChangeId(), source, target), common.RefreshAndSelect(r.From), common.Close) 78 | } 79 | case key.Matches(msg, r.keyMap.Cancel): 80 | return common.Close 81 | } 82 | return nil 83 | } 84 | 85 | func (r *Operation) SetSelectedRevision(commit *jj.Commit) { 86 | r.To = commit 87 | } 88 | 89 | func (r *Operation) ShortHelp() []key.Binding { 90 | return []key.Binding{ 91 | r.keyMap.Rebase.Revision, 92 | r.keyMap.Rebase.Branch, 93 | r.keyMap.Rebase.Source, 94 | r.keyMap.Rebase.Before, 95 | r.keyMap.Rebase.After, 96 | r.keyMap.Rebase.Onto, 97 | r.keyMap.Rebase.Insert, 98 | } 99 | } 100 | 101 | func (r *Operation) FullHelp() [][]key.Binding { 102 | return [][]key.Binding{r.ShortHelp()} 103 | } 104 | 105 | func (r *Operation) RenderPosition() operations.RenderPosition { 106 | if r.Target == TargetAfter { 107 | return operations.RenderPositionBefore 108 | } 109 | if r.Target == TargetDestination { 110 | return operations.RenderPositionBefore 111 | } 112 | return operations.RenderPositionAfter 113 | } 114 | 115 | func (r *Operation) Render() string { 116 | var source string 117 | if r.Source == SourceBranch { 118 | source = "branch of " 119 | } 120 | if r.Source == SourceDescendants { 121 | source = "itself and descendants of " 122 | } 123 | if r.Source == SourceRevision { 124 | source = "only " 125 | } 126 | var ret string 127 | if r.Target == TargetDestination { 128 | ret = "onto" 129 | } 130 | if r.Target == TargetAfter { 131 | ret = "after" 132 | } 133 | if r.Target == TargetBefore { 134 | ret = "before" 135 | } 136 | if r.Target == TargetInsert { 137 | ret = "insert" 138 | } 139 | 140 | if r.Target == TargetInsert { 141 | return lipgloss.JoinHorizontal( 142 | lipgloss.Left, 143 | common.DropStyle.Render("<< insert >>"), 144 | " ", 145 | common.DefaultPalette.Dimmed.Render(source), 146 | common.DefaultPalette.ChangeId.Render(r.From), 147 | common.DefaultPalette.Dimmed.Render(" between "), 148 | common.DefaultPalette.ChangeId.Render(r.InsertStart.GetChangeId()), 149 | common.DefaultPalette.Dimmed.Render(" and "), 150 | common.DefaultPalette.ChangeId.Render(r.To.GetChangeId()), 151 | ) 152 | } 153 | 154 | return lipgloss.JoinHorizontal( 155 | lipgloss.Left, 156 | common.DropStyle.Render("<< "+ret+" >>"), 157 | " ", 158 | common.DefaultPalette.Dimmed.Render("rebase"), 159 | " ", 160 | common.DefaultPalette.Dimmed.Render(source), 161 | common.DefaultPalette.ChangeId.Render(r.From), 162 | " ", 163 | common.DefaultPalette.Dimmed.Render(ret), 164 | " ", 165 | common.DefaultPalette.ChangeId.Render(r.To.GetChangeId()), 166 | ) 167 | } 168 | 169 | func (r *Operation) Name() string { 170 | return "rebase" 171 | } 172 | 173 | func NewOperation(context context.AppContext, from string, source Source, target Target) *Operation { 174 | return &Operation{ 175 | context: context, 176 | keyMap: context.KeyMap(), 177 | From: from, 178 | Source: source, 179 | Target: target, 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /internal/ui/operations/squash/squash_operation.go: -------------------------------------------------------------------------------- 1 | package squash 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/idursun/jjui/internal/config" 7 | "github.com/idursun/jjui/internal/jj" 8 | "github.com/idursun/jjui/internal/ui/common" 9 | "github.com/idursun/jjui/internal/ui/context" 10 | "github.com/idursun/jjui/internal/ui/operations" 11 | ) 12 | 13 | type Operation struct { 14 | context context.AppContext 15 | From string 16 | Current *jj.Commit 17 | keyMap config.KeyMappings[key.Binding] 18 | } 19 | 20 | func (s *Operation) HandleKey(msg tea.KeyMsg) tea.Cmd { 21 | switch { 22 | case key.Matches(msg, s.keyMap.Apply): 23 | return tea.Batch(common.Close, s.context.RunInteractiveCommand(jj.Squash(s.From, s.Current.ChangeId), common.Refresh)) 24 | case key.Matches(msg, s.keyMap.Cancel): 25 | return common.Close 26 | } 27 | return nil 28 | } 29 | 30 | func (s *Operation) SetSelectedRevision(commit *jj.Commit) { 31 | s.Current = commit 32 | } 33 | 34 | func (s *Operation) Render() string { 35 | return common.DropStyle.Render("<< into >>") 36 | } 37 | 38 | func (s *Operation) RenderPosition() operations.RenderPosition { 39 | return operations.RenderBeforeChangeId 40 | } 41 | 42 | func (s *Operation) Name() string { 43 | return "squash" 44 | } 45 | 46 | func (s *Operation) ShortHelp() []key.Binding { 47 | return []key.Binding{ 48 | s.keyMap.Apply, 49 | s.keyMap.Cancel, 50 | } 51 | } 52 | 53 | func (s *Operation) FullHelp() [][]key.Binding { 54 | return [][]key.Binding{s.ShortHelp()} 55 | } 56 | 57 | func NewOperation(context context.AppContext, from string) *Operation { 58 | return &Operation{ 59 | context: context, 60 | keyMap: context.KeyMap(), 61 | From: from, 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /internal/ui/oplog/operation_log.go: -------------------------------------------------------------------------------- 1 | package oplog 2 | 3 | import ( 4 | "bytes" 5 | 6 | "github.com/charmbracelet/bubbles/key" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/idursun/jjui/internal/config" 10 | "github.com/idursun/jjui/internal/jj" 11 | "github.com/idursun/jjui/internal/ui/common" 12 | "github.com/idursun/jjui/internal/ui/context" 13 | "github.com/idursun/jjui/internal/ui/graph" 14 | ) 15 | 16 | var normalStyle = lipgloss.NewStyle() 17 | 18 | type updateOpLogMsg struct { 19 | Rows []Row 20 | } 21 | 22 | type viewRange struct { 23 | start int 24 | end int 25 | } 26 | type Model struct { 27 | context context.AppContext 28 | rows []Row 29 | cursor int 30 | keymap config.KeyMappings[key.Binding] 31 | viewRange *viewRange 32 | width int 33 | height int 34 | } 35 | 36 | func (m *Model) ShortHelp() []key.Binding { 37 | return []key.Binding{m.keymap.Up, m.keymap.Down, m.keymap.Cancel, m.keymap.Diff, m.keymap.OpLog.Restore} 38 | } 39 | 40 | func (m *Model) FullHelp() [][]key.Binding { 41 | return [][]key.Binding{m.ShortHelp()} 42 | } 43 | 44 | func (m *Model) Width() int { 45 | return m.width 46 | } 47 | 48 | func (m *Model) Height() int { 49 | return m.height 50 | } 51 | 52 | func (m *Model) SetWidth(w int) { 53 | m.width = w 54 | } 55 | 56 | func (m *Model) SetHeight(h int) { 57 | m.height = h 58 | } 59 | 60 | func (m *Model) Init() tea.Cmd { 61 | return m.load() 62 | } 63 | 64 | func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) { 65 | switch msg := msg.(type) { 66 | case updateOpLogMsg: 67 | m.rows = msg.Rows 68 | m.viewRange.start = 0 69 | m.viewRange.end = 0 70 | case tea.KeyMsg: 71 | switch { 72 | case key.Matches(msg, m.keymap.Cancel): 73 | return m, common.Close 74 | case key.Matches(msg, m.keymap.Up): 75 | if m.cursor > 0 { 76 | m.cursor-- 77 | } 78 | case key.Matches(msg, m.keymap.Down): 79 | if m.cursor < len(m.rows)-1 { 80 | m.cursor++ 81 | } 82 | case key.Matches(msg, m.keymap.Diff): 83 | return m, func() tea.Msg { 84 | output, _ := m.context.RunCommandImmediate(jj.OpShow(m.rows[m.cursor].OperationId)) 85 | return common.ShowDiffMsg(output) 86 | } 87 | case key.Matches(msg, m.keymap.OpLog.Restore): 88 | return m, tea.Batch(common.Close, m.context.RunCommand(jj.OpRestore(m.rows[m.cursor].OperationId), common.Refresh)) 89 | } 90 | } 91 | return m, m.updateSelection() 92 | } 93 | 94 | func (m *Model) updateSelection() tea.Cmd { 95 | if m.rows == nil { 96 | return nil 97 | } 98 | return m.context.SetSelectedItem(context.SelectedOperation{OperationId: m.rows[m.cursor].OperationId}) 99 | } 100 | 101 | func (m *Model) View() string { 102 | if m.rows == nil { 103 | return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "loading") 104 | } 105 | 106 | h := m.height 107 | viewHeight := m.viewRange.end - m.viewRange.start 108 | if viewHeight != h { 109 | m.viewRange.end = m.viewRange.start + h 110 | } 111 | var w graph.Renderer 112 | selectedLineStart := -1 113 | selectedLineEnd := -1 114 | for i, row := range m.rows { 115 | isHighlighted := m.cursor == i 116 | if isHighlighted { 117 | selectedLineStart = w.LineCount() 118 | } else { 119 | rowLineCount := len(row.Lines) 120 | if rowLineCount+w.LineCount() < m.viewRange.start { 121 | w.SkipLines(rowLineCount) 122 | continue 123 | } 124 | } 125 | RenderRow(&w, row, isHighlighted, m.width) 126 | if isHighlighted { 127 | selectedLineEnd = w.LineCount() 128 | } 129 | if selectedLineEnd > 0 && w.LineCount() > h && w.LineCount() > m.viewRange.end { 130 | break 131 | } 132 | } 133 | if selectedLineStart <= m.viewRange.start { 134 | m.viewRange.start = selectedLineStart 135 | m.viewRange.end = selectedLineStart + h 136 | } else if selectedLineEnd > m.viewRange.end { 137 | m.viewRange.end = selectedLineEnd 138 | m.viewRange.start = selectedLineEnd - h 139 | } 140 | 141 | content := w.String(m.viewRange.start, m.viewRange.end) 142 | content = lipgloss.PlaceHorizontal(m.width, lipgloss.Left, content) 143 | return normalStyle.MaxWidth(m.width).Render(content) 144 | } 145 | 146 | func (m *Model) load() tea.Cmd { 147 | return func() tea.Msg { 148 | output, err := m.context.RunCommandImmediate(jj.OpLog(config.Current.OpLog.Limit)) 149 | if err != nil { 150 | panic(err) 151 | } 152 | 153 | rows := ParseRows(bytes.NewReader(output)) 154 | return updateOpLogMsg{Rows: rows} 155 | } 156 | } 157 | 158 | func New(context context.AppContext, width int, height int) *Model { 159 | keyMap := context.KeyMap() 160 | v := viewRange{start: 0, end: 0} 161 | return &Model{ 162 | context: context, 163 | keymap: keyMap, 164 | rows: nil, 165 | cursor: 0, 166 | viewRange: &v, 167 | width: width, 168 | height: height, 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /internal/ui/oplog/oplog_parser.go: -------------------------------------------------------------------------------- 1 | package oplog 2 | 3 | import ( 4 | "github.com/idursun/jjui/internal/screen" 5 | "io" 6 | ) 7 | 8 | func ParseRows(reader io.Reader) []Row { 9 | var rows []Row 10 | var row Row 11 | rawSegments := screen.ParseFromReader(reader) 12 | 13 | for segmentedLine := range screen.BreakNewLinesIter(rawSegments) { 14 | rowLine := NewRowLine(segmentedLine) 15 | if opIdIdx := rowLine.FindIdIndex(); opIdIdx != -1 { 16 | if row.OperationId != "" { 17 | rows = append(rows, row) 18 | } 19 | row = Row{OperationId: rowLine.Segments[opIdIdx].Text} 20 | } 21 | row.Lines = append(row.Lines, &rowLine) 22 | } 23 | rows = append(rows, row) 24 | return rows 25 | } 26 | -------------------------------------------------------------------------------- /internal/ui/oplog/renderer.go: -------------------------------------------------------------------------------- 1 | package oplog 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/idursun/jjui/internal/config" 10 | ) 11 | 12 | func RenderRow(r io.Writer, row Row, highlighted bool, width int) { 13 | highlightColor := lipgloss.AdaptiveColor{ 14 | Light: config.Current.UI.HighlightLight, 15 | Dark: config.Current.UI.HighlightDark, 16 | } 17 | highlightSeq := lipgloss.ColorProfile().FromColor(highlightColor).Sequence(true) 18 | 19 | for _, rowLine := range row.Lines { 20 | lw := strings.Builder{} 21 | for _, segment := range rowLine.Segments { 22 | if highlighted { 23 | fmt.Fprint(&lw, segment.WithBackground(highlightSeq).String()) 24 | } else { 25 | fmt.Fprint(&lw, segment.String()) 26 | } 27 | } 28 | line := lw.String() 29 | fmt.Fprint(r, line) 30 | if highlighted { 31 | lineWidth := lipgloss.Width(line) 32 | gap := width - lineWidth 33 | if gap > 0 { 34 | fmt.Fprintf(r, "\033[%sm%s\033[0m", highlightSeq, strings.Repeat(" ", gap)) 35 | } 36 | } 37 | fmt.Fprint(r, "\n") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/ui/oplog/row.go: -------------------------------------------------------------------------------- 1 | package oplog 2 | 3 | import "github.com/idursun/jjui/internal/screen" 4 | 5 | type Row struct { 6 | OperationId string 7 | Lines []*RowLine 8 | } 9 | 10 | type RowLine struct { 11 | Segments []*screen.Segment 12 | } 13 | 14 | func (l *RowLine) FindIdIndex() int { 15 | for i, segment := range l.Segments { 16 | if len(segment.Text) == 12 { 17 | return i 18 | } 19 | } 20 | return -1 21 | } 22 | 23 | func NewRowLine(segments []*screen.Segment) RowLine { 24 | return RowLine{Segments: segments} 25 | } 26 | -------------------------------------------------------------------------------- /internal/ui/preview/preview.go: -------------------------------------------------------------------------------- 1 | package preview 2 | 3 | import ( 4 | "bufio" 5 | "strings" 6 | "time" 7 | 8 | "github.com/charmbracelet/bubbles/help" 9 | "github.com/charmbracelet/bubbles/key" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/idursun/jjui/internal/config" 13 | "github.com/idursun/jjui/internal/jj" 14 | "github.com/idursun/jjui/internal/ui/common" 15 | "github.com/idursun/jjui/internal/ui/context" 16 | ) 17 | 18 | type viewRange struct { 19 | start int 20 | end int 21 | } 22 | 23 | type Model struct { 24 | tag int 25 | viewRange *viewRange 26 | help help.Model 27 | width int 28 | height int 29 | content string 30 | contentLineCount int 31 | context context.AppContext 32 | keyMap config.KeyMappings[key.Binding] 33 | } 34 | 35 | const DebounceTime = 200 * time.Millisecond 36 | 37 | var border = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) 38 | 39 | type refreshPreviewContentMsg struct { 40 | Tag int 41 | } 42 | 43 | type updatePreviewContentMsg struct { 44 | Content string 45 | } 46 | 47 | func (m *Model) Width() int { 48 | return m.width 49 | } 50 | 51 | func (m *Model) Height() int { 52 | return m.height 53 | } 54 | 55 | func (m *Model) SetWidth(w int) { 56 | m.width = w 57 | } 58 | 59 | func (m *Model) SetHeight(h int) { 60 | m.viewRange.end = min(m.viewRange.start+h-3, m.contentLineCount) 61 | m.height = h 62 | } 63 | 64 | func (m *Model) Init() tea.Cmd { 65 | return nil 66 | } 67 | 68 | func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) { 69 | switch msg := msg.(type) { 70 | case updatePreviewContentMsg: 71 | m.content = msg.Content 72 | m.contentLineCount = strings.Count(m.content, "\n") 73 | m.reset() 74 | case common.SelectionChangedMsg, common.RefreshMsg: 75 | m.tag++ 76 | tag := m.tag 77 | return m, tea.Tick(DebounceTime, func(t time.Time) tea.Msg { 78 | return refreshPreviewContentMsg{Tag: tag} 79 | }) 80 | case refreshPreviewContentMsg: 81 | if m.tag == msg.Tag { 82 | switch msg := m.context.SelectedItem().(type) { 83 | case context.SelectedFile: 84 | return m, func() tea.Msg { 85 | output, _ := m.context.RunCommandImmediate(jj.Diff(msg.ChangeId, msg.File, config.Current.Preview.ExtraArgs...)) 86 | return updatePreviewContentMsg{Content: string(output)} 87 | } 88 | case context.SelectedRevision: 89 | return m, func() tea.Msg { 90 | output, _ := m.context.RunCommandImmediate(jj.Show(msg.ChangeId, config.Current.Preview.ExtraArgs...)) 91 | return updatePreviewContentMsg{Content: string(output)} 92 | } 93 | case context.SelectedOperation: 94 | return m, func() tea.Msg { 95 | output, _ := m.context.RunCommandImmediate(jj.OpShow(msg.OperationId)) 96 | return updatePreviewContentMsg{Content: string(output)} 97 | } 98 | } 99 | } 100 | case tea.KeyMsg: 101 | switch { 102 | case key.Matches(msg, m.keyMap.Preview.ScrollDown): 103 | if m.viewRange.end < m.contentLineCount { 104 | m.viewRange.start++ 105 | m.viewRange.end++ 106 | } 107 | case key.Matches(msg, m.keyMap.Preview.ScrollUp): 108 | if m.viewRange.start > 0 { 109 | m.viewRange.start-- 110 | m.viewRange.end-- 111 | } 112 | case key.Matches(msg, m.keyMap.Preview.HalfPageDown): 113 | contentHeight := m.contentLineCount 114 | halfPageSize := m.height / 2 115 | if halfPageSize+m.viewRange.end > contentHeight { 116 | halfPageSize = contentHeight - m.viewRange.end 117 | } 118 | 119 | m.viewRange.start += halfPageSize 120 | m.viewRange.end += halfPageSize 121 | case key.Matches(msg, m.keyMap.Preview.HalfPageUp): 122 | halfPageSize := min(m.height/2, m.viewRange.start) 123 | m.viewRange.start -= halfPageSize 124 | m.viewRange.end -= halfPageSize 125 | } 126 | } 127 | return m, nil 128 | } 129 | 130 | func (m *Model) View() string { 131 | var w strings.Builder 132 | scanner := bufio.NewScanner(strings.NewReader(m.content)) 133 | current := 0 134 | for scanner.Scan() { 135 | line := scanner.Text() 136 | if current >= m.viewRange.start && current <= m.viewRange.end { 137 | if current > m.viewRange.start { 138 | w.WriteString("\n") 139 | } 140 | w.WriteString(lipgloss.NewStyle().MaxWidth(m.width - 2).Render(line)) 141 | } 142 | current++ 143 | if current > m.viewRange.end { 144 | break 145 | } 146 | } 147 | view := lipgloss.Place(m.width-2, m.height-2, 0, 0, w.String()) 148 | return border.Render(view) 149 | } 150 | 151 | func (m *Model) reset() { 152 | m.viewRange.start, m.viewRange.end = 0, m.height 153 | } 154 | 155 | func New(context context.AppContext) Model { 156 | keyMap := context.KeyMap() 157 | return Model{ 158 | viewRange: &viewRange{start: 0, end: 0}, 159 | context: context, 160 | keyMap: keyMap, 161 | help: help.New(), 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /internal/ui/revisions/revisions_test.go: -------------------------------------------------------------------------------- 1 | package revisions 2 | 3 | import ( 4 | "github.com/idursun/jjui/internal/jj" 5 | "github.com/idursun/jjui/internal/ui/graph" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func TestModel_highlightChanges(t *testing.T) { 11 | model := Model{ 12 | rows: []graph.Row{ 13 | {Commit: &jj.Commit{ChangeId: "someother"}}, 14 | {Commit: &jj.Commit{ChangeId: "nyqzpsmt"}}, 15 | }, 16 | output: ` 17 | Absorbed changes into these revisions: 18 | nyqzpsmt 8b1e95e3 change third file 19 | Working copy now at: okrwsxvv 5233c94f (empty) (no description set) 20 | Parent commit : nyqzpsmt 8b1e95e3 change third file 21 | `, err: nil} 22 | _ = model.highlightChanges() 23 | assert.False(t, model.rows[0].IsAffected) 24 | assert.True(t, model.rows[1].IsAffected) 25 | } 26 | -------------------------------------------------------------------------------- /internal/ui/revset/revset.go: -------------------------------------------------------------------------------- 1 | package revset 2 | 3 | import ( 4 | "strings" 5 | "unicode" 6 | 7 | "github.com/charmbracelet/bubbles/help" 8 | "github.com/charmbracelet/bubbles/key" 9 | "github.com/charmbracelet/bubbles/textinput" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/idursun/jjui/internal/ui/common" 13 | ) 14 | 15 | var allowedFunctions = []string{ 16 | "all()", 17 | "mine()", 18 | "empty()", 19 | "trunk()", 20 | "root()", 21 | "description(", 22 | "author(", 23 | "author_date(", 24 | "committer(", 25 | "committer_date(", 26 | "tags(", 27 | "files(", 28 | "latest(", 29 | "bookmarks(", 30 | "conflicts()", 31 | "diff_contains(", 32 | "descendants(", 33 | "parents(", 34 | "ancestors(", 35 | "connected(", 36 | "git_head()", 37 | "git_refs()", 38 | "heads(", 39 | "fork_point(", 40 | "merges(", 41 | "remote_bookmarks(", 42 | "present(", 43 | "coalesce(", 44 | "working_copies()", 45 | "at_operation(", 46 | "builtin_immutable_heads()", 47 | "immutable()", 48 | "immutable_heads()", 49 | "mutable()", 50 | "tracked_remote_bookmarks(", 51 | "untracked_remote_bookmarks(", 52 | "visible_heads()", 53 | "reachable(", 54 | "roots(", 55 | "children(", 56 | } 57 | 58 | var functionSignatureHelp = map[string]string{ 59 | "parents": "parents(x): Same as x-", 60 | "children": "children(x): Same as x+", 61 | "ancestors": "ancestors(x[, depth]): Returns the ancestors of x limited to the given depth", 62 | "descendants": "descendants(x[, depth]): Returns the descendants of x limited to the given depth", 63 | "reachable": "reachable(srcs, domain): All commits reachable from srcs within domain, traversing all parent and child edges", 64 | "connected": "connected(x): Same as x::x. Useful when x includes several commits", 65 | "bookmarks": "bookmarks([pattern]): If pattern is specified, this selects the bookmarks whose name match the given string pattern", 66 | "remote_bookmarks": "remote_bookmarks([bookmark_pattern[, [remote=]remote_pattern]]): All remote bookmarks targets across all remotes", 67 | "tracked_remote_bookmarks": "tracked_remote_bookmarks([bookmark_pattern[, [remote=]remote_pattern]])", 68 | "untracked_remote_bookmarks": "untracked_remote_bookmarks([bookmark_pattern[, [remote=]remote_pattern]])", 69 | "tags": "tags([pattern]): All tag targets. If pattern is specified, this selects the tags whose name match the given string pattern", 70 | "heads": "heads(x): Commits in x that are not ancestors of other commits in x", 71 | "roots": "roots(x): Commits in x that are not descendants of other commits in x", 72 | "latest": "latest(x[, count]): Latest count commits in x", 73 | "fork_point": "fork_point(x): The fork point of all commits in x", 74 | "description": "description(pattern): Commits that have a description matching the given string pattern", 75 | "author": "author(pattern): Commits with the author's name or email matching the given string pattern", 76 | "committer": "committer(pattern): Commits with the committer's name or email matching the given pattern", 77 | "author_date": "author_date(pattern): Commits with author dates matching the specified date pattern.", 78 | "committer_date": "committer_date(pattern): Commits with committer dates matching the specified date pattern", 79 | "files": "files(expression): Commits modifying paths matching the given fileset expression", 80 | "diff_contains": "diff_contains(text[, files]): Commits containing the given text in their diffs", 81 | "present": "present(x): Same as x, but evaluated to none() if any of the commits in x doesn't exist", 82 | "coalesce": "coalesce(revsets...): Commits in the first revset in the list of revsets which does not evaluate to none()", 83 | "at_operation": "at_operation(op, x): Evaluates to x at the specified operation", 84 | } 85 | 86 | type EditRevSetMsg struct { 87 | Clear bool 88 | } 89 | 90 | type Model struct { 91 | Editing bool 92 | Value string 93 | defaultRevSet string 94 | signatureHelp string 95 | textInput textinput.Model 96 | help help.Model 97 | keymap keymap 98 | } 99 | 100 | func (m Model) IsFocused() bool { 101 | return m.Editing 102 | } 103 | 104 | type keymap struct{} 105 | 106 | func (k keymap) ShortHelp() []key.Binding { 107 | return []key.Binding{ 108 | key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "complete")), 109 | key.NewBinding(key.WithKeys("ctrl+n"), key.WithHelp("ctrl+n", "next")), 110 | key.NewBinding(key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "prev")), 111 | key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "accept")), 112 | key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "quit")), 113 | } 114 | } 115 | 116 | func (k keymap) FullHelp() [][]key.Binding { 117 | return [][]key.Binding{k.ShortHelp()} 118 | } 119 | 120 | func New(defaultRevSet string) Model { 121 | ti := textinput.New() 122 | ti.Placeholder = "" 123 | ti.Prompt = "revset: " 124 | ti.PromptStyle = common.DefaultPalette.ChangeId 125 | ti.Cursor.Style = cursorStyle 126 | ti.Focus() 127 | ti.ShowSuggestions = true 128 | ti.SetValue(defaultRevSet) 129 | 130 | h := help.New() 131 | h.Styles.ShortKey = common.DefaultPalette.ChangeId 132 | h.Styles.ShortDesc = common.DefaultPalette.Dimmed 133 | return Model{ 134 | Editing: false, 135 | Value: defaultRevSet, 136 | defaultRevSet: defaultRevSet, 137 | help: h, 138 | keymap: keymap{}, 139 | textInput: ti, 140 | } 141 | } 142 | 143 | func (m Model) Init() tea.Cmd { 144 | return nil 145 | } 146 | 147 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 148 | switch msg := msg.(type) { 149 | case tea.KeyMsg: 150 | if !m.Editing { 151 | return m, nil 152 | } 153 | switch msg.Type { 154 | case tea.KeyCtrlC, tea.KeyEsc: 155 | m.Editing = false 156 | return m, nil 157 | case tea.KeyEnter: 158 | m.Editing = false 159 | m.Value = m.textInput.Value() 160 | if m.Value == "" { 161 | m.Value = m.defaultRevSet 162 | } 163 | return m, tea.Batch(common.Close, UpdateRevSet(m.Value)) 164 | } 165 | case UpdateRevSetMsg: 166 | m.Editing = false 167 | m.Value = string(msg) 168 | case EditRevSetMsg: 169 | m.Editing = true 170 | m.signatureHelp = "" 171 | m.textInput.Focus() 172 | if msg.Clear { 173 | m.textInput.SetValue("") 174 | } 175 | return m, textinput.Blink 176 | } 177 | 178 | value := m.textInput.Value() 179 | var suggestions []string 180 | lastIndex := strings.LastIndexFunc(strings.Trim(value, "() "), func(r rune) bool { 181 | return unicode.IsSpace(r) || r == ',' || r == '|' || r == '&' || r == '~' || r == '(' || r == '.' || r == ':' 182 | }) 183 | 184 | if lastIndex == -1 && value == "" { 185 | suggestions = []string{"@ | mine()"} 186 | } else { 187 | lastFunctionName := value[lastIndex+1:] 188 | m.signatureHelp = "" 189 | helpFunction := strings.Trim(lastFunctionName, "() ") 190 | if _, ok := functionSignatureHelp[helpFunction]; ok { 191 | m.signatureHelp = functionSignatureHelp[helpFunction] 192 | } 193 | if !strings.HasSuffix(value, ")") && lastFunctionName != "" { 194 | for _, f := range allowedFunctions { 195 | if strings.HasPrefix(f, lastFunctionName) { 196 | rest := strings.TrimPrefix(f, lastFunctionName) 197 | suggestions = append(suggestions, value+rest) 198 | } 199 | } 200 | } 201 | } 202 | m.textInput.SetSuggestions(suggestions) 203 | 204 | var cmd tea.Cmd 205 | m.textInput, cmd = m.textInput.Update(msg) 206 | return m, cmd 207 | } 208 | 209 | var ( 210 | promptStyle = common.DefaultPalette.ChangeId.SetString("revset:") 211 | cursorStyle = common.DefaultPalette.EmptyPlaceholder 212 | ) 213 | 214 | func (m Model) View() string { 215 | if m.Editing { 216 | if m.signatureHelp != "" { 217 | return lipgloss.JoinVertical(0, m.textInput.View(), m.signatureHelp) 218 | } 219 | return lipgloss.JoinVertical(0, m.textInput.View(), m.help.View(m.keymap)) 220 | } 221 | 222 | revset := "(default)" 223 | if m.Value != "" { 224 | revset = m.Value 225 | } 226 | 227 | return promptStyle.Render(cursorStyle.Render(revset)) 228 | } 229 | 230 | type UpdateRevSetMsg string 231 | 232 | func UpdateRevSet(revset string) tea.Cmd { 233 | return func() tea.Msg { 234 | return UpdateRevSetMsg(revset) 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /internal/ui/revset/revset_test.go: -------------------------------------------------------------------------------- 1 | package revset 2 | 3 | import ( 4 | "testing" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestSignatureHelp(t *testing.T) { 11 | tests := []struct { 12 | input string 13 | expected string 14 | }{ 15 | {"ancestors", "ancestors("}, 16 | {"ancestors(", "ancestors("}, 17 | } 18 | for _, test := range tests { 19 | t.Run(test.input, func(t *testing.T) { 20 | model := New("") 21 | model.Editing = true 22 | model.textInput.SetValue(test.input) 23 | m, _ := model.Update(tea.KeyLeft) 24 | assert.Contains(t, m.signatureHelp, test.expected) 25 | }) 26 | } 27 | } 28 | 29 | func TestSuggestions(t *testing.T) { 30 | tests := []struct { 31 | input string 32 | expected string 33 | }{ 34 | {"ancestors", "ancestors("}, 35 | {"ancestors(visible_", "ancestors(visible_heads()"}, 36 | {"author", "author("}, 37 | {"author(m", "author(mine()"}, 38 | {"author( m", "author( mine()"}, 39 | {"present(@) | m", "present(@) | mine()"}, 40 | } 41 | for _, test := range tests { 42 | t.Run(test.input, func(t *testing.T) { 43 | model := New("") 44 | model.Editing = true 45 | model.textInput.SetValue(test.input) 46 | m, _ := model.Update(tea.KeyLeft) 47 | suggestions := m.textInput.AvailableSuggestions() 48 | assert.Contains(t, suggestions, test.expected) 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /internal/ui/status/status.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbles/textinput" 6 | "strings" 7 | "time" 8 | 9 | "github.com/charmbracelet/bubbles/help" 10 | "github.com/charmbracelet/bubbles/spinner" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | "github.com/idursun/jjui/internal/ui/common" 14 | "github.com/idursun/jjui/internal/ui/context" 15 | ) 16 | 17 | var cancel = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "dismiss")) 18 | var accept = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "accept")) 19 | 20 | type Model struct { 21 | context context.AppContext 22 | spinner spinner.Model 23 | input textinput.Model 24 | help help.Model 25 | keyMap help.KeyMap 26 | command string 27 | running bool 28 | output string 29 | error error 30 | width int 31 | mode string 32 | editing bool 33 | } 34 | 35 | func (m *Model) IsFocused() bool { 36 | return m.editing 37 | } 38 | 39 | const CommandClearDuration = 3 * time.Second 40 | 41 | type clearMsg string 42 | 43 | func (m *Model) Width() int { 44 | return m.width 45 | } 46 | 47 | func (m *Model) Height() int { 48 | return 1 49 | } 50 | 51 | func (m *Model) SetWidth(w int) { 52 | m.width = w 53 | } 54 | 55 | func (m *Model) SetHeight(int) {} 56 | func (m *Model) Init() tea.Cmd { 57 | return nil 58 | } 59 | 60 | func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) { 61 | km := m.context.KeyMap() 62 | switch msg := msg.(type) { 63 | case clearMsg: 64 | if m.command == string(msg) { 65 | m.command = "" 66 | m.error = nil 67 | m.output = "" 68 | } 69 | return m, nil 70 | case common.CommandRunningMsg: 71 | m.command = string(msg) 72 | m.running = true 73 | return m, m.spinner.Tick 74 | case common.CommandCompletedMsg: 75 | m.running = false 76 | m.output = msg.Output 77 | m.error = msg.Err 78 | if m.error == nil { 79 | commandToBeCleared := m.command 80 | return m, tea.Tick(CommandClearDuration, func(time.Time) tea.Msg { 81 | return clearMsg(commandToBeCleared) 82 | }) 83 | } 84 | return m, nil 85 | case tea.KeyMsg: 86 | switch { 87 | case key.Matches(msg, km.Cancel) && m.error != nil: 88 | m.error = nil 89 | m.output = "" 90 | m.command = "" 91 | m.editing = false 92 | m.mode = "" 93 | case key.Matches(msg, km.Cancel) && m.editing: 94 | m.editing = false 95 | m.input.Reset() 96 | case key.Matches(msg, accept) && m.editing: 97 | m.error = nil 98 | m.output = "" 99 | m.command = "" 100 | m.editing = false 101 | m.mode = "" 102 | query := m.input.Value() 103 | m.input.Reset() 104 | return m, func() tea.Msg { 105 | return common.QuickSearchMsg(query) 106 | } 107 | case key.Matches(msg, km.QuickSearch) && !m.editing: 108 | m.editing = true 109 | m.mode = "search" 110 | return m, m.input.Focus() 111 | default: 112 | if m.editing { 113 | var cmd tea.Cmd 114 | m.input, cmd = m.input.Update(msg) 115 | return m, cmd 116 | } 117 | } 118 | return m, nil 119 | default: 120 | var cmd tea.Cmd 121 | m.spinner, cmd = m.spinner.Update(msg) 122 | return m, cmd 123 | } 124 | } 125 | 126 | func (m *Model) View() string { 127 | commandStatusMark := common.DefaultPalette.Normal.Render(" ") 128 | if m.running { 129 | commandStatusMark = common.DefaultPalette.Normal.Render(m.spinner.View()) 130 | } else if m.error != nil { 131 | commandStatusMark = common.DefaultPalette.StatusError.Render("✗ ") 132 | } else if m.command != "" { 133 | commandStatusMark = common.DefaultPalette.StatusSuccess.Render("✓ ") 134 | } else { 135 | commandStatusMark = m.help.View(m.keyMap) 136 | } 137 | ret := common.DefaultPalette.Normal.Render(m.command) 138 | if m.editing { 139 | commandStatusMark = "" 140 | ret = m.input.View() 141 | } 142 | mode := common.DefaultPalette.StatusMode.Width(10).Render("", m.mode) 143 | ret = lipgloss.JoinHorizontal(lipgloss.Left, mode, " ", commandStatusMark, ret) 144 | if m.error != nil { 145 | k := cancel.Help().Key 146 | return lipgloss.JoinVertical(0, 147 | ret, 148 | common.DefaultPalette.StatusError.Render(strings.Trim(m.output, "\n")), 149 | common.DefaultPalette.ChangeId.Render("press ", k, " to dismiss")) 150 | } 151 | return ret 152 | } 153 | 154 | func (m *Model) SetHelp(keyMap help.KeyMap) { 155 | m.keyMap = keyMap 156 | } 157 | 158 | func (m *Model) SetMode(mode string) { 159 | if m.editing { 160 | m.mode = "search" 161 | } else { 162 | m.mode = mode 163 | } 164 | } 165 | 166 | func New(context context.AppContext) Model { 167 | s := spinner.New() 168 | s.Spinner = spinner.Dot 169 | 170 | h := help.New() 171 | h.Styles.ShortKey = common.DefaultPalette.ChangeId 172 | h.Styles.ShortDesc = common.DefaultPalette.Dimmed 173 | 174 | t := textinput.New() 175 | t.Width = 50 176 | 177 | return Model{ 178 | context: context, 179 | spinner: s, 180 | help: h, 181 | command: "", 182 | running: false, 183 | output: "", 184 | input: t, 185 | keyMap: nil, 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /internal/ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/charmbracelet/bubbles/key" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/idursun/jjui/internal/config" 11 | "github.com/idursun/jjui/internal/jj" 12 | "github.com/idursun/jjui/internal/screen" 13 | "github.com/idursun/jjui/internal/ui/bookmarks" 14 | "github.com/idursun/jjui/internal/ui/common" 15 | "github.com/idursun/jjui/internal/ui/context" 16 | customcommands "github.com/idursun/jjui/internal/ui/custom_commands" 17 | "github.com/idursun/jjui/internal/ui/diff" 18 | "github.com/idursun/jjui/internal/ui/git" 19 | "github.com/idursun/jjui/internal/ui/helppage" 20 | "github.com/idursun/jjui/internal/ui/oplog" 21 | "github.com/idursun/jjui/internal/ui/preview" 22 | "github.com/idursun/jjui/internal/ui/revisions" 23 | "github.com/idursun/jjui/internal/ui/revset" 24 | "github.com/idursun/jjui/internal/ui/status" 25 | "github.com/idursun/jjui/internal/ui/undo" 26 | ) 27 | 28 | type Model struct { 29 | revisions *revisions.Model 30 | oplog *oplog.Model 31 | revsetModel revset.Model 32 | previewModel *preview.Model 33 | previewVisible bool 34 | previewWindowPercentage float64 35 | diff *diff.Model 36 | state common.State 37 | error error 38 | status *status.Model 39 | output string 40 | width int 41 | height int 42 | context context.AppContext 43 | keyMap config.KeyMappings[key.Binding] 44 | stacked tea.Model 45 | } 46 | 47 | type autoRefreshMsg struct{} 48 | 49 | func (m Model) Init() tea.Cmd { 50 | return tea.Sequence(tea.SetWindowTitle(fmt.Sprintf("jjui - %s", m.context.Location())), m.revisions.Init(), m.scheduleAutoRefresh()) 51 | } 52 | 53 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 54 | if _, ok := msg.(common.CloseViewMsg); ok && (m.diff != nil || m.stacked != nil || m.oplog != nil) { 55 | if m.diff != nil { 56 | m.diff = nil 57 | return m, nil 58 | } 59 | if m.stacked != nil { 60 | m.stacked = nil 61 | return m, nil 62 | } 63 | if m.oplog != nil { 64 | m.oplog = nil 65 | return m, common.SelectionChanged 66 | } 67 | m.oplog = nil 68 | return m, nil 69 | } 70 | 71 | var cmd tea.Cmd 72 | if m.diff != nil { 73 | m.diff, cmd = m.diff.Update(msg) 74 | return m, cmd 75 | } 76 | 77 | var cmds []tea.Cmd 78 | 79 | switch msg := msg.(type) { 80 | case tea.KeyMsg: 81 | if m.revsetModel.Editing { 82 | m.revsetModel, cmd = m.revsetModel.Update(msg) 83 | m.state = common.Loading 84 | return m, cmd 85 | } 86 | 87 | if m.status.IsFocused() { 88 | m.status, cmd = m.status.Update(msg) 89 | return m, cmd 90 | } 91 | 92 | if m.revisions.IsFocused() { 93 | m.revisions, cmd = m.revisions.Update(msg) 94 | return m, cmd 95 | } 96 | 97 | if m.stacked != nil { 98 | m.stacked, cmd = m.stacked.Update(msg) 99 | return m, cmd 100 | } 101 | 102 | switch { 103 | case key.Matches(msg, m.keyMap.Cancel) && m.state == common.Error: 104 | m.state = common.Ready 105 | m.error = nil 106 | case key.Matches(msg, m.keyMap.Cancel) && m.stacked != nil: 107 | m.stacked = nil 108 | case key.Matches(msg, m.keyMap.OpLog.Mode): 109 | m.oplog = oplog.New(m.context, m.width, m.height) 110 | return m, m.oplog.Init() 111 | case key.Matches(msg, m.keyMap.Revset) && m.revisions.InNormalMode(): 112 | m.revsetModel, _ = m.revsetModel.Update(revset.EditRevSetMsg{Clear: m.state != common.Error}) 113 | return m, nil 114 | case key.Matches(msg, m.keyMap.Git.Mode) && m.revisions.InNormalMode(): 115 | m.stacked = git.NewModel(m.context, m.revisions.SelectedRevision(), m.width, m.height) 116 | case key.Matches(msg, m.keyMap.Undo) && m.revisions.InNormalMode(): 117 | m.stacked = undo.NewModel(m.context) 118 | cmds = append(cmds, m.stacked.Init()) 119 | case key.Matches(msg, m.keyMap.Bookmark.Mode) && m.revisions.InNormalMode(): 120 | changeIds := m.revisions.GetCommitIds() 121 | m.stacked = bookmarks.NewModel(m.context, m.revisions.SelectedRevision(), changeIds, m.width, m.height) 122 | cmds = append(cmds, m.stacked.Init()) 123 | case key.Matches(msg, m.keyMap.Help): 124 | cmds = append(cmds, common.ToggleHelp) 125 | return m, tea.Batch(cmds...) 126 | case key.Matches(msg, m.keyMap.Preview.Mode): 127 | m.previewVisible = !m.previewVisible 128 | cmds = append(cmds, common.SelectionChanged) 129 | return m, tea.Batch(cmds...) 130 | case key.Matches(msg, m.keyMap.Preview.Expand): 131 | m.previewWindowPercentage += config.Current.Preview.WidthIncrementPercentage 132 | case key.Matches(msg, m.keyMap.Preview.Shrink): 133 | m.previewWindowPercentage -= config.Current.Preview.WidthIncrementPercentage 134 | case key.Matches(msg, m.keyMap.CustomCommands): 135 | m.stacked = customcommands.NewModel(m.context, m.width, m.height) 136 | cmds = append(cmds, m.stacked.Init()) 137 | case key.Matches(msg, m.keyMap.QuickSearch) && m.oplog != nil: 138 | //HACK: prevents quick search from activating in op log view 139 | return m, nil 140 | default: 141 | if matched := customcommands.Matches(msg); matched != nil { 142 | command := *matched 143 | cmd = command.Prepare(m.context).Invoke(m.context) 144 | cmds = append(cmds, cmd) 145 | return m, cmd 146 | } 147 | } 148 | case common.ToggleHelpMsg: 149 | if m.stacked == nil { 150 | m.stacked = helppage.New(m.context) 151 | if p, ok := m.stacked.(common.Sizable); ok { 152 | p.SetHeight(m.height - 2) 153 | p.SetWidth(m.width) 154 | } 155 | } else { 156 | m.stacked = nil 157 | } 158 | return m, nil 159 | case common.ShowDiffMsg: 160 | m.diff = diff.New(m.context, string(msg), m.width, m.height) 161 | return m, m.diff.Init() 162 | case common.CommandCompletedMsg: 163 | m.output = msg.Output 164 | case common.UpdateRevisionsFailedMsg: 165 | m.state = common.Error 166 | m.output = msg.Output 167 | m.error = msg.Err 168 | case autoRefreshMsg: 169 | return m, tea.Batch(m.scheduleAutoRefresh(), common.Refresh) 170 | case tea.WindowSizeMsg: 171 | m.width = msg.Width 172 | m.height = msg.Height 173 | if s, ok := m.stacked.(common.Sizable); ok { 174 | s.SetWidth(m.width - 2) 175 | s.SetHeight(m.height - 2) 176 | } 177 | m.status.SetWidth(m.width) 178 | } 179 | 180 | m.revsetModel, cmd = m.revsetModel.Update(msg) 181 | cmds = append(cmds, cmd) 182 | 183 | m.status, cmd = m.status.Update(msg) 184 | cmds = append(cmds, cmd) 185 | 186 | if m.stacked != nil { 187 | m.stacked, cmd = m.stacked.Update(msg) 188 | cmds = append(cmds, cmd) 189 | } 190 | 191 | if m.oplog != nil { 192 | m.oplog, cmd = m.oplog.Update(msg) 193 | cmds = append(cmds, cmd) 194 | } else { 195 | m.revisions, cmd = m.revisions.Update(msg) 196 | cmds = append(cmds, cmd) 197 | } 198 | 199 | if m.previewVisible { 200 | m.previewModel, cmd = m.previewModel.Update(msg) 201 | cmds = append(cmds, cmd) 202 | } 203 | 204 | return m, tea.Batch(cmds...) 205 | } 206 | 207 | func (m Model) View() string { 208 | if m.diff != nil { 209 | m.status.SetMode("diff") 210 | m.status.SetHelp(m.diff) 211 | footer := m.status.View() 212 | footerHeight := lipgloss.Height(footer) 213 | m.diff.SetHeight(m.height - footerHeight) 214 | return lipgloss.JoinVertical(0, m.diff.View(), footer) 215 | } 216 | 217 | topView := m.revsetModel.View() 218 | if m.state == common.Error { 219 | topView += fmt.Sprintf("\n%s\n", m.output) 220 | } 221 | topViewHeight := lipgloss.Height(topView) 222 | 223 | if m.oplog != nil { 224 | m.status.SetMode("oplog") 225 | m.status.SetHelp(m.oplog) 226 | } else { 227 | m.status.SetHelp(m.revisions) 228 | m.status.SetMode(m.revisions.CurrentOperation().Name()) 229 | } 230 | 231 | footer := m.status.View() 232 | footerHeight := lipgloss.Height(footer) 233 | 234 | leftView := m.renderLeftView(footerHeight, topViewHeight) 235 | 236 | previewView := "" 237 | if m.previewVisible { 238 | m.previewModel.SetWidth(m.width - lipgloss.Width(leftView)) 239 | m.previewModel.SetHeight(m.height - footerHeight - topViewHeight) 240 | previewView = m.previewModel.View() 241 | } 242 | 243 | centerView := lipgloss.JoinHorizontal(lipgloss.Left, leftView, previewView) 244 | 245 | if m.stacked != nil { 246 | stackedView := m.stacked.View() 247 | w, h := lipgloss.Size(stackedView) 248 | sx := (m.width - w) / 2 249 | sy := (m.height - h) / 2 250 | centerView = screen.Stacked(centerView, stackedView, sx, sy) 251 | } 252 | return lipgloss.JoinVertical(0, topView, centerView, footer) 253 | } 254 | 255 | func (m Model) renderLeftView(footerHeight int, topViewHeight int) string { 256 | leftView := "" 257 | w := m.width 258 | 259 | if m.previewVisible { 260 | w = m.width - int(float64(m.width)*(m.previewWindowPercentage/100.0)) 261 | } 262 | 263 | if m.oplog != nil { 264 | m.oplog.SetWidth(w) 265 | m.oplog.SetHeight(m.height - footerHeight - topViewHeight) 266 | leftView = m.oplog.View() 267 | } else { 268 | m.revisions.SetWidth(w) 269 | m.revisions.SetHeight(m.height - footerHeight - topViewHeight) 270 | leftView = m.revisions.View() 271 | } 272 | return leftView 273 | } 274 | 275 | func (m Model) scheduleAutoRefresh() tea.Cmd { 276 | interval := config.Current.UI.AutoRefreshInterval 277 | if interval > 0 { 278 | return tea.Tick(time.Duration(interval)*time.Second, func(time.Time) tea.Msg { 279 | return autoRefreshMsg{} 280 | }) 281 | } 282 | return nil 283 | } 284 | 285 | func New(c context.AppContext, initialRevset string) tea.Model { 286 | if initialRevset == "" { 287 | defaultRevset, _ := c.RunCommandImmediate(jj.ConfigGet("revsets.log")) 288 | initialRevset = string(defaultRevset) 289 | } 290 | revisionsModel := revisions.New(c, initialRevset) 291 | previewModel := preview.New(c) 292 | statusModel := status.New(c) 293 | return Model{ 294 | context: c, 295 | keyMap: c.KeyMap(), 296 | state: common.Loading, 297 | revisions: &revisionsModel, 298 | previewModel: &previewModel, 299 | previewVisible: config.Current.Preview.ShowAtStart, 300 | previewWindowPercentage: config.Current.Preview.WidthPercentage, 301 | status: &statusModel, 302 | revsetModel: revset.New(initialRevset), 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /internal/ui/undo/undo.go: -------------------------------------------------------------------------------- 1 | package undo 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/key" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/idursun/jjui/internal/jj" 10 | "github.com/idursun/jjui/internal/ui/common" 11 | "github.com/idursun/jjui/internal/ui/confirmation" 12 | "github.com/idursun/jjui/internal/ui/context" 13 | ) 14 | 15 | type Model struct { 16 | confirmation tea.Model 17 | } 18 | 19 | func (m Model) Init() tea.Cmd { 20 | return m.confirmation.Init() 21 | } 22 | 23 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 24 | var cmd tea.Cmd 25 | m.confirmation, cmd = m.confirmation.Update(msg) 26 | return m, cmd 27 | } 28 | 29 | func (m Model) View() string { 30 | return m.confirmation.View() 31 | } 32 | 33 | var style = lipgloss.NewStyle().Width(80) 34 | 35 | func NewModel(context context.AppContext) Model { 36 | output, _ := context.RunCommandImmediate(jj.OpLog(1)) 37 | message := fmt.Sprintf("%s\n\nAre you sure you want to undo last change?", style.Render(string(output))) 38 | model := confirmation.New(message) 39 | model.SetBorderStyle(lipgloss.NewStyle().Border(lipgloss.NormalBorder()).Padding(2)) 40 | model.AddOption("Yes", context.RunCommand(jj.Undo(), common.Refresh, common.Close), key.NewBinding(key.WithKeys("y"))) 41 | model.AddOption("No", common.Close, key.NewBinding(key.WithKeys("n", "esc"))) 42 | return Model{ 43 | confirmation: &model, 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/ui/undo/undo_test.go: -------------------------------------------------------------------------------- 1 | package undo 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | "time" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/x/exp/teatest" 10 | "github.com/idursun/jjui/internal/jj" 11 | "github.com/idursun/jjui/test" 12 | ) 13 | 14 | func TestConfirm(t *testing.T) { 15 | c := test.NewTestContext(t) 16 | c.Expect(jj.OpLog(1)) 17 | c.Expect(jj.Undo()) 18 | defer c.Verify() 19 | 20 | model := NewModel(c) 21 | tm := teatest.NewTestModel(t, test.NewShell(model)) 22 | teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 23 | return bytes.Contains(bts, []byte("undo")) 24 | }) 25 | tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) 26 | tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second)) 27 | } 28 | 29 | func TestCancel(t *testing.T) { 30 | c := test.NewTestContext(t) 31 | c.Expect(jj.OpLog(1)) 32 | defer c.Verify() 33 | 34 | tm := teatest.NewTestModel(t, test.NewShell(NewModel(c))) 35 | teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 36 | return bytes.Contains(bts, []byte("undo")) 37 | }) 38 | tm.Send(tea.KeyMsg{Type: tea.KeyEsc}) 39 | teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 40 | return bytes.Contains(bts, []byte("closed")) 41 | }) 42 | tm.Quit() 43 | tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second)) 44 | } 45 | -------------------------------------------------------------------------------- /nix/default.nix: -------------------------------------------------------------------------------- 1 | { inputs, ... }: 2 | { 3 | systems = import inputs.systems; 4 | 5 | flake.overlays.default = final: _prev: { 6 | jjui = inputs.self.packages.${final.system}.jjui; 7 | }; 8 | 9 | perSystem = 10 | { pkgs, ... }: 11 | let 12 | jjui = pkgs.buildGoModule rec { 13 | name = "jjui"; 14 | src = ./..; 15 | vendorHash = builtins.readFile ./vendor-hash; 16 | meta.mainProgram = "jjui"; 17 | }; 18 | 19 | in 20 | { 21 | packages.default = jjui; 22 | packages.jjui = jjui; 23 | 24 | devShells.default = pkgs.mkShell { 25 | nativeBuildInputs = [ 26 | pkgs.go 27 | pkgs.gopls 28 | ]; 29 | }; 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /nix/vendor-hash: -------------------------------------------------------------------------------- 1 | sha256-YlOK+NvyH/3uvvFcCZixv2+Y2m26TP8+ohUSdl3ppro= 2 | -------------------------------------------------------------------------------- /test/log_builder.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/muesli/termenv" 8 | "strings" 9 | ) 10 | 11 | type part int 12 | 13 | const ( 14 | normal = iota 15 | id 16 | author 17 | bookmark 18 | ) 19 | 20 | var styles = map[part]lipgloss.Style{ 21 | normal: lipgloss.NewStyle(), 22 | id: lipgloss.NewStyle().Foreground(lipgloss.Color("1")), 23 | author: lipgloss.NewStyle().Foreground(lipgloss.Color("2")), 24 | bookmark: lipgloss.NewStyle().Foreground(lipgloss.Color("3")), 25 | } 26 | 27 | type LogBuilder struct { 28 | w strings.Builder 29 | } 30 | 31 | func (l *LogBuilder) String() string { 32 | return l.w.String() 33 | } 34 | 35 | func (l *LogBuilder) Write(line string) { 36 | lipgloss.SetColorProfile(termenv.ANSI) 37 | scanner := bufio.NewScanner(strings.NewReader(line)) 38 | scanner.Split(bufio.ScanWords) 39 | for scanner.Scan() { 40 | text := scanner.Text() 41 | if strings.HasPrefix(text, "short_id=") { 42 | text = strings.TrimPrefix(text, "short_id=") 43 | l.ShortId(text) 44 | continue 45 | } 46 | if strings.HasPrefix(text, "id=") { 47 | text = strings.TrimPrefix(text, "id=") 48 | l.Id(text[:1], text[1:]) 49 | continue 50 | } 51 | if strings.HasPrefix(text, "author=") { 52 | l.Author(strings.TrimPrefix(text, "author=")) 53 | continue 54 | } 55 | if strings.HasPrefix(text, "bookmarks=") { 56 | text = strings.TrimPrefix(text, "bookmarks=") 57 | values := strings.Split(text, ",") 58 | l.Bookmarks(strings.Join(values, " ")) 59 | continue 60 | } 61 | l.Append(text) 62 | } 63 | l.w.WriteString("\n") 64 | } 65 | 66 | func (l *LogBuilder) Append(value string) { 67 | fmt.Fprintf(&l.w, "%s ", styles[normal].Render(value)) 68 | } 69 | 70 | func (l *LogBuilder) ShortId(sid string) { 71 | fmt.Fprintf(&l.w, " %s ", styles[id].Render(sid)) 72 | } 73 | 74 | func (l *LogBuilder) Id(short string, rest string) { 75 | fmt.Fprintf(&l.w, " %s%s ", styles[id].Render(short), styles[id].Render(rest)) 76 | } 77 | 78 | func (l *LogBuilder) Author(value string) { 79 | fmt.Fprintf(&l.w, " %s ", styles[author].Render(value)) 80 | } 81 | 82 | func (l *LogBuilder) Bookmarks(value string) { 83 | fmt.Fprintf(&l.w, " %s ", styles[bookmark].Render(value)) 84 | } 85 | -------------------------------------------------------------------------------- /test/log_parser_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "github.com/idursun/jjui/internal/ui/graph" 5 | "github.com/stretchr/testify/assert" 6 | "os" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestParser_Parse(t *testing.T) { 12 | file, _ := os.Open("testdata/output.log") 13 | rows := graph.ParseRows(file) 14 | assert.Len(t, rows, 11) 15 | } 16 | 17 | func TestParser_Parse_NoCommitId(t *testing.T) { 18 | file, _ := os.Open("testdata/no-commit-id.log") 19 | rows := graph.ParseRows(file) 20 | assert.Len(t, rows, 1) 21 | } 22 | 23 | func TestParser_Parse_ShortId(t *testing.T) { 24 | file, _ := os.Open("testdata/short-id.log") 25 | rows := graph.ParseRows(file) 26 | assert.Len(t, rows, 2) 27 | assert.Equal(t, "X", rows[0].Commit.ChangeId) 28 | assert.Equal(t, "E", rows[0].Commit.CommitId) 29 | assert.Equal(t, "T", rows[1].Commit.ChangeId) 30 | assert.Equal(t, "79", rows[1].Commit.CommitId) 31 | } 32 | 33 | func TestParser_Parse_SingleLineWithDescription(t *testing.T) { 34 | file, _ := os.Open("testdata/single-line-with-description.log") 35 | rows := graph.ParseRows(file) 36 | assert.Len(t, rows, 1) 37 | assert.Equal(t, "x", rows[0].Commit.ChangeId) 38 | assert.Equal(t, "4", rows[0].Commit.CommitId) 39 | } 40 | 41 | func TestParser_Parse_CommitIdOnASeparateLine(t *testing.T) { 42 | file, _ := os.Open("testdata/commit-id.log") 43 | rows := graph.ParseRows(file) 44 | assert.Len(t, rows, 1) 45 | assert.Equal(t, "o", rows[0].Commit.ChangeId) 46 | assert.Equal(t, "5", rows[0].Commit.CommitId) 47 | } 48 | 49 | func TestParser_Parse_ConflictedLongIds(t *testing.T) { 50 | file, _ := os.Open("testdata/conflicted-change-id.log") 51 | rows := graph.ParseRows(file) 52 | assert.Len(t, rows, 3) 53 | assert.Equal(t, "p??", rows[0].Commit.ChangeId) 54 | assert.Equal(t, "qusvoztl??", rows[1].Commit.ChangeId) 55 | assert.Equal(t, "tyoqvzlm??", rows[2].Commit.ChangeId) 56 | } 57 | 58 | func TestParser_Parse_Disconnected(t *testing.T) { 59 | var lb LogBuilder 60 | lb.Write("* id=abcde author=some@author id=xyrq") 61 | lb.Write("│ some documentation") 62 | lb.Write("~\n") 63 | lb.Write("* id=abcde author=some@author id=xyrq") 64 | lb.Write("│ another commit") 65 | lb.Write("~\n") 66 | rows := graph.ParseRows(strings.NewReader(lb.String())) 67 | assert.Len(t, rows, 2) 68 | } 69 | 70 | func TestParser_Parse_Extend(t *testing.T) { 71 | var lb LogBuilder 72 | lb.Write("* id=abcde author=some@author id=xyrq") 73 | lb.Write("│ some documentation") 74 | 75 | rows := graph.ParseRows(strings.NewReader(lb.String())) 76 | assert.Len(t, rows, 1) 77 | row := rows[0] 78 | 79 | extended := row.Lines[1].Extend(row.Indent) 80 | assert.Len(t, extended.Segments, 1) 81 | } 82 | 83 | func TestParser_Parse_WorkingCopy(t *testing.T) { 84 | var lb LogBuilder 85 | lb.Write("* id=abcde author=some@author id=xyrq") 86 | lb.Write("│ some documentation") 87 | lb.Write("@ id=kdys author=some@author id=12cd") 88 | lb.Write("│ some documentation") 89 | 90 | rows := graph.ParseRows(strings.NewReader(lb.String())) 91 | assert.Len(t, rows, 2) 92 | row := rows[1] 93 | 94 | assert.True(t, row.Commit.IsWorkingCopy) 95 | } 96 | -------------------------------------------------------------------------------- /test/operation_host.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/idursun/jjui/internal/ui/common" 6 | "github.com/idursun/jjui/internal/ui/confirmation" 7 | "github.com/idursun/jjui/internal/ui/operations" 8 | ) 9 | 10 | type OperationHost struct { 11 | closed bool 12 | Operation operations.Operation 13 | } 14 | 15 | func (o OperationHost) Init() tea.Cmd { 16 | return nil 17 | } 18 | 19 | func (o OperationHost) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 20 | switch msg := msg.(type) { 21 | case common.CloseViewMsg, confirmation.CloseMsg: 22 | o.closed = true 23 | return o, tea.Quit 24 | case tea.KeyMsg: 25 | if op, ok := o.Operation.(operations.HandleKey); ok { 26 | cmd := op.HandleKey(msg) 27 | return o, cmd 28 | } 29 | } 30 | if op, ok := o.Operation.(operations.OperationWithOverlay); ok { 31 | var cmd tea.Cmd 32 | o.Operation, cmd = op.Update(msg) 33 | return o, cmd 34 | } 35 | return o, nil 36 | } 37 | 38 | func (o OperationHost) View() string { 39 | if o.closed { 40 | return "closed" 41 | } 42 | return o.Operation.Render() 43 | } 44 | -------------------------------------------------------------------------------- /test/shell.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/idursun/jjui/internal/ui/common" 6 | "github.com/idursun/jjui/internal/ui/confirmation" 7 | ) 8 | 9 | type model struct { 10 | closed bool 11 | embeddedModel tea.Model 12 | } 13 | 14 | func (m model) Init() tea.Cmd { 15 | return m.embeddedModel.Init() 16 | } 17 | 18 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 19 | switch msg := msg.(type) { 20 | case common.CloseViewMsg, confirmation.CloseMsg: 21 | m.closed = true 22 | return m, tea.Quit 23 | default: 24 | var cmd tea.Cmd 25 | m.embeddedModel, cmd = m.embeddedModel.Update(msg) 26 | return m, cmd 27 | } 28 | } 29 | 30 | func (m model) View() string { 31 | if m.closed { 32 | return "closed" 33 | } 34 | return m.embeddedModel.View() 35 | } 36 | 37 | func NewShell(embeddedModel tea.Model) tea.Model { 38 | return model{ 39 | embeddedModel: embeddedModel, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/test_context.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "bytes" 5 | "github.com/charmbracelet/bubbles/key" 6 | "github.com/idursun/jjui/internal/config" 7 | "github.com/idursun/jjui/internal/ui/context" 8 | "io" 9 | "testing" 10 | 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/idursun/jjui/internal/ui/common" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | type ExpectedCommand struct { 17 | args []string 18 | output []byte 19 | called bool 20 | } 21 | 22 | func (e *ExpectedCommand) SetOutput(output []byte) *ExpectedCommand { 23 | e.output = output 24 | return e 25 | } 26 | 27 | type TestContext struct { 28 | *testing.T 29 | selectedItem context.SelectedItem 30 | expectations map[string][]*ExpectedCommand 31 | } 32 | 33 | func (t *TestContext) Location() string { 34 | return "test" 35 | } 36 | 37 | func (t *TestContext) KeyMap() config.KeyMappings[key.Binding] { 38 | return config.Convert(config.DefaultKeyMappings) 39 | } 40 | 41 | func (t *TestContext) SelectedItem() context.SelectedItem { 42 | return t.selectedItem 43 | } 44 | 45 | func (t *TestContext) SetSelectedItem(item context.SelectedItem) tea.Cmd { 46 | t.selectedItem = item 47 | return nil 48 | } 49 | 50 | func (t *TestContext) RunCommandImmediate(args []string) ([]byte, error) { 51 | subCommand := args[0] 52 | if _, ok := t.expectations[subCommand]; !ok { 53 | assert.Fail(t, "unexpected command", subCommand) 54 | } 55 | expectations := t.expectations[subCommand] 56 | if len(expectations) == 0 { 57 | assert.Fail(t, "unexpected command", subCommand) 58 | } 59 | for _, e := range expectations { 60 | if assert.Equal(t.T, e.args, args) { 61 | e.called = true 62 | return e.output, nil 63 | } 64 | } 65 | assert.Fail(t, "unexpected command", subCommand) 66 | return nil, nil 67 | } 68 | 69 | func (t *TestContext) RunCommandStreaming(args []string) (io.Reader, error) { 70 | reader, err := t.RunCommandImmediate(args) 71 | return bytes.NewBuffer(reader), err 72 | } 73 | 74 | func (t *TestContext) RunCommand(args []string, continuations ...tea.Cmd) tea.Cmd { 75 | cmds := make([]tea.Cmd, 0) 76 | cmds = append(cmds, func() tea.Msg { 77 | _, _ = t.RunCommandImmediate(args) 78 | return common.CommandCompletedMsg{} 79 | }) 80 | cmds = append(cmds, continuations...) 81 | return tea.Batch(cmds...) 82 | } 83 | 84 | func (t *TestContext) RunInteractiveCommand(args []string, continuation tea.Cmd) tea.Cmd { 85 | return t.RunCommand(args, continuation) 86 | } 87 | 88 | func (t *TestContext) Expect(args []string) *ExpectedCommand { 89 | subCommand := args[0] 90 | if _, ok := t.expectations[subCommand]; !ok { 91 | t.expectations[subCommand] = make([]*ExpectedCommand, 0) 92 | } 93 | e := &ExpectedCommand{ 94 | args: args, 95 | } 96 | t.expectations[subCommand] = append(t.expectations[subCommand], e) 97 | return e 98 | } 99 | 100 | func (t *TestContext) Verify() { 101 | for subCommand, expectations := range t.expectations { 102 | for _, e := range expectations { 103 | if !e.called { 104 | assert.Fail(t, "expected command not called", subCommand) 105 | } 106 | } 107 | } 108 | } 109 | 110 | func NewTestContext(t *testing.T) *TestContext { 111 | return &TestContext{ 112 | T: t, 113 | expectations: make(map[string][]*ExpectedCommand), 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /test/testdata/commit-id.log: -------------------------------------------------------------------------------- 1 | ○ o ibrahim@dursun.cc 2025-04-19 21:11:58 and-a-little-bit-more feature 2 | │ some-very-long-branch-name-goes-here 5 3 | ~ add feature1 with a very long bookmark name 4 | -------------------------------------------------------------------------------- /test/testdata/conflicted-change-id.log: -------------------------------------------------------------------------------- 1 | ○ p?? ibrahim@dursun.cc 2025-02-26 13:49:30 70 2 | │ (empty) update ARCHITECTURE documentation 3 | ~ 4 | ○ qusvoztl?? ibrahim@dursun.cc 2025-02-26 13:49:30 705c4ddf 5 | │ (empty) update ARCHITECTURE documentation 6 | ~ 7 | ○ tyoqvzlm?? ibrahim@dursun.cc 2025-02-27 23:19:21 705c4ddf 8 | │ (empty) something gone wrong 9 | ~ 10 | -------------------------------------------------------------------------------- /test/testdata/no-commit-id.log: -------------------------------------------------------------------------------- 1 | ◆ rxolorlu yuya@tcha.org 2024-09-27 23:49:28 5c52b4ec 2 | │ diff: omit construction of count-to-words map for right-side histogram 3 | ~ 4 | -------------------------------------------------------------------------------- /test/testdata/output.log: -------------------------------------------------------------------------------- 1 | @ vzvklxpm ibrahim@dursun.cc 2025-03-15 23:36:03 exp/log-parser 19a58c40 2 | │ refactor: add notemplate_parser 3 | ◆ uzlqpksu ibrahim@dursun.cc 2025-03-15 22:42:14 main 90d3c3f5 4 | │ feat(absorb): support `jj absorb` 5 | ~ (elided revisions) 6 | │ ○ owvurkmm ibrahim@dursun.cc 2025-03-15 12:16:34 b3d83572 7 | │ │ (no description set) 8 | │ ○ mxnulzmt ibrahim@dursun.cc 2025-03-14 21:07:40 31787156 9 | │ │ refactor: remove selection tracking code 10 | │ ○ tyypswxl ibrahim@dursun.cc 2025-03-14 20:30:08 1a879824 11 | ├─╯ refactor: make operations more like tea.Model 12 | │ ○ vzwqtpsz ibrahim@dursun.cc 2025-03-14 11:46:48 exp/selection e491cf2e 13 | ├─╯ feat(rebase): show selection of source revisions 14 | ◆ tounwvkw ibrahim@dursun.cc 2025-03-14 11:46:10 v0.7 c4ee4a98 15 | │ feat(bookmarks): show bookmarks of the selected revision at top 16 | ~ (elided revisions) 17 | │ ○ wvlqrkul ibrahim@dursun.cc 2025-03-13 22:02:07 17acaec8 18 | ├─╯ test: add revisions test 19 | ◆ rsvynkpp ibrahim@dursun.cc 2025-03-13 22:01:38 20e7d399 20 | │ refactor: add toggle selection key to default keymap 21 | ~ (elided revisions) 22 | │ ○ vwlvxmux ibrahim@dursun.cc 2025-01-13 21:43:48 0443b902 23 | ├─╯ build: add profiler 24 | ◆ wxwwwkyl ibrahim@dursun.cc 2025-01-13 21:43:25 b2b4e9be 25 | │ refactor: return prepared command instead of command output 26 | ~ 27 | -------------------------------------------------------------------------------- /test/testdata/short-id.log: -------------------------------------------------------------------------------- 1 | ○ X ibrahim@dursun.cc 2025-04-05 22:08:30 main* E 2 | │ feat(details): bind `*` sets revset to changes of selected file 3 | ◆ T ibrahim@dursun.cc 2025-04-05 10:04:34 main@origin 79 4 | │ fix(preview): fix extraneous empty line at the bottom 5 | ~ 6 | -------------------------------------------------------------------------------- /test/testdata/single-line-with-description.log: -------------------------------------------------------------------------------- 1 | ○ x show help for diff view in status bar 4 2 | │ 3 | ~ 4 | --------------------------------------------------------------------------------