diff --git a/.gitfetch-worktrees/.parent-hints b/.gitfetch-worktrees/.parent-hints new file mode 100644 index 0000000..f15fb42 --- /dev/null +++ b/.gitfetch-worktrees/.parent-hints @@ -0,0 +1,8 @@ +cladueinit tui +feat1 tui +feat2 tui +loadhooks tui +staging tui +stash tui +test tui +uibugs tui diff --git a/.github/workflows/update-aur.yml b/.github/workflows/update-aur.yml new file mode 100644 index 0000000..ccf08e2 --- /dev/null +++ b/.github/workflows/update-aur.yml @@ -0,0 +1,46 @@ +name: Update AUR Package + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + update-aur: + runs-on: ubuntu-latest + + steps: + - name: Checkout main repo + uses: actions/checkout@v4 + with: + repository: Matars/gitfetch + path: gitfetch + + - name: Get version from pyproject.toml + id: get_version + run: | + VERSION=$(grep '^version =' gitfetch/pyproject.toml | sed 's/version = "\(.*\)"/\1/') + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Checkout AUR repo + uses: actions/checkout@v4 + with: + repository: Matars/aur-gitfetch + token: ${{ secrets.AUR_TAP_TOKEN }} + path: aur-repo + + - name: Update PKGBUILD + run: | + cd aur-repo + VERSION=${{ steps.get_version.outputs.version }} + sed -i "s/^pkgver=.*/pkgver=$VERSION/" PKGBUILD + sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD + + - name: Commit and push + run: | + cd aur-repo + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + git add PKGBUILD + git commit -m "Update gitfetch to v${{ steps.get_version.outputs.version }}" + git push diff --git a/.gitignore b/.gitignore index 13c67aa..60b74ef 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,15 @@ __pycache__/ *.egg-info +codeql/ +specs/ + +prompt.md # env venv/ .venv env/ -.env/ \ No newline at end of file +.env/ +target/ + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..afad90f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +gitfetch is a neofetch-style CLI tool that displays statistics from GitHub, GitLab, Gitea, Forgejo, Codeberg, and Sourcehut in a terminal interface. It features ASCII art contribution graphs, streak tracking, and extensive customization options. + +The project has two components: +- **Python CLI** (`src/gitfetch/`): Main application with caching, multi-provider support, and rich terminal display +- **Rust TUI** (`rust/gitfetch-tui/`): Experimental ratatui-based interface for daily git workflow (stage/commit/push) + +## Common Commands + +```bash +# Install for development +make dev + +# Run tests +make test +python3 -m pytest tests/ -v + +# Run single test file +python3 -m pytest tests/test_cache.py -v + +# Run single test +python3 -m pytest tests/test_cli.py::test_function_name -v + +# Clean build artifacts +make clean + +# Run the CLI locally +gitfetch [username] +gitfetch --tui # Launch experimental Rust TUI + +# Run Rust TUI from source +cargo run --manifest-path rust/gitfetch-tui/Cargo.toml +``` + +## Architecture + +### Python CLI Structure + +``` +src/gitfetch/ +├── cli.py # Entry point, argument parsing, provider initialization +├── fetcher.py # Provider fetchers (GitHubFetcher, GitLabFetcher, GiteaFetcher, SourcehutFetcher) +├── display.py # Terminal rendering, contribution graph visualization, layout management +├── cache.py # SQLite-based caching with stale-while-revalidate pattern +├── config.py # ConfigManager: ~/.config/gitfetch/gitfetch.conf management +├── providers.py # ProviderConfig dataclass, env var mappings, default URLs +├── calculations.py # Streak/contribution calculations +├── constants.py # Magic values (timeouts, thresholds) +├── text_patterns.py # ASCII patterns for --text and --shape modes +``` + +### Key Design Patterns + +**Provider Abstraction**: `BaseFetcher` ABC defines the interface; each provider (GitHub, GitLab, Gitea, Sourcehut) implements `fetch_user_data()`, `fetch_user_stats()`, and `get_authenticated_user()`. GitHub/GitLab use their respective CLIs (`gh`, `glab`) for authentication. + +**Caching**: SQLite-based with background refresh. If cache is stale but exists, displays immediately and spawns a background subprocess to refresh. + +**Display Layout**: Adaptive layout system that chooses between "full", "compact", and "minimal" based on terminal dimensions. Contribution graph renders week columns with configurable intensity colors. + +### Rust TUI + +Single-file application (`rust/gitfetch-tui/src/main.rs`) using ratatui/crossterm. Features: +- Real-time file change monitoring +- Git staging/unstaging +- Commit modal with message input +- Push operations +- Worktree management with embedded terminal via portable-pty + +## Configuration + +Config stored at `~/.config/gitfetch/gitfetch.conf`. Provider tokens can be set via environment variables: +- `GH_TOKEN` for GitHub +- `GITLAB_TOKEN` for GitLab +- `GITEA_TOKEN` for Gitea +- `SOURCEHUT_TOKEN` for Sourcehut + +Cache stored at `~/.local/share/gitfetch/cache.db` (SQLite). + +## Testing + +Tests use pytest with mocking for external API calls. Test files mirror source structure: +- `tests/test_cache.py` - Cache expiry, SQLite operations +- `tests/test_cli.py` - Argument parsing, initialization flow +- `tests/test_display.py` - Rendering, layout calculations +- `tests/test_fetcher.py` - Provider API mocking +- `tests/test_search_query.py` - GitHub search query parsing + +## Dependencies + +Python requires `requests`, `readchar`, `webcolors`. Dev dependencies include `pytest`, `black`, `mypy`. + +Rust TUI requires `ratatui`, `crossterm`, `portable-pty`, `vt100`. diff --git a/README.md b/README.md index 65401e7..a138d7e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,10 @@ +# NOTICE + +This branch was given a to ralph with the goal of writing tests, finding bugs, refactorign large files and potentially adding new features. + +This is for experimenation purposes only and this branch might or might not be merged into the main branch. + + # gitfetch [![CI](https://github.com/Matars/gitfetch/actions/workflows/ci.yml/badge.svg)](https://github.com/Matars/gitfetch/actions/workflows/ci.yml) @@ -57,6 +64,36 @@ make dev - Smart SQLite-based caching system - Cross-platform support (macOS and Linux) - Extensive customization options +- Experimental Rust TUI scaffold via `gitfetch --tui` + +## Experimental TUI (Rust) + +`gitfetch --tui` launches an experimental Rust `ratatui` interface for daily git flow. + +Current capabilities: + +- Live refresh (~700ms) of changed files and branch state +- Stage/unstage a selected file +- Create commit from inside the TUI +- Push from inside the TUI + +For local testing from this repository: + +```bash +cargo run --manifest-path rust/gitfetch-tui/Cargo.toml +gitfetch --tui +``` + +You can also point to a custom TUI binary with `GITFETCH_TUI_BIN`. + +Controls: + +- `j` / `k` or arrow keys: move selection +- `space` or `enter`: stage/unstage selected file +- `c`: open commit input modal +- `p`: push +- `r`: refresh now +- `q`: quit ## Supported Platforms diff --git a/docs/installation.md b/docs/installation.md index 738151c..ba7ec26 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -33,7 +33,7 @@ Or manual build: ```bash git clone https://aur.archlinux.org/gitfetch-python.git cd gitfetch-python -makepkg -si +make dev ``` ## NixOS (Flake only) diff --git a/requirements.txt b/requirements.txt index eae9653..35bd52c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ -# No Python dependencies required +# Runtime dependencies # Requires GitHub CLI (gh) to be installed and authenticated requests>=2.0.0 readchar>=4.0.0 -pytest>=7.0.0 webcolors>=24.11.1 diff --git a/rust/gitfetch-tui/Cargo.lock b/rust/gitfetch-tui/Cargo.lock new file mode 100644 index 0000000..054af11 --- /dev/null +++ b/rust/gitfetch-tui/Cargo.lock @@ -0,0 +1,935 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.3", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror", + "winapi", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "gitfetch-tui" +version = "0.1.0" +dependencies = [ + "crossterm 0.29.0", + "portable-pty", + "ratatui", + "vt100", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.11.0", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width 0.1.14", + "vte", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] diff --git a/rust/gitfetch-tui/Cargo.toml b/rust/gitfetch-tui/Cargo.toml new file mode 100644 index 0000000..9524439 --- /dev/null +++ b/rust/gitfetch-tui/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "gitfetch-tui" +version = "0.1.0" +edition = "2021" +description = "Experimental TUI scaffold for gitfetch" + +[dependencies] +crossterm = "0.29" +ratatui = "0.29" +portable-pty = "0.8" +vt100 = "0.15" diff --git a/rust/gitfetch-tui/src/main.rs b/rust/gitfetch-tui/src/main.rs new file mode 100644 index 0000000..d974211 --- /dev/null +++ b/rust/gitfetch-tui/src/main.rs @@ -0,0 +1,5160 @@ +use std::error::Error; +use std::io; +use std::io::Read; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread; +use std::time::{Duration, Instant}; +use std::{ + collections::{BTreeMap, HashSet}, + fs, +}; + +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::symbols::Marker; +use ratatui::text::{Line, Span}; +use ratatui::widgets::canvas::{self, Canvas}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; +use ratatui::Terminal; + +#[derive(Clone, Debug)] +struct FileEntry { + path: String, + staged: bool, + unstaged: bool, + untracked: bool, +} + +#[derive(Clone, Debug)] +enum Mode { + Normal, + CommitInput, + WorktreeCommitPushInput, + WorktreeCreateInput, + WorktreeBranchConflictConfirm, + QuitWithSessionsConfirm, + AgentPopup, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum ViewMode { + Changes, + Worktrees, +} + +struct App { + branch: String, + ahead: usize, + behind: usize, + files: Vec, + tree_items: Vec, + selected: usize, + selected_overview: Option, + active_pane: ActivePane, + overview_scroll: u16, + status_line: String, + mode: Mode, + view_mode: ViewMode, + commit_input: String, + worktrees: Vec, + selected_worktree: usize, + worktree_focus: WorktreePane, + worktree_canvas_zoom: f64, + worktree_canvas_pan_x: f64, + worktree_canvas_pan_y: f64, + show_panel_help: bool, + new_worktree_branch: String, + new_worktree_base: WorktreeCreateBase, + pending_create_branch: String, + confirm_delete_branch_yes: bool, + worktree_commit_input: String, + worktree_commit_path: Option, + confirm_quit_with_sessions_yes: bool, + quit_now: bool, + agent_sessions: BTreeMap, + agent_popup_path: Option, + terminal_popup_mode: TerminalPopupMode, + agent_tx: Sender, + agent_rx: Receiver, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TerminalPopupMode { + Input, + Control, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AgentState { + Launching, + Running, + Done, + Failed, +} + +struct AgentSession { + state: AgentState, + parser: vt100::Parser, + master: Option>, + writer: Option>, + child: Option>, + last_size: (u16, u16), + launched_at: Instant, + last_io_at: Instant, + bytes_from_agent: u64, + bytes_to_agent: u64, +} + +enum AgentEvent { + Output { path: String, bytes: Vec }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WorktreePane { + Canvas, + Details, + Actions, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WorktreeCreateBase { + Main, + Selected, + SelectedWithChanges, +} + +#[derive(Clone, Debug, Default)] +struct WorktreeEntry { + path: String, + head: String, + branch: String, + bare: bool, + detached: bool, + locked: bool, + prunable: bool, + is_current: bool, + dirty: bool, + ahead: usize, + behind: usize, + parent_hint: Option, +} + +#[derive(Clone, Debug)] +struct TreeItem { + path: String, + label: String, + kind: TreeKind, + staged: bool, + unstaged: bool, + untracked: bool, + added_lines: usize, + removed_lines: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum TreeKind { + Folder, + File, +} + +#[derive(Clone, Copy, Debug, Default)] +struct PathStatus { + staged: bool, + unstaged: bool, + untracked: bool, +} + +#[derive(Clone, Copy, Debug, Default)] +struct PathDelta { + added_lines: usize, + removed_lines: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum ActivePane { + Files, + Overview, +} + +#[derive(Clone, Debug)] +struct FileOverview { + file: String, + state: String, + added_lines: usize, + removed_lines: usize, + methods_added: Vec, + methods_modified: Vec, + methods_deleted: Vec, + traditional_diff: Vec, + use_traditional_overview: bool, +} + +#[derive(Clone, Debug)] +struct DiffPreviewLine { + kind: DiffPreviewKind, + text: String, +} + +#[derive(Clone, Debug)] +enum DiffPreviewKind { + Added, + Removed, + Meta, + Context, +} + +impl App { + fn new() -> Self { + let (agent_tx, agent_rx) = mpsc::channel(); + Self { + branch: "unknown".to_string(), + ahead: 0, + behind: 0, + files: Vec::new(), + tree_items: Vec::new(), + selected: 0, + selected_overview: None, + active_pane: ActivePane::Files, + overview_scroll: 0, + status_line: "Ready".to_string(), + mode: Mode::Normal, + view_mode: ViewMode::Changes, + commit_input: String::new(), + worktrees: Vec::new(), + selected_worktree: 0, + worktree_focus: WorktreePane::Canvas, + worktree_canvas_zoom: 1.0, + worktree_canvas_pan_x: 0.0, + worktree_canvas_pan_y: 0.0, + show_panel_help: false, + new_worktree_branch: String::new(), + new_worktree_base: WorktreeCreateBase::Selected, + pending_create_branch: String::new(), + confirm_delete_branch_yes: false, + worktree_commit_input: String::new(), + worktree_commit_path: None, + confirm_quit_with_sessions_yes: false, + quit_now: false, + agent_sessions: BTreeMap::new(), + agent_popup_path: None, + terminal_popup_mode: TerminalPopupMode::Input, + agent_tx, + agent_rx, + } + } + + fn selected_item(&self) -> Option<&TreeItem> { + self.tree_items.get(self.selected) + } + + fn select_next(&mut self) { + if self.tree_items.is_empty() { + self.selected = 0; + } else { + self.selected = (self.selected + 1) % self.tree_items.len(); + } + } + + fn select_prev(&mut self) { + if self.tree_items.is_empty() { + self.selected = 0; + } else if self.selected == 0 { + self.selected = self.tree_items.len() - 1; + } else { + self.selected -= 1; + } + } + + fn focus_left(&mut self) { + self.active_pane = ActivePane::Files; + } + + fn focus_right(&mut self) { + self.active_pane = ActivePane::Overview; + } + + fn selected_worktree(&self) -> Option<&WorktreeEntry> { + self.worktrees.get(self.selected_worktree) + } + + fn next_worktree_pane(&mut self) { + self.worktree_focus = match self.worktree_focus { + WorktreePane::Canvas => WorktreePane::Details, + WorktreePane::Details => WorktreePane::Actions, + WorktreePane::Actions => WorktreePane::Canvas, + }; + } + + fn cycle_worktree_base_left(&mut self) { + self.new_worktree_base = match self.new_worktree_base { + WorktreeCreateBase::Main => WorktreeCreateBase::SelectedWithChanges, + WorktreeCreateBase::Selected => WorktreeCreateBase::Main, + WorktreeCreateBase::SelectedWithChanges => WorktreeCreateBase::Selected, + }; + } + + fn cycle_worktree_base_right(&mut self) { + self.new_worktree_base = match self.new_worktree_base { + WorktreeCreateBase::Main => WorktreeCreateBase::Selected, + WorktreeCreateBase::Selected => WorktreeCreateBase::SelectedWithChanges, + WorktreeCreateBase::SelectedWithChanges => WorktreeCreateBase::Main, + }; + } +} + +struct TuiGuard; + +impl Drop for TuiGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let mut stdout = io::stdout(); + let _ = execute!(stdout, LeaveAlternateScreen); + } +} + +fn main() -> Result<(), Box> { + enable_raw_mode()?; + let _guard = TuiGuard; + + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(); + refresh_status(&mut app); + + let ui_tick_rate_fast = Duration::from_millis(16); + let ui_tick_rate_normal = Duration::from_millis(33); + let status_tick_rate = Duration::from_millis(1200); + let mut last_ui_tick = Instant::now(); + let mut last_status_tick = Instant::now(); + let mut should_quit = false; + + while !should_quit { + drain_agent_events(&mut app); + refresh_agent_sessions(&mut app); + + // Resize terminal session to match actual popup dimensions + if matches!(app.mode, Mode::AgentPopup) { + if let Some(path) = app.agent_popup_path.clone() { + let size = terminal.size()?; + let frame_area = Rect::new(0, 0, size.width, size.height); + let (rows, cols) = calc_terminal_popup_size(frame_area); + resize_terminal_session(&mut app, &path, rows, cols); + } + } + + terminal.draw(|frame| draw_ui(frame, &app))?; + + let ui_tick_rate = if matches!(app.mode, Mode::AgentPopup) { + ui_tick_rate_fast + } else { + ui_tick_rate_normal + }; + let ui_timeout = ui_tick_rate.saturating_sub(last_ui_tick.elapsed()); + let status_timeout = status_tick_rate.saturating_sub(last_status_tick.elapsed()); + let timeout = if ui_timeout < status_timeout { + ui_timeout + } else { + status_timeout + }; + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + if !matches!(app.mode, Mode::AgentPopup) + && key.modifiers.contains(KeyModifiers::CONTROL) + && key.code == KeyCode::Char('c') + { + should_quit = true; + continue; + } + match app.mode { + Mode::Normal => { + should_quit = handle_normal_mode_key(&mut app, key.code)?; + } + Mode::CommitInput => { + handle_commit_mode_key(&mut app, key.code)?; + } + Mode::WorktreeCommitPushInput => { + handle_worktree_commit_push_mode_key(&mut app, key.code)?; + } + Mode::WorktreeCreateInput => { + handle_worktree_create_mode_key(&mut app, key.code)?; + } + Mode::WorktreeBranchConflictConfirm => { + handle_branch_conflict_confirm_mode_key(&mut app, key.code)?; + } + Mode::QuitWithSessionsConfirm => { + handle_quit_with_sessions_mode_key(&mut app, key.code); + } + Mode::AgentPopup => { + handle_agent_popup_key(&mut app, key)?; + } + } + + if app.quit_now { + should_quit = true; + } + } + } + } + + if last_ui_tick.elapsed() >= ui_tick_rate { + last_ui_tick = Instant::now(); + } + + let refresh_git = !matches!(app.mode, Mode::AgentPopup); + if refresh_git && last_status_tick.elapsed() >= status_tick_rate { + refresh_status(&mut app); + last_status_tick = Instant::now(); + } + } + + terminal.show_cursor()?; + Ok(()) +} + +fn handle_normal_mode_key(app: &mut App, code: KeyCode) -> Result> { + if app.view_mode == ViewMode::Worktrees { + return handle_worktree_mode_key(app, code); + } + + match code { + KeyCode::Char('q') => return Ok(request_quit(app)), + KeyCode::Char('w') => { + app.view_mode = ViewMode::Worktrees; + app.worktree_focus = WorktreePane::Canvas; + app.show_panel_help = false; + app.status_line = "Switched to worktree navigator".to_string(); + refresh_worktrees(app); + } + KeyCode::Left | KeyCode::Char('h') => app.focus_left(), + KeyCode::Right | KeyCode::Char('l') => app.focus_right(), + KeyCode::Down | KeyCode::Char('j') => match app.active_pane { + ActivePane::Files => { + app.select_next(); + app.overview_scroll = 0; + refresh_selected_overview(app); + } + ActivePane::Overview => { + app.overview_scroll = app.overview_scroll.saturating_add(1); + } + }, + KeyCode::Up | KeyCode::Char('k') => match app.active_pane { + ActivePane::Files => { + app.select_prev(); + app.overview_scroll = 0; + refresh_selected_overview(app); + } + ActivePane::Overview => { + app.overview_scroll = app.overview_scroll.saturating_sub(1); + } + }, + KeyCode::Char('r') => refresh_status(app), + KeyCode::Enter | KeyCode::Char(' ') => { + toggle_stage(app)?; + refresh_status(app); + } + KeyCode::Char('c') => { + app.mode = Mode::CommitInput; + app.commit_input.clear(); + app.status_line = "Commit mode: type a message and press Enter".to_string(); + } + KeyCode::Char('p') => { + let output = push_with_upstream()?; + app.status_line = output; + refresh_status(app); + } + KeyCode::Char('s') => { + app.status_line = run_git(&["stash", "push", "--include-untracked"])?; + refresh_status(app); + } + KeyCode::Char('S') => { + app.status_line = run_git(&["stash", "pop"])?; + refresh_status(app); + } + _ => {} + } + + Ok(false) +} + +fn handle_worktree_mode_key(app: &mut App, code: KeyCode) -> Result> { + match code { + KeyCode::Char('q') => return Ok(request_quit(app)), + KeyCode::Char('w') => { + app.view_mode = ViewMode::Changes; + app.show_panel_help = false; + app.status_line = "Switched to changed files view".to_string(); + } + KeyCode::Tab => { + app.next_worktree_pane(); + app.show_panel_help = false; + } + KeyCode::Char('?') => { + app.show_panel_help = !app.show_panel_help; + } + KeyCode::Left => move_worktree_selection(app, NavDirection::Left), + KeyCode::Right => move_worktree_selection(app, NavDirection::Right), + KeyCode::Up => move_worktree_selection(app, NavDirection::Up), + KeyCode::Down => move_worktree_selection(app, NavDirection::Down), + KeyCode::Char('+') | KeyCode::Char('=') => zoom_worktree_canvas(app, true), + KeyCode::Char('-') => zoom_worktree_canvas(app, false), + KeyCode::Char('0') => reset_worktree_canvas_view(app), + KeyCode::Char('W') => pan_worktree_canvas(app, 0.0, 1.0), + KeyCode::Char('A') => pan_worktree_canvas(app, -1.0, 0.0), + KeyCode::Char('S') => pan_worktree_canvas(app, 0.0, -1.0), + KeyCode::Char('D') => pan_worktree_canvas(app, 1.0, 0.0), + KeyCode::Char('h') => move_worktree_level_siblings(app, false), + KeyCode::Char('l') => move_worktree_level_siblings(app, true), + KeyCode::Char('j') => move_worktree_level_vertical(app, false), + KeyCode::Char('k') => move_worktree_level_vertical(app, true), + KeyCode::Char('r') => { + refresh_worktrees(app); + app.status_line = "Refreshed worktree list".to_string(); + } + KeyCode::Char('a') => { + app.mode = Mode::WorktreeCreateInput; + app.new_worktree_branch.clear(); + app.new_worktree_base = WorktreeCreateBase::Selected; + app.status_line = + "Create worktree: choose base with ←/→, then type branch name".to_string(); + } + KeyCode::Char('o') => { + open_terminal_popup_for_selected_worktree(app)?; + } + KeyCode::Char('z') => { + open_terminal_popup_for_selected_worktree(app)?; + } + KeyCode::Char('p') => { + if let Some(path) = app.selected_worktree().map(|wt| wt.path.clone()) { + app.mode = Mode::WorktreeCommitPushInput; + app.worktree_commit_input.clear(); + app.worktree_commit_path = Some(path); + app.status_line = + "Worktree push mode: commit message, Enter to add/commit/push".to_string(); + } + } + KeyCode::Char('f') => { + app.status_line = update_connected_parent(app)?; + refresh_worktrees(app); + refresh_status(app); + } + KeyCode::Char('x') => { + app.status_line = run_git(&["worktree", "prune"])?; + refresh_worktrees(app); + } + KeyCode::Char('d') => { + app.status_line = remove_selected_worktree(app)?; + refresh_worktrees(app); + refresh_status(app); + } + KeyCode::Char('m') => { + app.status_line = merge_selected_into_parent(app)?; + refresh_worktrees(app); + refresh_status(app); + } + _ => {} + } + + Ok(false) +} + +fn zoom_worktree_canvas(app: &mut App, zoom_in: bool) { + const MIN_ZOOM: f64 = 0.65; + const MAX_ZOOM: f64 = 3.4; + const STEP: f64 = 1.2; + + if zoom_in { + app.worktree_canvas_zoom = (app.worktree_canvas_zoom * STEP).clamp(MIN_ZOOM, MAX_ZOOM); + } else { + app.worktree_canvas_zoom = (app.worktree_canvas_zoom / STEP).clamp(MIN_ZOOM, MAX_ZOOM); + } + + app.status_line = format!("Canvas zoom: {:.2}x", app.worktree_canvas_zoom); +} + +fn pan_worktree_canvas(app: &mut App, dx: f64, dy: f64) { + let step = 0.18 / app.worktree_canvas_zoom.max(0.65); + app.worktree_canvas_pan_x = (app.worktree_canvas_pan_x + dx * step).clamp(-1.8, 1.8); + app.worktree_canvas_pan_y = (app.worktree_canvas_pan_y + dy * step).clamp(-1.8, 1.8); + app.status_line = format!( + "Canvas pan: x={:+.2} y={:+.2}", + app.worktree_canvas_pan_x, app.worktree_canvas_pan_y + ); +} + +fn reset_worktree_canvas_view(app: &mut App) { + app.worktree_canvas_zoom = 1.0; + app.worktree_canvas_pan_x = 0.0; + app.worktree_canvas_pan_y = 0.0; + app.status_line = "Canvas view reset".to_string(); +} + +fn open_terminal_popup_for_selected_worktree(app: &mut App) -> Result<(), Box> { + if let Some(path) = app.selected_worktree().map(|wt| wt.path.clone()) { + app.agent_popup_path = Some(path.clone()); + app.mode = Mode::AgentPopup; + app.terminal_popup_mode = TerminalPopupMode::Input; + if !has_live_terminal_session(app, path.as_str()) { + launch_shell_session(app, path.as_str())?; + } else { + app.status_line = "Reopened terminal session".to_string(); + } + } + Ok(()) +} + +fn request_quit(app: &mut App) -> bool { + if live_terminal_session_count(app) == 0 { + return true; + } + + app.mode = Mode::QuitWithSessionsConfirm; + app.confirm_quit_with_sessions_yes = false; + app.status_line = "Active terminal sessions detected".to_string(); + false +} + +fn live_terminal_session_count(app: &App) -> usize { + app.agent_sessions + .values() + .filter(|session| session.child.is_some()) + .count() +} + +fn handle_quit_with_sessions_mode_key(app: &mut App, code: KeyCode) { + match code { + KeyCode::Esc => { + app.mode = Mode::Normal; + app.confirm_quit_with_sessions_yes = false; + app.status_line = "Quit cancelled".to_string(); + } + KeyCode::Left | KeyCode::Right | KeyCode::Tab => { + app.confirm_quit_with_sessions_yes = !app.confirm_quit_with_sessions_yes; + } + KeyCode::Char('y') => app.confirm_quit_with_sessions_yes = true, + KeyCode::Char('n') => app.confirm_quit_with_sessions_yes = false, + KeyCode::Enter => { + if app.confirm_quit_with_sessions_yes { + app.quit_now = true; + } else { + app.mode = Mode::Normal; + app.status_line = "Quit cancelled".to_string(); + } + } + _ => {} + } +} + +fn handle_worktree_create_mode_key(app: &mut App, code: KeyCode) -> Result<(), Box> { + match code { + KeyCode::Esc => { + app.mode = Mode::Normal; + app.status_line = "Create worktree cancelled".to_string(); + } + KeyCode::Left => { + app.cycle_worktree_base_left(); + } + KeyCode::Right => { + app.cycle_worktree_base_right(); + } + KeyCode::Enter => { + let branch = app.new_worktree_branch.trim(); + if branch.is_empty() { + app.status_line = "Branch name is required".to_string(); + } else { + let root = create_root_for_app(app); + if branch_exists(root.as_str(), branch) { + app.pending_create_branch = branch.to_string(); + app.confirm_delete_branch_yes = false; + app.mode = Mode::WorktreeBranchConflictConfirm; + app.status_line = format!( + "Branch '{}' already exists. Confirm delete and recreate.", + branch + ); + return Ok(()); + } + + app.status_line = create_worktree(app, branch)?; + refresh_worktrees(app); + refresh_status(app); + } + app.mode = Mode::Normal; + app.new_worktree_branch.clear(); + } + KeyCode::Backspace => { + app.new_worktree_branch.pop(); + } + KeyCode::Char(c) => { + app.new_worktree_branch.push(c); + } + _ => {} + } + + Ok(()) +} + +fn handle_branch_conflict_confirm_mode_key( + app: &mut App, + code: KeyCode, +) -> Result<(), Box> { + match code { + KeyCode::Esc => { + app.mode = Mode::Normal; + app.confirm_delete_branch_yes = false; + app.pending_create_branch.clear(); + app.status_line = "Create worktree cancelled".to_string(); + } + KeyCode::Left | KeyCode::Right | KeyCode::Tab => { + app.confirm_delete_branch_yes = !app.confirm_delete_branch_yes; + } + KeyCode::Char('y') => app.confirm_delete_branch_yes = true, + KeyCode::Char('n') => app.confirm_delete_branch_yes = false, + KeyCode::Enter => { + if app.confirm_delete_branch_yes { + let branch = app.pending_create_branch.clone(); + let root = create_root_for_app(app); + app.status_line = + delete_branch_and_create_worktree(app, root.as_str(), branch.as_str())?; + refresh_worktrees(app); + refresh_status(app); + } else { + app.status_line = "Create worktree cancelled (kept existing branch)".to_string(); + } + + app.mode = Mode::Normal; + app.confirm_delete_branch_yes = false; + app.pending_create_branch.clear(); + app.new_worktree_branch.clear(); + } + _ => {} + } + + Ok(()) +} + +fn handle_agent_popup_key(app: &mut App, key: KeyEvent) -> Result<(), Box> { + let code = key.code; + let Some(path) = app.agent_popup_path.clone() else { + app.mode = Mode::Normal; + return Ok(()); + }; + + if !has_live_terminal_session(app, path.as_str()) { + launch_shell_session(app, path.as_str())?; + } + + if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(code, KeyCode::Char('g')) { + app.terminal_popup_mode = match app.terminal_popup_mode { + TerminalPopupMode::Input => TerminalPopupMode::Control, + TerminalPopupMode::Control => TerminalPopupMode::Input, + }; + return Ok(()); + } + + if app.terminal_popup_mode == TerminalPopupMode::Input + && key.modifiers.is_empty() + && matches!(code, KeyCode::Char(':')) + { + app.terminal_popup_mode = match app.terminal_popup_mode { + TerminalPopupMode::Input => TerminalPopupMode::Control, + TerminalPopupMode::Control => TerminalPopupMode::Input, + }; + return Ok(()); + } + + if app.terminal_popup_mode == TerminalPopupMode::Control { + match code { + KeyCode::Esc => { + app.mode = Mode::Normal; + app.agent_popup_path = None; + app.status_line = "Terminal session moved to background".to_string(); + } + KeyCode::Char('q') => { + terminate_terminal_session(app, path.as_str()); + app.mode = Mode::Normal; + app.agent_popup_path = None; + app.status_line = "Terminal session closed".to_string(); + } + KeyCode::Char('r') => { + app.agent_sessions.remove(path.as_str()); + launch_shell_session(app, path.as_str())?; + app.status_line = "Terminal restarted".to_string(); + } + KeyCode::Char('i') => { + app.terminal_popup_mode = TerminalPopupMode::Input; + } + _ => {} + } + return Ok(()); + } + + match code { + KeyCode::Tab => { + write_to_agent(app, path.as_str(), "\t")?; + } + KeyCode::Left => { + write_to_agent(app, path.as_str(), "\x1b[D")?; + } + KeyCode::Right => { + write_to_agent(app, path.as_str(), "\x1b[C")?; + } + KeyCode::Up => { + write_to_agent(app, path.as_str(), "\x1b[A")?; + } + KeyCode::Down => { + write_to_agent(app, path.as_str(), "\x1b[B")?; + } + KeyCode::Home => { + write_to_agent(app, path.as_str(), "\x1b[H")?; + } + KeyCode::End => { + write_to_agent(app, path.as_str(), "\x1b[F")?; + } + KeyCode::PageUp => { + write_to_agent(app, path.as_str(), "\x1b[5~")?; + } + KeyCode::PageDown => { + write_to_agent(app, path.as_str(), "\x1b[6~")?; + } + KeyCode::Delete => { + write_to_agent(app, path.as_str(), "\x1b[3~")?; + } + KeyCode::Backspace => { + write_to_agent(app, path.as_str(), "\x7f")?; + } + KeyCode::Enter => { + write_to_agent(app, path.as_str(), "\r")?; + } + KeyCode::Char(c) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + if let Some(seq) = control_seq(c) { + write_to_agent(app, path.as_str(), seq)?; + } + } else { + let mut s = String::new(); + s.push(c); + write_to_agent(app, path.as_str(), s.as_str())?; + } + } + _ => {} + } + + Ok(()) +} + +fn terminate_terminal_session(app: &mut App, path: &str) { + if let Some(mut session) = app.agent_sessions.remove(path) { + if let Some(mut child) = session.child.take() { + let _ = child.kill(); + } + } +} + +fn control_seq(c: char) -> Option<&'static str> { + match c.to_ascii_lowercase() { + 'a' => Some("\x01"), + 'b' => Some("\x02"), + 'c' => Some("\x03"), + 'd' => Some("\x04"), + 'e' => Some("\x05"), + 'f' => Some("\x06"), + 'g' => Some("\x07"), + 'h' => Some("\x08"), + 'i' => Some("\x09"), + 'j' => Some("\x0A"), + 'k' => Some("\x0B"), + 'l' => Some("\x0C"), + 'm' => Some("\x0D"), + 'n' => Some("\x0E"), + 'o' => Some("\x0F"), + 'p' => Some("\x10"), + 'q' => Some("\x11"), + 'r' => Some("\x12"), + 's' => Some("\x13"), + 't' => Some("\x14"), + 'u' => Some("\x15"), + 'v' => Some("\x16"), + 'w' => Some("\x17"), + 'x' => Some("\x18"), + 'y' => Some("\x19"), + 'z' => Some("\x1A"), + _ => None, + } +} + +fn has_live_terminal_session(app: &App, path: &str) -> bool { + app.agent_sessions + .get(path) + .map(|session| session.child.is_some() && session.writer.is_some()) + .unwrap_or(false) +} + +fn launch_shell_session(app: &mut App, path: &str) -> Result<(), Box> { + const TERM_ROWS: u16 = 44; + const TERM_COLS: u16 = 150; + + let pty_system = native_pty_system(); + let pair = pty_system.openpty(PtySize { + rows: TERM_ROWS, + cols: TERM_COLS, + pixel_width: 0, + pixel_height: 0, + })?; + + let shell = std::env::var("SHELL").unwrap_or_else(|_| "zsh".to_string()); + let mut cmd = CommandBuilder::new(shell.as_str()); + cmd.arg("-i"); + cmd.arg("-l"); + cmd.cwd(path); + let child = pair.slave.spawn_command(cmd)?; + + let tx = app.agent_tx.clone(); + let mut reader = pair.master.try_clone_reader()?; + let writer = pair.master.take_writer()?; + let output_path = path.to_string(); + thread::spawn(move || { + let mut buf = [0u8; 1024]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let _ = tx.send(AgentEvent::Output { + path: output_path.clone(), + bytes: buf[..n].to_vec(), + }); + } + Err(_) => break, + } + } + }); + + let now = Instant::now(); + let session = AgentSession { + state: AgentState::Launching, + parser: vt100::Parser::new(TERM_ROWS, TERM_COLS, 2000), + master: Some(pair.master), + writer: Some(writer), + child: Some(child), + last_size: (TERM_ROWS, TERM_COLS), + launched_at: now, + last_io_at: now, + bytes_from_agent: 0, + bytes_to_agent: 0, + }; + + app.agent_sessions.insert(path.to_string(), session); + if let Some(active) = app.agent_sessions.get_mut(path) { + active + .parser + .process(b"[terminal attached - type commands and press Enter]\r\n"); + } + app.status_line = format!("Shell started in popup for {}", path); + Ok(()) +} + +fn resize_terminal_session(app: &mut App, path: &str, rows: u16, cols: u16) { + if let Some(session) = app.agent_sessions.get_mut(path) { + // Only resize if size actually changed + if session.last_size == (rows, cols) { + return; + } + // Resize the PTY + if let Some(master) = session.master.as_ref() { + let _ = master.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }); + } + // Resize the vt100 parser to match + session.parser.set_size(rows, cols); + session.last_size = (rows, cols); + } +} + +/// Calculate terminal popup dimensions based on frame size +fn calc_terminal_popup_size(frame_area: Rect) -> (u16, u16) { + let popup = terminal_popup_rect(frame_area); + let inner = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Min(8), + Constraint::Length(1), + ]) + .split(popup); + // Terminal area is inner[2], minus borders + let rows = inner[2].height.saturating_sub(2); + let cols = inner[2].width.saturating_sub(2); + (rows, cols) +} + +fn write_to_agent(app: &mut App, path: &str, text: &str) -> Result<(), Box> { + if let Some(session) = app.agent_sessions.get_mut(path) { + if let Some(writer) = session.writer.as_mut() { + writer.write_all(text.as_bytes())?; + writer.flush()?; + session.bytes_to_agent = session.bytes_to_agent.saturating_add(text.len() as u64); + session.last_io_at = Instant::now(); + if session.state == AgentState::Launching { + session.state = AgentState::Running; + } + } + } + Ok(()) +} + +fn drain_agent_events(app: &mut App) { + while let Ok(event) = app.agent_rx.try_recv() { + match event { + AgentEvent::Output { path, bytes } => { + if let Some(session) = app.agent_sessions.get_mut(path.as_str()) { + session.state = AgentState::Running; + session.bytes_from_agent = + session.bytes_from_agent.saturating_add(bytes.len() as u64); + session.last_io_at = Instant::now(); + session.parser.process(bytes.as_slice()); + } + } + } + } +} + +const AGENT_ACTIVE_WINDOW: Duration = Duration::from_secs(3); + +fn agent_session_is_live(session: &AgentSession) -> bool { + session.child.is_some() && session.writer.is_some() +} + +fn agent_session_idle_seconds(session: &AgentSession, now: Instant) -> u64 { + now.saturating_duration_since(session.last_io_at).as_secs() +} + +fn agent_session_is_active(session: &AgentSession, now: Instant) -> bool { + agent_session_is_live(session) + && now.saturating_duration_since(session.last_io_at) <= AGENT_ACTIVE_WINDOW +} + +fn agent_session_avg_bps(session: &AgentSession, now: Instant) -> u64 { + let seconds = now + .saturating_duration_since(session.launched_at) + .as_secs() + .max(1); + session.bytes_from_agent / seconds +} + +fn session_label_from_path(path: &str) -> String { + Path::new(path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(path) + .to_string() +} + +fn refresh_agent_sessions(app: &mut App) { + for session in app.agent_sessions.values_mut() { + if let Some(child) = session.child.as_mut() { + if let Ok(Some(status)) = child.try_wait() { + if status.success() { + session.state = AgentState::Done; + session + .parser + .process(b"\r\n[terminal exited successfully]\r\n"); + } else { + session.state = AgentState::Failed; + let line = format!("\r\n[terminal exited: {}]\r\n", status); + session.parser.process(line.as_bytes()); + } + session.child = None; + session.writer = None; + } + } + } +} + +fn handle_commit_mode_key(app: &mut App, code: KeyCode) -> Result<(), Box> { + match code { + KeyCode::Esc => { + app.mode = Mode::Normal; + app.status_line = "Commit cancelled".to_string(); + } + KeyCode::Enter => { + let message = app.commit_input.trim(); + if message.is_empty() { + app.status_line = "Commit message is empty".to_string(); + } else { + let output = run_git(&["commit", "-m", message])?; + app.status_line = output; + refresh_status(app); + } + app.mode = Mode::Normal; + app.commit_input.clear(); + } + KeyCode::Backspace => { + app.commit_input.pop(); + } + KeyCode::Char(c) => { + app.commit_input.push(c); + } + _ => {} + } + + Ok(()) +} + +fn handle_worktree_commit_push_mode_key( + app: &mut App, + code: KeyCode, +) -> Result<(), Box> { + match code { + KeyCode::Esc => { + app.mode = Mode::Normal; + app.worktree_commit_input.clear(); + app.worktree_commit_path = None; + app.status_line = "Worktree commit/push cancelled".to_string(); + } + KeyCode::Enter => { + let message = app.worktree_commit_input.trim().to_string(); + let Some(path) = app.worktree_commit_path.clone() else { + app.status_line = "No worktree selected for commit/push".to_string(); + app.mode = Mode::Normal; + return Ok(()); + }; + + if message.is_empty() { + app.status_line = "Commit message is empty".to_string(); + } else { + app.status_line = commit_and_push_worktree(path.as_str(), message.as_str())?; + refresh_worktrees(app); + refresh_status(app); + } + + app.mode = Mode::Normal; + app.worktree_commit_input.clear(); + app.worktree_commit_path = None; + } + KeyCode::Backspace => { + app.worktree_commit_input.pop(); + } + KeyCode::Char(c) => { + app.worktree_commit_input.push(c); + } + _ => {} + } + + Ok(()) +} + +fn refresh_status(app: &mut App) { + let output = match run_git(&["status", "--porcelain=1", "-b", "-uall"]) { + Ok(text) => text, + Err(err) => { + app.status_line = err.to_string(); + return; + } + }; + + let mut lines = output.lines(); + if let Some(head) = lines.next() { + parse_branch_line(app, head); + } + + let mut files = Vec::new(); + for line in lines { + if line.len() < 4 { + continue; + } + + let x = line.chars().next().unwrap_or(' '); + let y = line.chars().nth(1).unwrap_or(' '); + let path = line[3..].trim().to_string(); + + if should_hide_internal_worktree_path(path.as_str()) { + continue; + } + + files.push(FileEntry { + path, + staged: x != ' ' && x != '?', + unstaged: y != ' ', + untracked: x == '?' && y == '?', + }); + } + + app.files = files; + app.tree_items = build_tree_items(&app.files); + + if app.tree_items.is_empty() { + app.selected = 0; + } else if app.selected >= app.tree_items.len() { + app.selected = app.tree_items.len() - 1; + } + + let max_scroll = max_overview_scroll(app); + if app.overview_scroll > max_scroll { + app.overview_scroll = max_scroll; + } + + refresh_selected_overview(app); + refresh_worktrees(app); +} + +fn refresh_worktrees(app: &mut App) { + let output = match git_output(&["worktree", "list", "--porcelain"]) { + Some(text) => text, + None => { + app.worktrees.clear(); + app.selected_worktree = 0; + return; + } + }; + + let current_path = std::env::current_dir() + .ok() + .map(|path| normalize_path(path.to_string_lossy().as_ref())); + let root = create_root_for_app(app); + let parent_hints = load_parent_hint_map(root.as_str()); + + let mut entries: Vec = Vec::new(); + let mut current = WorktreeEntry::default(); + let mut in_block = false; + + for line in output.lines() { + if line.trim().is_empty() { + if in_block { + hydrate_worktree_runtime_state( + &mut current, + current_path.as_deref(), + &parent_hints, + ); + entries.push(current.clone()); + current = WorktreeEntry::default(); + in_block = false; + } + continue; + } + + if let Some(path) = line.strip_prefix("worktree ") { + if in_block { + hydrate_worktree_runtime_state( + &mut current, + current_path.as_deref(), + &parent_hints, + ); + entries.push(current.clone()); + current = WorktreeEntry::default(); + } + current.path = path.trim().to_string(); + in_block = true; + continue; + } + + if let Some(head) = line.strip_prefix("HEAD ") { + current.head = head.trim().to_string(); + continue; + } + + if let Some(branch) = line.strip_prefix("branch ") { + current.branch = branch + .trim() + .strip_prefix("refs/heads/") + .unwrap_or(branch.trim()) + .to_string(); + continue; + } + + if line == "detached" { + current.detached = true; + if current.branch.is_empty() { + current.branch = "detached".to_string(); + } + continue; + } + + if line == "bare" { + current.bare = true; + continue; + } + + if line.starts_with("locked") { + current.locked = true; + continue; + } + + if line.starts_with("prunable") { + current.prunable = true; + continue; + } + } + + if in_block { + hydrate_worktree_runtime_state(&mut current, current_path.as_deref(), &parent_hints); + entries.push(current); + } + + entries.sort_by(|a, b| { + b.is_current + .cmp(&a.is_current) + .then_with(|| a.branch.cmp(&b.branch)) + .then_with(|| a.path.cmp(&b.path)) + }); + + app.worktrees = entries; + if app.worktrees.is_empty() { + app.selected_worktree = 0; + } else if app.selected_worktree >= app.worktrees.len() { + app.selected_worktree = app.worktrees.len() - 1; + } +} + +fn hydrate_worktree_runtime_state( + entry: &mut WorktreeEntry, + current_path: Option<&str>, + parent_hints: &BTreeMap, +) { + let normalized = normalize_path(entry.path.as_str()); + entry.is_current = current_path + .map(|cwd| cwd == normalized.as_str()) + .unwrap_or(false); + + if entry.branch.is_empty() { + entry.branch = "detached".to_string(); + } + + let (dirty, ahead, behind) = worktree_branch_state(entry.path.as_str()); + entry.dirty = dirty; + entry.ahead = ahead; + entry.behind = behind; + entry.parent_hint = parent_hints.get(entry.branch.as_str()).cloned(); +} + +fn worktree_branch_state(path: &str) -> (bool, usize, usize) { + let output = match Command::new("git") + .args(["-C", path, "status", "--porcelain=1", "-b", "-uall"]) + .output() + { + Ok(out) if out.status.success() => { + sanitize_for_tui(String::from_utf8_lossy(&out.stdout).as_ref()) + } + _ => return (false, 0, 0), + }; + + let mut lines = output.lines(); + let mut ahead = 0usize; + let mut behind = 0usize; + if let Some(head) = lines.next() { + let (_, parsed_ahead, parsed_behind) = parse_branch_snapshot(head); + ahead = parsed_ahead; + behind = parsed_behind; + } + let dirty = lines.any(|line| status_line_counts_as_dirty(line)); + (dirty, ahead, behind) +} + +fn status_line_counts_as_dirty(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.is_empty() { + return false; + } + + if let Some(path) = trimmed.strip_prefix("?? ") { + return !should_hide_internal_worktree_path(path.trim()); + } + + if let Some(path) = trimmed.strip_prefix("!! ") { + return !should_hide_internal_worktree_path(path.trim()); + } + + true +} + +fn parse_branch_snapshot(line: &str) -> (String, usize, usize) { + let mut ahead = 0usize; + let mut behind = 0usize; + + let stripped = line.strip_prefix("## ").unwrap_or(line); + let branch = if let Some((name, rest)) = stripped.split_once("...") { + if let Some(start) = rest.find('[') { + if let Some(end) = rest[start + 1..].find(']') { + let info = &rest[start + 1..start + 1 + end]; + for token in info.split(',').map(|part| part.trim()) { + if let Some(v) = token.strip_prefix("ahead ") { + ahead = v.parse::().unwrap_or(0); + } + if let Some(v) = token.strip_prefix("behind ") { + behind = v.parse::().unwrap_or(0); + } + } + } + } + name.trim().to_string() + } else { + stripped.trim().to_string() + }; + + (branch, ahead, behind) +} + +fn normalize_path(path: &str) -> String { + fs::canonicalize(Path::new(path)) + .unwrap_or_else(|_| Path::new(path).to_path_buf()) + .to_string_lossy() + .to_string() +} + +fn remove_selected_worktree(app: &App) -> Result> { + let Some(selected) = app.selected_worktree() else { + return Ok("No worktree selected".to_string()); + }; + + if selected.is_current { + return Ok("Refusing to remove current worktree".to_string()); + } + + if selected.dirty { + return Ok("Refusing to remove dirty worktree (clean it first)".to_string()); + } + + run_git(&["worktree", "remove", selected.path.as_str()]) +} + +fn merge_selected_into_parent(app: &App) -> Result> { + if app.selected_worktree >= app.worktrees.len() { + return Ok("No worktree selected".to_string()); + } + + let selected = app.worktrees[app.selected_worktree].clone(); + if selected.detached || selected.branch.is_empty() { + return Ok("Selected worktree is detached; merge requires a branch".to_string()); + } + + let Some(parent_idx) = connected_parent_index(app) else { + return Ok("No connected parent node found for selected worktree".to_string()); + }; + let parent = app.worktrees[parent_idx].clone(); + + if parent.detached || parent.branch.is_empty() { + return Ok("Parent node is detached; cannot merge into detached HEAD".to_string()); + } + if parent.branch == selected.branch { + return Ok("Selected and parent are the same branch; nothing to merge".to_string()); + } + + let before_head = git_output(&["-C", parent.path.as_str(), "rev-parse", "HEAD"]) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + // Use an explicit commit/ref target to avoid ambiguous names like "stash" + // resolving to refs/stash instead of refs/heads/stash. + let merge_target = if !selected.head.is_empty() { + selected.head.clone() + } else { + format!("refs/heads/{}", selected.branch) + }; + + let merge = Command::new("git") + .args([ + "-C", + parent.path.as_str(), + "merge", + "--no-edit", + merge_target.as_str(), + ]) + .output()?; + + let stdout = sanitize_for_tui(String::from_utf8_lossy(&merge.stdout).as_ref()) + .trim() + .to_string(); + let stderr = sanitize_for_tui(String::from_utf8_lossy(&merge.stderr).as_ref()) + .trim() + .to_string(); + + if !merge.status.success() { + let reason = if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + "merge failed".to_string() + }; + return Ok(format!( + "Merge '{}' -> '{}' failed:\n{}", + selected.branch, + parent.branch, + sanitize_for_tui(reason.as_str()) + )); + } + + let after_head = git_output(&["-C", parent.path.as_str(), "rev-parse", "HEAD"]) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + let details = if !stdout.is_empty() { + single_line(stdout.as_str()) + } else if !stderr.is_empty() { + single_line(stderr.as_str()) + } else { + "ok".to_string() + }; + + if !before_head.is_empty() && before_head == after_head { + Ok(format!( + "No new merge for '{}' -> '{}' ({}) - {}", + selected.branch, parent.branch, parent.path, details + )) + } else { + Ok(format!( + "Merged '{}' into '{}' ({}) [{} -> {}] - {}", + selected.branch, + parent.branch, + parent.path, + truncate_text(before_head.as_str(), 8), + truncate_text(after_head.as_str(), 8), + details + )) + } +} + +fn update_connected_parent(app: &App) -> Result> { + let Some(parent_idx) = connected_parent_index(app) else { + return Ok("No connected parent node found for selected worktree".to_string()); + }; + let parent = app.worktrees[parent_idx].clone(); + + if parent.detached || parent.branch.is_empty() { + return Ok("Parent node is detached; cannot fetch updates".to_string()); + } + + let fetch = run_git(&["-C", parent.path.as_str(), "fetch", "--all", "--prune"])?; + + Ok(format!( + "Fetched parent '{}' - {}", + parent.branch, + single_line(fetch.as_str()), + )) +} + +fn connected_parent_index(app: &App) -> Option { + if app.selected_worktree >= app.worktrees.len() { + return None; + } + + let root_branch = current_session_branch(app); + let parents = worktree_parent_map(&app.worktrees, root_branch.as_str()); + if let Some(parent_idx) = parents.get(app.selected_worktree).and_then(|v| *v) { + return Some(parent_idx); + } + + app.worktrees.iter().enumerate().find_map(|(idx, wt)| { + if idx == app.selected_worktree { + return None; + } + if !wt.detached && wt.branch == root_branch { + Some(idx) + } else { + None + } + }) +} + +#[derive(Clone, Copy)] +enum NavDirection { + Left, + Right, + Up, + Down, +} + +fn move_worktree_selection(app: &mut App, direction: NavDirection) { + if app.worktrees.len() < 2 || app.selected_worktree >= app.worktrees.len() { + return; + } + + let root_branch = current_session_branch(app); + let parents = worktree_parent_map(&app.worktrees, root_branch.as_str()); + let depths = graph_depths(&parents); + let points = graph_layout(&parents); + let (cx, cy) = points[app.selected_worktree]; + let mut best_idx: Option = None; + let mut best_score = f32::MAX; + + for (idx, (x, y)) in points.iter().enumerate() { + if idx == app.selected_worktree { + continue; + } + + let dx = *x - cx; + let dy = *y - cy; + let in_front = match direction { + NavDirection::Left => dx < -0.15, + NavDirection::Right => dx > 0.15, + NavDirection::Up => dy < -0.15, + NavDirection::Down => dy > 0.15, + }; + if !in_front { + continue; + } + + let directional_penalty = match direction { + NavDirection::Left | NavDirection::Right => dy.abs() * 1.7, + NavDirection::Up | NavDirection::Down => dx.abs() * 1.7, + }; + let score = dx.abs() + dy.abs() + directional_penalty; + if score < best_score { + best_score = score; + best_idx = Some(idx); + } + } + + if best_idx.is_none() { + let current = app.selected_worktree; + let current_depth = depths[current]; + let max_depth = depths.iter().copied().max().unwrap_or(0); + let mut rows: BTreeMap> = BTreeMap::new(); + for (idx, depth) in depths.iter().enumerate() { + rows.entry(*depth).or_default().push(idx); + } + for nodes in rows.values_mut() { + nodes.sort_by(|a, b| points[*a].0.total_cmp(&points[*b].0)); + } + + let current_pos = rows + .get(¤t_depth) + .and_then(|nodes| nodes.iter().position(|idx| *idx == current)) + .unwrap_or(0); + + let next_nonempty_depth = |order: Vec, rows: &BTreeMap>| { + order + .into_iter() + .find(|depth| rows.get(depth).map(|n| !n.is_empty()).unwrap_or(false)) + }; + + best_idx = match direction { + NavDirection::Right => { + let next_on_row = rows + .get(¤t_depth) + .and_then(|nodes| nodes.get(current_pos + 1)) + .copied(); + next_on_row.or_else(|| { + let order: Vec = ((current_depth + 1)..=max_depth) + .chain(0..=current_depth) + .collect(); + next_nonempty_depth(order, &rows) + .and_then(|depth| rows.get(&depth)) + .and_then(|nodes| nodes.last()) + .copied() + }) + } + NavDirection::Left => { + let prev_on_row = rows + .get(¤t_depth) + .and_then(|nodes| current_pos.checked_sub(1).and_then(|pos| nodes.get(pos))) + .copied(); + prev_on_row.or_else(|| { + let order: Vec = (0..current_depth) + .rev() + .chain((current_depth..=max_depth).rev()) + .collect(); + next_nonempty_depth(order, &rows) + .and_then(|depth| rows.get(&depth)) + .and_then(|nodes| nodes.first()) + .copied() + }) + } + NavDirection::Down => { + let order: Vec = ((current_depth + 1)..=max_depth) + .chain(0..=current_depth) + .collect(); + next_nonempty_depth(order, &rows) + .and_then(|depth| rows.get(&depth)) + .and_then(|nodes| { + nodes.iter().copied().min_by(|a, b| { + let ax = (points[*a].0 - cx).abs(); + let bx = (points[*b].0 - cx).abs(); + ax.total_cmp(&bx) + }) + }) + } + NavDirection::Up => { + let order: Vec = (0..current_depth) + .rev() + .chain((current_depth..=max_depth).rev()) + .collect(); + next_nonempty_depth(order, &rows) + .and_then(|depth| rows.get(&depth)) + .and_then(|nodes| { + nodes.iter().copied().min_by(|a, b| { + let ax = (points[*a].0 - cx).abs(); + let bx = (points[*b].0 - cx).abs(); + ax.total_cmp(&bx) + }) + }) + } + }; + } + + if let Some(idx) = best_idx { + app.selected_worktree = idx; + } +} + +fn move_worktree_level_siblings(app: &mut App, move_right: bool) { + if app.worktrees.len() < 2 || app.selected_worktree >= app.worktrees.len() { + return; + } + + let root_branch = current_session_branch(app); + let parents = worktree_parent_map(&app.worktrees, root_branch.as_str()); + let depths = graph_depths(&parents); + let points = graph_layout(&parents); + let current = app.selected_worktree; + let current_depth = depths[current]; + let (cx, cy) = points[current]; + + let mut best_idx: Option = None; + let mut best_score = f32::MAX; + for (idx, (x, y)) in points.iter().enumerate() { + if idx == current || depths[idx] != current_depth { + continue; + } + + let dx = *x - cx; + let in_direction = if move_right { dx > 0.02 } else { dx < -0.02 }; + if !in_direction { + continue; + } + + let score = dx.abs() + ((*y - cy).abs() * 1.4); + if score < best_score { + best_score = score; + best_idx = Some(idx); + } + } + + if let Some(idx) = best_idx { + app.selected_worktree = idx; + } +} + +fn move_worktree_level_vertical(app: &mut App, move_up: bool) { + if app.worktrees.len() < 2 || app.selected_worktree >= app.worktrees.len() { + return; + } + + let root_branch = current_session_branch(app); + let parents = worktree_parent_map(&app.worktrees, root_branch.as_str()); + let depths = graph_depths(&parents); + let points = graph_layout(&parents); + let current = app.selected_worktree; + let current_depth = depths[current]; + let (cx, cy) = points[current]; + + if move_up { + if let Some(parent_idx) = parents.get(current).and_then(|parent| *parent) { + app.selected_worktree = parent_idx; + return; + } + + if current_depth == 0 { + return; + } + } + + let target_depth = if move_up { + current_depth.saturating_sub(1) + } else { + current_depth + 1 + }; + + let mut best_idx: Option = None; + let mut best_score = f32::MAX; + + for (idx, depth) in depths.iter().enumerate() { + if idx == current || *depth != target_depth { + continue; + } + + if !move_up && parents.get(idx).copied().flatten() != Some(current) { + continue; + } + + let (x, y) = points[idx]; + let score = (x - cx).abs() + (y - cy).abs(); + if score < best_score { + best_score = score; + best_idx = Some(idx); + } + } + + if best_idx.is_none() && !move_up { + for (idx, depth) in depths.iter().enumerate() { + if idx == current || *depth != target_depth { + continue; + } + + let (x, y) = points[idx]; + let score = (x - cx).abs() + (y - cy).abs(); + if score < best_score { + best_score = score; + best_idx = Some(idx); + } + } + } + + if let Some(idx) = best_idx { + app.selected_worktree = idx; + } +} + +fn create_root_for_app(app: &App) -> String { + app.selected_worktree() + .and_then(|wt| repo_container_from_path(wt.path.as_str())) + .or_else(|| repo_container_from_path(".")) + .or_else(repo_root) + .unwrap_or_else(|| ".".to_string()) +} + +fn branch_exists(root: &str, branch: &str) -> bool { + Command::new("git") + .args([ + "-C", + root, + "show-ref", + "--verify", + "--quiet", + &format!("refs/heads/{}", branch), + ]) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +fn delete_branch_and_create_worktree( + app: &App, + root: &str, + branch: &str, +) -> Result> { + let delete = Command::new("git") + .args(["-C", root, "branch", "-D", branch]) + .output()?; + if !delete.status.success() { + let stderr = sanitize_for_tui(String::from_utf8_lossy(&delete.stderr).as_ref()) + .trim() + .to_string(); + let stdout = sanitize_for_tui(String::from_utf8_lossy(&delete.stdout).as_ref()) + .trim() + .to_string(); + let reason = if !stderr.is_empty() { stderr } else { stdout }; + return Ok(format!("Failed deleting branch '{}': {}", branch, reason)); + } + + create_worktree(app, branch) +} + +fn create_worktree(app: &App, branch: &str) -> Result> { + let sanitized = branch.replace('/', "-"); + let root = create_root_for_app(app); + let container = format!("{}/.gitfetch-worktrees", root); + let _ = fs::create_dir_all(container.as_str()); + let path = format!("{}/.gitfetch-worktrees/{}", root, sanitized); + if Path::new(path.as_str()).exists() { + return Ok(format!( + "Target path already exists: {} (pick another branch name)", + path + )); + } + let (start_point, parent_branch, source_path) = worktree_create_source(app); + + let output = Command::new("git") + .args([ + "-C", + root.as_str(), + "worktree", + "add", + "-b", + branch, + path.as_str(), + start_point.as_str(), + ]) + .output()?; + let stdout = sanitize_for_tui(String::from_utf8_lossy(&output.stdout).as_ref()) + .trim() + .to_string(); + let stderr = sanitize_for_tui(String::from_utf8_lossy(&output.stderr).as_ref()) + .trim() + .to_string(); + + if output.status.success() { + let verified = Command::new("git") + .args(["-C", root.as_str(), "worktree", "list", "--porcelain"]) + .output() + .ok() + .map(|out| sanitize_for_tui(String::from_utf8_lossy(&out.stdout).as_ref())) + .map(|list| { + list.lines() + .any(|line| line.trim() == format!("worktree {}", path).as_str()) + }) + .unwrap_or(false); + + let mut message = if stdout.is_empty() { + format!( + "Created worktree '{}' at {} from {}", + branch, path, start_point + ) + } else { + stdout + }; + + if app.new_worktree_base == WorktreeCreateBase::SelectedWithChanges { + if let Some(diff) = capture_uncommitted_patch(source_path.as_str())? { + if diff.trim().is_empty() { + message.push_str(" (no uncommitted tracked changes to apply)"); + } else { + let apply_result = run_git_with_input( + &["-C", path.as_str(), "apply", "--whitespace=nowarn", "-"], + diff.as_bytes(), + )?; + if apply_result.success { + message.push_str(" + carried uncommitted tracked changes"); + } else { + message.push_str(" (created, but failed to apply uncommitted changes)"); + if !apply_result.stderr.is_empty() { + message.push_str(": "); + message.push_str(apply_result.stderr.as_str()); + } + } + } + } + } + + let _ = save_parent_hint(root.as_str(), branch, parent_branch.as_str()); + + if !verified { + message.push_str( + " (warning: creation reported success but worktree was not found in list)", + ); + } + + Ok(message) + } else if !stderr.is_empty() { + let tail = stderr + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or(stderr.as_str()) + .to_string(); + Ok(format!( + "Failed creating worktree '{}' from '{}': {}", + branch, start_point, tail + )) + } else if !stdout.is_empty() { + Ok(stdout) + } else { + Ok("git worktree add failed".to_string()) + } +} + +fn repo_root() -> Option { + let output = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let root = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if root.is_empty() { + None + } else { + Some(root) + } +} + +fn repo_container_from_path(path: &str) -> Option { + let output = Command::new("git") + .args(["-C", path, "rev-parse", "--git-common-dir"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if raw.is_empty() { + None + } else { + let common_dir = if Path::new(raw.as_str()).is_absolute() { + PathBuf::from(raw) + } else { + Path::new(path).join(raw) + }; + let common_abs = fs::canonicalize(common_dir.as_path()).unwrap_or(common_dir); + let parent = common_abs.parent()?.to_string_lossy().to_string(); + if parent.is_empty() { + None + } else { + Some(parent) + } + } +} + +fn parent_hint_map_path(root: &str) -> String { + format!("{}/.gitfetch-worktrees/.parent-hints", root) +} + +fn load_parent_hint_map(root: &str) -> BTreeMap { + let mut map = BTreeMap::new(); + let content = match fs::read_to_string(parent_hint_map_path(root)) { + Ok(v) => v, + Err(_) => return map, + }; + + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if let Some((child, parent)) = trimmed.split_once('\t') { + let c = child.trim(); + let p = parent.trim(); + if !c.is_empty() && !p.is_empty() { + map.insert(c.to_string(), p.to_string()); + } + } + } + + map +} + +fn save_parent_hint( + root: &str, + child_branch: &str, + parent_branch: &str, +) -> Result<(), Box> { + let mut map = load_parent_hint_map(root); + map.insert(child_branch.to_string(), parent_branch.to_string()); + + let mut lines = String::new(); + for (child, parent) in map { + lines.push_str(child.as_str()); + lines.push('\t'); + lines.push_str(parent.as_str()); + lines.push('\n'); + } + + fs::write(parent_hint_map_path(root), lines)?; + Ok(()) +} + +fn selected_branch_name(app: &App) -> String { + if let Some(selected) = app.selected_worktree() { + if !selected.detached && !selected.branch.is_empty() { + return selected.branch.clone(); + } + if !selected.head.is_empty() { + return selected.head.clone(); + } + } + + let raw = app.branch.trim(); + let name = raw + .strip_prefix("HEAD (detached at ") + .and_then(|value| value.strip_suffix(')')) + .unwrap_or(raw); + if name.is_empty() { + "HEAD".to_string() + } else { + name.to_string() + } +} + +fn worktree_create_source(app: &App) -> (String, String, String) { + match app.new_worktree_base { + WorktreeCreateBase::Main => { + let main = resolve_main_branch(); + (main.clone(), main, ".".to_string()) + } + WorktreeCreateBase::Selected | WorktreeCreateBase::SelectedWithChanges => { + let selected = selected_branch_name(app); + let source_path = app + .selected_worktree() + .map(|wt| wt.path.clone()) + .unwrap_or_else(|| ".".to_string()); + (selected.clone(), selected, source_path) + } + } +} + +fn resolve_main_branch() -> String { + if let Some(main) = git_output(&["show-ref", "--verify", "--quiet", "refs/heads/main"]) { + if main.is_empty() { + return "main".to_string(); + } + } + if let Some(master) = git_output(&["show-ref", "--verify", "--quiet", "refs/heads/master"]) { + if master.is_empty() { + return "master".to_string(); + } + } + "main".to_string() +} + +fn capture_uncommitted_patch(source_path: &str) -> Result, Box> { + let output = Command::new("git") + .args(["-C", source_path, "diff", "--binary", "HEAD"]) + .output()?; + if !output.status.success() { + return Ok(None); + } + Ok(Some(sanitize_for_tui( + String::from_utf8_lossy(&output.stdout).as_ref(), + ))) +} + +struct CommandResult { + success: bool, + stderr: String, +} + +fn run_git_with_input(args: &[&str], input: &[u8]) -> Result> { + let mut child = Command::new("git") + .args(args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + + if let Some(stdin) = child.stdin.as_mut() { + stdin.write_all(input)?; + } + + let output = child.wait_with_output()?; + Ok(CommandResult { + success: output.status.success(), + stderr: sanitize_for_tui(String::from_utf8_lossy(&output.stderr).as_ref()) + .trim() + .to_string(), + }) +} + +fn parse_branch_line(app: &mut App, line: &str) { + let (branch, ahead, behind) = parse_branch_snapshot(line); + app.branch = branch; + app.ahead = ahead; + app.behind = behind; +} + +fn toggle_stage(app: &mut App) -> Result<(), Box> { + let item = match app.selected_item() { + Some(entry) => entry, + None => { + app.status_line = "No item selected".to_string(); + return Ok(()); + } + }; + + if item.staged { + app.status_line = run_git(&["restore", "--staged", "--", &item.path])?; + } else { + app.status_line = run_git(&["add", "--", &item.path])?; + } + + Ok(()) +} + +fn refresh_selected_overview(app: &mut App) { + let item = match app.selected_item() { + Some(entry) => entry, + None => { + app.selected_overview = None; + app.overview_scroll = 0; + return; + } + }; + + app.selected_overview = match item.kind { + TreeKind::File => Some(build_file_overview(&FileEntry { + path: item.path.clone(), + staged: item.staged, + unstaged: item.unstaged, + untracked: item.untracked, + })), + TreeKind::Folder => Some(build_folder_overview(item, &app.files)), + }; + + let max_scroll = max_overview_scroll(app); + if app.overview_scroll > max_scroll { + app.overview_scroll = max_scroll; + } +} + +fn build_folder_overview(folder: &TreeItem, files: &[FileEntry]) -> FileOverview { + let prefix = format!("{}/", folder.path); + let mut total_added = 0usize; + let mut total_removed = 0usize; + let mut methods_added: HashSet = HashSet::new(); + let mut methods_modified: HashSet = HashSet::new(); + let mut methods_deleted: HashSet = HashSet::new(); + let mut traditional_diff: Vec = Vec::new(); + + for file in files { + if !(file.path == folder.path || file.path.starts_with(prefix.as_str())) { + continue; + } + + let overview = build_file_overview(file); + total_added += overview.added_lines; + total_removed += overview.removed_lines; + methods_added.extend(overview.methods_added); + methods_modified.extend(overview.methods_modified); + methods_deleted.extend(overview.methods_deleted); + + if traditional_diff.len() < 24 { + traditional_diff.push(DiffPreviewLine { + kind: DiffPreviewKind::Meta, + text: format!("file: {}", file.path), + }); + for row in overview.traditional_diff.into_iter().take(6) { + if traditional_diff.len() >= 24 { + break; + } + traditional_diff.push(row); + } + } + } + + let methods_added = sorted_from_set(methods_added); + let methods_modified = sorted_from_set(methods_modified); + let methods_deleted = sorted_from_set(methods_deleted); + let use_traditional_overview = + methods_added.is_empty() && methods_modified.is_empty() && methods_deleted.is_empty(); + + FileOverview { + file: format!("{}/", folder.path), + state: build_state_label(&FileEntry { + path: folder.path.clone(), + staged: folder.staged, + unstaged: folder.unstaged, + untracked: folder.untracked, + }), + added_lines: total_added, + removed_lines: total_removed, + methods_added, + methods_modified, + methods_deleted, + traditional_diff, + use_traditional_overview, + } +} + +fn build_file_overview(file: &FileEntry) -> FileOverview { + let state = build_state_label(file); + let mut added_lines = 0usize; + let mut removed_lines = 0usize; + let mut methods_added: Vec = Vec::new(); + let mut methods_modified: Vec = Vec::new(); + let mut methods_deleted: Vec = Vec::new(); + let mut traditional_diff: Vec = Vec::new(); + + if file.untracked { + let text = fs::read_to_string(&file.path).unwrap_or_default(); + added_lines = text.lines().count(); + methods_added = sorted_from_set(collect_methods_from_content(&text, &file.path)); + traditional_diff = preview_for_untracked(&text); + } else if let Some(diff) = git_output(&[ + "diff", + "--no-color", + "--unified=0", + "HEAD", + "--", + &file.path, + ]) { + let summary = summarize_diff(&diff, &file.path); + added_lines = summary.added_lines; + removed_lines = summary.removed_lines; + methods_added = summary.methods_added; + methods_modified = summary.methods_modified; + methods_deleted = summary.methods_deleted; + traditional_diff = summary.diff_preview; + } + + let use_traditional_overview = + methods_added.is_empty() && methods_modified.is_empty() && methods_deleted.is_empty(); + + FileOverview { + file: file.path.clone(), + state, + added_lines, + removed_lines, + methods_added, + methods_modified, + methods_deleted, + traditional_diff, + use_traditional_overview, + } +} + +fn build_state_label(file: &FileEntry) -> String { + let mut states: Vec<&str> = Vec::new(); + if file.staged { + states.push("staged"); + } + if file.unstaged { + states.push("unstaged"); + } + if file.untracked { + states.push("new"); + } + if states.is_empty() { + "clean".to_string() + } else { + states.join(", ") + } +} + +#[derive(Default)] +struct DiffSummary { + added_lines: usize, + removed_lines: usize, + methods_added: Vec, + methods_modified: Vec, + methods_deleted: Vec, + diff_preview: Vec, +} + +fn summarize_diff(diff: &str, file_path: &str) -> DiffSummary { + let mut added_methods: HashSet = HashSet::new(); + let mut removed_methods: HashSet = HashSet::new(); + let mut modified_hunks: HashSet = HashSet::new(); + let mut diff_preview: Vec = Vec::new(); + let mut current_hunk_method: Option = None; + let mut added_lines = 0usize; + let mut removed_lines = 0usize; + + for line in diff.lines() { + if line.starts_with("@@") { + current_hunk_method = parse_hunk_header(line) + .and_then(|header| extract_method_name(header.as_str(), file_path)); + push_preview_line(&mut diff_preview, DiffPreviewKind::Meta, line); + continue; + } + + if line.starts_with("+++") || line.starts_with("---") { + push_preview_line(&mut diff_preview, DiffPreviewKind::Meta, line); + continue; + } + + if line.starts_with("diff --git") || line.starts_with("index ") { + push_preview_line(&mut diff_preview, DiffPreviewKind::Meta, line); + continue; + } + + if let Some(rest) = line.strip_prefix('+') { + added_lines += 1; + if let Some(name) = current_hunk_method.as_ref() { + modified_hunks.insert(name.clone()); + } + if let Some(name) = extract_method_name(rest, file_path) { + added_methods.insert(name); + } + push_preview_line(&mut diff_preview, DiffPreviewKind::Added, line); + continue; + } + + if let Some(rest) = line.strip_prefix('-') { + removed_lines += 1; + if let Some(name) = current_hunk_method.as_ref() { + modified_hunks.insert(name.clone()); + } + if let Some(name) = extract_method_name(rest, file_path) { + removed_methods.insert(name); + } + push_preview_line(&mut diff_preview, DiffPreviewKind::Removed, line); + continue; + } + + push_preview_line(&mut diff_preview, DiffPreviewKind::Context, line); + } + + let methods_added_set: HashSet = added_methods + .difference(&removed_methods) + .cloned() + .collect(); + let methods_deleted_set: HashSet = removed_methods + .difference(&added_methods) + .cloned() + .collect(); + let overlap_set: HashSet = added_methods + .intersection(&removed_methods) + .cloned() + .collect(); + let methods_modified_set: HashSet = modified_hunks + .union(&overlap_set) + .cloned() + .filter(|name| !methods_added_set.contains(name) && !methods_deleted_set.contains(name)) + .collect(); + + DiffSummary { + added_lines, + removed_lines, + methods_added: sorted_from_set(methods_added_set), + methods_modified: sorted_from_set(methods_modified_set), + methods_deleted: sorted_from_set(methods_deleted_set), + diff_preview, + } +} + +fn push_preview_line(lines: &mut Vec, kind: DiffPreviewKind, raw: &str) { + if lines.len() >= 28 { + return; + } + lines.push(DiffPreviewLine { + kind, + text: truncate_text(raw, 96), + }); +} + +fn preview_for_untracked(content: &str) -> Vec { + let mut lines = Vec::new(); + for (idx, line) in content.lines().enumerate() { + if idx >= 24 { + break; + } + lines.push(DiffPreviewLine { + kind: DiffPreviewKind::Added, + text: format!("+{}", truncate_text(line, 95)), + }); + } + lines +} + +fn sorted_from_set(set: HashSet) -> Vec { + let mut v: Vec = set.into_iter().collect(); + v.sort(); + v +} + +fn parse_hunk_header(line: &str) -> Option { + let mut parts = line.split("@@"); + let _ = parts.next(); + let tail = parts.nth(1).unwrap_or_default().trim(); + if tail.is_empty() { + None + } else { + Some(tail.to_string()) + } +} + +fn collect_methods_from_content(content: &str, file_path: &str) -> HashSet { + let mut methods = HashSet::new(); + for line in content.lines() { + if let Some(name) = extract_method_name(line, file_path) { + methods.insert(name); + } + } + methods +} + +fn extract_method_name(line: &str, file_path: &str) -> Option { + let s = line.trim_start(); + let ext = file_extension(file_path); + + match ext { + "py" => extract_python_method(s), + "rs" => extract_rust_method(s), + "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" => extract_js_method(s), + "go" => extract_go_method(s), + _ => extract_general_method(s), + } +} + +fn file_extension(path: &str) -> &str { + path.rsplit('.').next().unwrap_or_default() +} + +fn extract_python_method(s: &str) -> Option { + if let Some(rest) = s.strip_prefix("def ") { + return extract_identifier_until_paren(rest); + } + if let Some(rest) = s.strip_prefix("async def ") { + return extract_identifier_until_paren(rest); + } + None +} + +fn extract_rust_method(s: &str) -> Option { + if let Some(idx) = s.find(" fn ") { + return extract_identifier_until_paren(&s[idx + 4..]); + } + if let Some(rest) = s.strip_prefix("fn ") { + return extract_identifier_until_paren(rest); + } + None +} + +fn extract_js_method(s: &str) -> Option { + if let Some(rest) = s.strip_prefix("function ") { + return extract_identifier_until_paren(rest); + } + if let Some(rest) = s.strip_prefix("async function ") { + return extract_identifier_until_paren(rest); + } + if let Some(rest) = s.strip_prefix("const ") { + if rest.contains("=>") { + let (left, _) = rest.split_once('=').unwrap_or(("", "")); + let ident = left.trim(); + if is_identifier_like(ident) { + return Some(ident.to_string()); + } + } + } + None +} + +fn extract_go_method(s: &str) -> Option { + if let Some(rest) = s.strip_prefix("func ") { + if rest.starts_with('(') { + let after_receiver = rest.split(')').nth(1).unwrap_or_default().trim_start(); + return extract_identifier_until_paren(after_receiver); + } + return extract_identifier_until_paren(rest); + } + None +} + +fn extract_general_method(s: &str) -> Option { + if let Some(rest) = s.strip_prefix("function ") { + return extract_identifier_until_paren(rest); + } + if let Some(rest) = s.strip_prefix("def ") { + return extract_identifier_until_paren(rest); + } + None +} + +fn extract_identifier_until_paren(text: &str) -> Option { + let name = text + .split('(') + .next() + .unwrap_or_default() + .trim() + .trim_end_matches('{') + .trim(); + + if !is_identifier_like(name) { + None + } else { + Some(name.to_string()) + } +} + +fn is_identifier_like(value: &str) -> bool { + if value.is_empty() { + return false; + } + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first == '_' || first.is_ascii_alphabetic()) { + return false; + } + chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) +} + +fn sanitize_for_tui(input: &str) -> String { + let mut out = String::new(); + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\u{1b}' { + match chars.peek().copied() { + Some('[') => { + let _ = chars.next(); + while let Some(c) = chars.next() { + if ('@'..='~').contains(&c) { + break; + } + } + continue; + } + Some(']') => { + let _ = chars.next(); + while let Some(c) = chars.next() { + if c == '\u{7}' { + break; + } + if c == '\u{1b}' { + if let Some('\\') = chars.peek().copied() { + let _ = chars.next(); + break; + } + } + } + continue; + } + _ => continue, + } + } + + if ch == '\n' || (ch >= ' ' && ch != '\u{7f}') { + out.push(ch); + } else if ch == '\t' { + out.push_str(" "); + } + } + + out +} + +fn run_git(args: &[&str]) -> Result> { + let output = Command::new("git").args(args).output()?; + let stdout = sanitize_for_tui(String::from_utf8_lossy(&output.stdout).as_ref()) + .trim() + .to_string(); + let stderr = sanitize_for_tui(String::from_utf8_lossy(&output.stderr).as_ref()) + .trim() + .to_string(); + + if output.status.success() { + if stdout.is_empty() { + Ok(format!("✓ git {}", args.join(" "))) + } else { + Ok(stdout) + } + } else if !stderr.is_empty() { + Ok(stderr) + } else { + Ok(format!("git {} failed", args.join(" "))) + } +} + +fn git_output(args: &[&str]) -> Option { + let output = Command::new("git").args(args).output().ok()?; + if !output.status.success() { + return None; + } + Some(sanitize_for_tui( + String::from_utf8_lossy(&output.stdout).as_ref(), + )) +} + +fn push_with_upstream() -> Result> { + let first = Command::new("git").args(["push"]).output()?; + let first_stdout = sanitize_for_tui(String::from_utf8_lossy(&first.stdout).as_ref()) + .trim() + .to_string(); + let first_stderr = sanitize_for_tui(String::from_utf8_lossy(&first.stderr).as_ref()) + .trim() + .to_string(); + + if first.status.success() { + if first_stdout.is_empty() { + return Ok("✓ git push".to_string()); + } + return Ok(first_stdout); + } + + let error_text = if !first_stderr.is_empty() { + first_stderr.clone() + } else { + first_stdout.clone() + }; + + let needs_upstream = error_text.contains("has no upstream branch") + || error_text.contains("--set-upstream") + || error_text.contains("set upstream"); + + if !needs_upstream { + if error_text.is_empty() { + return Ok("git push failed".to_string()); + } + return Ok(error_text); + } + + let remote = preferred_remote()?; + let second = Command::new("git") + .args(["push", "-u", remote.as_str(), "HEAD"]) + .output()?; + let second_stdout = sanitize_for_tui(String::from_utf8_lossy(&second.stdout).as_ref()) + .trim() + .to_string(); + let second_stderr = sanitize_for_tui(String::from_utf8_lossy(&second.stderr).as_ref()) + .trim() + .to_string(); + + if second.status.success() { + if second_stdout.is_empty() { + Ok(format!("✓ git push -u {} HEAD", remote)) + } else { + Ok(format!( + "Set upstream to {} and pushed\n{}", + remote, second_stdout + )) + } + } else if !second_stderr.is_empty() { + Ok(second_stderr) + } else if !second_stdout.is_empty() { + Ok(second_stdout) + } else { + Ok(format!("git push -u {} HEAD failed", remote)) + } +} + +fn commit_and_push_worktree(path: &str, message: &str) -> Result> { + let add = Command::new("git") + .args(["-C", path, "add", "."]) + .output()?; + if !add.status.success() { + let stderr = sanitize_for_tui(String::from_utf8_lossy(&add.stderr).as_ref()) + .trim() + .to_string(); + let stdout = sanitize_for_tui(String::from_utf8_lossy(&add.stdout).as_ref()) + .trim() + .to_string(); + let reason = if !stderr.is_empty() { stderr } else { stdout }; + return Ok(format!( + "git add failed in {}: {}", + path, + single_line(reason.as_str()) + )); + } + + let commit = Command::new("git") + .args(["-C", path, "commit", "-m", message]) + .output()?; + let commit_stdout = sanitize_for_tui(String::from_utf8_lossy(&commit.stdout).as_ref()) + .trim() + .to_string(); + let commit_stderr = sanitize_for_tui(String::from_utf8_lossy(&commit.stderr).as_ref()) + .trim() + .to_string(); + + let nothing_to_commit = !commit.status.success() + && (commit_stdout.contains("nothing to commit") + || commit_stderr.contains("nothing to commit") + || commit_stderr.contains("no changes added to commit")); + + if !commit.status.success() && !nothing_to_commit { + let reason = if !commit_stderr.is_empty() { + commit_stderr + } else { + commit_stdout + }; + return Ok(format!( + "git commit failed in {}: {}", + path, + single_line(reason.as_str()) + )); + } + + let push = push_with_upstream_at(path)?; + if nothing_to_commit { + Ok(format!( + "No new commit in {}; pushed current HEAD - {}", + path, + single_line(push.as_str()) + )) + } else { + let commit_line = if !commit_stdout.is_empty() { + single_line(commit_stdout.as_str()) + } else if !commit_stderr.is_empty() { + single_line(commit_stderr.as_str()) + } else { + "commit ok".to_string() + }; + Ok(format!( + "Committed+Pushed in {} - {} | {}", + path, + commit_line, + single_line(push.as_str()) + )) + } +} + +fn push_with_upstream_at(path: &str) -> Result> { + let remote = preferred_remote_at(path)?; + let push = Command::new("git") + .args(["-C", path, "push", "-u", remote.as_str(), "HEAD"]) + .output()?; + let push_stdout = sanitize_for_tui(String::from_utf8_lossy(&push.stdout).as_ref()) + .trim() + .to_string(); + let push_stderr = sanitize_for_tui(String::from_utf8_lossy(&push.stderr).as_ref()) + .trim() + .to_string(); + + if push.status.success() { + Ok(if push_stdout.is_empty() { + format!("✓ git push -u {} HEAD", remote) + } else { + format!("Set upstream to {} and pushed\n{}", remote, push_stdout) + }) + } else if !push_stderr.is_empty() { + Ok(push_stderr) + } else if !push_stdout.is_empty() { + Ok(push_stdout) + } else { + Ok(format!("git push -u {} HEAD failed", remote)) + } +} + +fn preferred_remote_at(path: &str) -> Result> { + let output = Command::new("git").args(["-C", path, "remote"]).output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + let remotes: Vec<&str> = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect(); + + if remotes.iter().any(|name| *name == "origin") { + Ok("origin".to_string()) + } else if let Some(first) = remotes.first() { + Ok((*first).to_string()) + } else { + Ok("origin".to_string()) + } +} + +fn preferred_remote() -> Result> { + let output = Command::new("git").args(["remote"]).output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + let remotes: Vec<&str> = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect(); + + if remotes.iter().any(|name| *name == "origin") { + Ok("origin".to_string()) + } else if let Some(first) = remotes.first() { + Ok((*first).to_string()) + } else { + Ok("origin".to_string()) + } +} + +fn draw_ui(frame: &mut ratatui::Frame<'_>, app: &App) { + frame.render_widget(Clear, frame.area()); + frame.render_widget( + Block::default().style(Style::default().bg(Color::Black).fg(Color::White)), + frame.area(), + ); + + if matches!(app.mode, Mode::AgentPopup) { + draw_agent_popup(frame, app); + return; + } + + if app.view_mode == ViewMode::Changes { + let columns = Layout::default() + .direction(Direction::Horizontal) + .spacing(1) + .constraints([ + Constraint::Percentage(22), + Constraint::Percentage(56), + Constraint::Percentage(22), + ]) + .split(frame.area()); + + let right = Layout::default() + .direction(Direction::Vertical) + .spacing(1) + .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) + .split(columns[2]); + + draw_files_panel(frame, app, columns[0]); + draw_selected_overview_panel(frame, app, columns[1]); + draw_pulse_panel(frame, app, right[0]); + draw_changes_actions_panel(frame, right[1]); + } else { + let columns = Layout::default() + .direction(Direction::Horizontal) + .spacing(1) + .constraints([Constraint::Percentage(72), Constraint::Percentage(28)]) + .split(frame.area()); + + let right = Layout::default() + .direction(Direction::Vertical) + .spacing(1) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(columns[1]); + + draw_worktree_canvas_panel(frame, app, columns[0]); + draw_worktree_details_panel(frame, app, right[0]); + draw_worktree_actions_panel(frame, app, right[1]); + } + + if matches!(app.mode, Mode::CommitInput) { + draw_commit_modal(frame, app); + } + + if matches!(app.mode, Mode::WorktreeCommitPushInput) { + draw_worktree_commit_push_modal(frame, app); + } + + if matches!(app.mode, Mode::WorktreeCreateInput) { + draw_worktree_create_modal(frame, app); + } + + if matches!(app.mode, Mode::WorktreeBranchConflictConfirm) { + draw_branch_conflict_confirm_modal(frame, app); + } + + if matches!(app.mode, Mode::QuitWithSessionsConfirm) { + draw_quit_with_sessions_modal(frame, app); + } + + if app.view_mode == ViewMode::Worktrees && app.show_panel_help { + draw_worktree_help_modal(frame, app); + } +} + +fn draw_files_panel(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) { + frame.render_widget(Clear, area); + + let content_width = area.width.saturating_sub(6) as usize; + let mut items: Vec> = Vec::new(); + let mut index_map: Vec> = Vec::new(); + let count_width = app + .tree_items + .iter() + .map(|item| { + item.added_lines + .max(item.removed_lines) + .to_string() + .chars() + .count() + }) + .max() + .unwrap_or(1) + .max(4); + + let unstaged_indices: Vec = app + .tree_items + .iter() + .enumerate() + .filter(|(_, item)| item.unstaged || item.untracked) + .map(|(idx, _)| idx) + .collect(); + let staged_indices: Vec = app + .tree_items + .iter() + .enumerate() + .filter(|(_, item)| item.staged) + .map(|(idx, _)| idx) + .collect(); + + push_section_header( + &mut items, + &mut index_map, + "unstaged", + unstaged_indices.len(), + ); + if unstaged_indices.is_empty() { + items.push(ListItem::new(Line::from(Span::styled( + " clean", + Style::default().fg(Color::DarkGray), + )))); + index_map.push(None); + } else { + for idx in unstaged_indices { + push_tree_row( + &mut items, + &mut index_map, + idx, + &app.tree_items[idx], + content_width, + count_width, + ); + } + } + + items.push(ListItem::new(Line::from(""))); + index_map.push(None); + + push_section_header(&mut items, &mut index_map, "staged", staged_indices.len()); + if staged_indices.is_empty() { + items.push(ListItem::new(Line::from(Span::styled( + " clean", + Style::default().fg(Color::DarkGray), + )))); + index_map.push(None); + } else { + for idx in staged_indices { + push_tree_row( + &mut items, + &mut index_map, + idx, + &app.tree_items[idx], + content_width, + count_width, + ); + } + } + + let selected_render_idx = index_map + .iter() + .position(|mapped| *mapped == Some(app.selected)); + + let mut state = ListState::default(); + if let Some(idx) = selected_render_idx { + state.select(Some(idx)); + } + + let border_color = if app.active_pane == ActivePane::Files { + Color::Cyan + } else { + Color::Gray + }; + + let list = List::new(items) + .block( + Block::default() + .title("changed files") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)) + .border_style(Style::default().fg(border_color)), + ) + .highlight_style( + Style::default() + .fg(Color::White) + .bg(Color::Rgb(42, 58, 86)) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ ") + .style(Style::default().bg(Color::Black)); + + frame.render_stateful_widget(list, area, &mut state); +} + +fn push_section_header( + items: &mut Vec>, + index_map: &mut Vec>, + title: &str, + count: usize, +) { + items.push(ListItem::new(Line::from(vec![Span::styled( + format!("{} ({})", title, count), + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::BOLD), + )]))); + index_map.push(None); +} + +fn push_tree_row( + items: &mut Vec>, + index_map: &mut Vec>, + idx: usize, + item: &TreeItem, + content_width: usize, + count_width: usize, +) { + let mut spans: Vec> = Vec::new(); + let name_color = if item.kind == TreeKind::Folder { + Color::LightYellow + } else { + Color::LightCyan + }; + + let plus_text = format!("+{:>width$}", item.added_lines, width = count_width); + let minus_text = format!("-{:>width$}", item.removed_lines, width = count_width); + let right_len = plus_text.chars().count() + 1 + minus_text.chars().count(); + let label_col = content_width.saturating_sub(right_len).max(8); + let label = truncate_text(item.label.as_str(), label_col); + let padding = label_col.saturating_sub(label.chars().count()); + + spans.push(Span::styled(label, Style::default().fg(name_color))); + spans.push(Span::raw(" ".repeat(padding))); + spans.push(Span::styled(plus_text, Style::default().fg(Color::Green))); + spans.push(Span::raw(" ")); + spans.push(Span::styled(minus_text, Style::default().fg(Color::Red))); + + items.push(ListItem::new(Line::from(spans))); + index_map.push(Some(idx)); +} + +fn draw_selected_overview_panel(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) { + frame.render_widget(Clear, area); + + let info = app.selected_overview.as_ref(); + + let mut lines: Vec> = Vec::new(); + if let Some(info) = info { + lines.push(Line::from(vec![ + Span::styled("file: ", Style::default().fg(Color::Gray)), + Span::styled(info.file.as_str(), Style::default().fg(Color::White)), + ])); + lines.push(Line::from(vec![ + Span::styled("state: ", Style::default().fg(Color::Gray)), + Span::styled(info.state.as_str(), Style::default().fg(Color::Cyan)), + ])); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "files changes", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )])); + lines.push(Line::from(vec![ + Span::styled( + "+", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + info.added_lines.to_string(), + Style::default().fg(Color::Green), + ), + Span::raw(" "), + Span::styled( + "-", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::styled( + info.removed_lines.to_string(), + Style::default().fg(Color::Red), + ), + ])); + lines.push(Line::from("")); + + if info.use_traditional_overview { + if info.traditional_diff.is_empty() { + lines.push(Line::from("No diff preview available")); + } else { + lines.push(Line::from("diff preview:")); + for row in info.traditional_diff.iter().take(24) { + let color = match row.kind { + DiffPreviewKind::Added => Color::Green, + DiffPreviewKind::Removed => Color::Red, + DiffPreviewKind::Meta => Color::Blue, + DiffPreviewKind::Context => Color::Gray, + }; + lines.push(Line::from(Span::styled( + row.text.as_str(), + Style::default().fg(color), + ))); + } + } + } else { + push_method_section( + &mut lines, + "methods added", + Color::LightGreen, + &info.methods_added, + ); + push_method_section( + &mut lines, + "methods modified", + Color::Yellow, + &info.methods_modified, + ); + push_method_section( + &mut lines, + "methods deleted", + Color::LightRed, + &info.methods_deleted, + ); + } + } else { + lines.push(Line::from("No changed file selected")); + } + + let panel = Paragraph::new(lines) + .scroll((app.overview_scroll, 0)) + .block( + Block::default() + .title("selected file overview") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)) + .border_style( + Style::default().fg(if app.active_pane == ActivePane::Overview { + Color::Cyan + } else { + Color::Gray + }), + ), + ) + .style(Style::default().bg(Color::Black).fg(Color::White)) + .alignment(Alignment::Left); + + frame.render_widget(panel, area); +} + +fn push_method_section(lines: &mut Vec>, title: &str, color: Color, names: &[String]) { + lines.push(Line::from(vec![Span::styled( + title.to_string(), + Style::default().fg(color), + )])); + if names.is_empty() { + lines.push(Line::from("- none")); + } else { + for name in names.iter().take(8) { + lines.push(Line::from(vec![ + Span::raw("- "), + Span::styled(truncate_text(name, 56), Style::default().fg(Color::White)), + ])); + } + } + lines.push(Line::from("")); +} + +fn draw_pulse_panel(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) { + let staged_count = app.files.iter().filter(|f| f.staged).count(); + let unstaged_count = app + .files + .iter() + .filter(|f| f.unstaged || f.untracked) + .count(); + + let status_limit = area.width.saturating_sub(12) as usize; + let status_text = truncate_text( + single_line(app.status_line.as_str()).as_str(), + status_limit.max(10), + ); + + let now = Instant::now(); + let mut active_sessions = 0usize; + let mut idle_sessions = 0usize; + let mut live_summary: Vec<(bool, u64, u64, String)> = app + .agent_sessions + .iter() + .filter_map(|(path, session)| { + if !agent_session_is_live(session) { + return None; + } + let is_active = agent_session_is_active(session, now); + if is_active { + active_sessions += 1; + } else { + idle_sessions += 1; + } + Some(( + is_active, + agent_session_avg_bps(session, now), + agent_session_idle_seconds(session, now), + session_label_from_path(path), + )) + }) + .collect(); + live_summary.sort_by(|a, b| b.cmp(a)); + + let info = vec![ + Line::from(vec![ + Span::styled("Branch: ", Style::default().fg(Color::Gray)), + Span::styled( + app.branch.as_str(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled("Ahead: ", Style::default().fg(Color::Gray)), + Span::styled(app.ahead.to_string(), Style::default().fg(Color::Green)), + Span::raw(" "), + Span::styled("Behind: ", Style::default().fg(Color::Gray)), + Span::styled(app.behind.to_string(), Style::default().fg(Color::Yellow)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Staged files: ", Style::default().fg(Color::Gray)), + Span::styled(staged_count.to_string(), Style::default().fg(Color::Green)), + ]), + Line::from(vec![ + Span::styled("Unstaged files: ", Style::default().fg(Color::Gray)), + Span::styled( + unstaged_count.to_string(), + Style::default().fg(Color::Yellow), + ), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("PTY sessions: ", Style::default().fg(Color::Gray)), + Span::styled( + format!("{} live", active_sessions + idle_sessions), + Style::default().fg(Color::LightCyan), + ), + Span::raw(" "), + Span::styled("active ", Style::default().fg(Color::Gray)), + Span::styled( + active_sessions.to_string(), + Style::default().fg(Color::Green), + ), + Span::raw(" "), + Span::styled("idle ", Style::default().fg(Color::Gray)), + Span::styled( + idle_sessions.to_string(), + Style::default().fg(Color::Yellow), + ), + ]), + Line::from({ + if live_summary.is_empty() { + vec![ + Span::styled("Live PTY: ", Style::default().fg(Color::Gray)), + Span::raw("none"), + ] + } else { + let mut spans = vec![Span::styled("Live PTY: ", Style::default().fg(Color::Gray))]; + for (idx, (is_active, bps, idle_secs, label)) in + live_summary.iter().take(2).enumerate() + { + if idx > 0 { + spans.push(Span::raw(" ")); + } + let mode = if *is_active { "A" } else { "I" }; + let color = if *is_active { + Color::LightGreen + } else { + Color::Yellow + }; + spans.push(Span::styled( + format!( + "{} {} {}B/s {}s", + truncate_text(label, 12), + mode, + bps, + idle_secs + ), + Style::default().fg(color), + )); + } + spans + } + }), + Line::from(""), + Line::from(Span::styled( + "Live refresh every ~700ms", + Style::default().fg(Color::Blue), + )), + Line::from(""), + Line::from(vec![ + Span::styled("Status: ", Style::default().fg(Color::Gray)), + Span::styled(status_text, Style::default().fg(Color::White)), + ]), + ]; + + let panel = Paragraph::new(info) + .block( + Block::default() + .title("pulse") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)) + .border_style(Style::default().fg(Color::Gray)), + ) + .style(Style::default().bg(Color::Black).fg(Color::White)) + .alignment(Alignment::Left); + + frame.render_widget(panel, area); +} + +fn draw_changes_actions_panel(frame: &mut ratatui::Frame<'_>, area: Rect) { + let lines = vec![ + Line::from(vec![ + Span::styled("w", Style::default().fg(Color::LightBlue)), + Span::raw(" worktree canvas"), + ]), + Line::from(vec![ + Span::styled("h/l", Style::default().fg(Color::LightBlue)), + Span::raw(" focus files/overview"), + ]), + Line::from(vec![ + Span::styled("j/k", Style::default().fg(Color::LightBlue)), + Span::raw(" move selection/scroll"), + ]), + Line::from(vec![ + Span::styled("space|enter", Style::default().fg(Color::LightGreen)), + Span::raw(" stage or unstage"), + ]), + Line::from(vec![ + Span::styled("c", Style::default().fg(Color::Yellow)), + Span::raw(" commit"), + ]), + Line::from(vec![ + Span::styled("p", Style::default().fg(Color::Magenta)), + Span::raw(" push"), + ]), + Line::from(vec![ + Span::styled("s", Style::default().fg(Color::LightYellow)), + Span::raw(" stash changes"), + ]), + Line::from(vec![ + Span::styled("S", Style::default().fg(Color::Yellow)), + Span::raw(" stash pop"), + ]), + Line::from(vec![ + Span::styled("r", Style::default().fg(Color::Cyan)), + Span::raw(" refresh"), + ]), + Line::from(vec![ + Span::styled("q", Style::default().fg(Color::Red)), + Span::raw(" quit"), + ]), + ]; + + let panel = Paragraph::new(lines) + .block( + Block::default() + .title("actions") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)) + .border_style(Style::default().fg(Color::Gray)), + ) + .style(Style::default().bg(Color::Black).fg(Color::White)) + .alignment(Alignment::Left); + + frame.render_widget(panel, area); +} + +fn draw_worktree_canvas_panel(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) { + let border_color = if app.worktree_focus == WorktreePane::Canvas { + Color::Cyan + } else { + Color::Gray + }; + let title = if app.worktree_canvas_zoom != 1.0 + || app.worktree_canvas_pan_x != 0.0 + || app.worktree_canvas_pan_y != 0.0 + { + format!("worktree graph z:{:.1}x", app.worktree_canvas_zoom) + } else { + "worktree graph".to_string() + }; + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .style(Style::default().bg(Color::Black)); + + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.width < 10 || inner.height < 6 { + return; + } + + let root_branch = current_session_branch(app); + + if app.worktrees.is_empty() { + frame.render_widget( + Paragraph::new("No worktrees. Press 'a' to create one.") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::DarkGray)), + inner, + ); + return; + } + + let parents = worktree_parent_map(&app.worktrees, root_branch.as_str()); + let logical = graph_layout(&parents); + let node_points: Vec<(f64, f64)> = logical + .iter() + .map(|point| logical_to_canvas_point(*point)) + .collect(); + let root_point = (0.0f64, 1.35f64); + let bounds = worktree_canvas_bounds(app); + let selected_idx = app + .selected_worktree + .min(app.worktrees.len().saturating_sub(1)); + + let canvas = Canvas::default() + .marker(Marker::Braille) + .x_bounds([bounds.min_x, bounds.max_x]) + .y_bounds([bounds.min_y, bounds.max_y]) + .paint(|ctx| { + for (idx, parent) in parents.iter().enumerate() { + let to = node_points[idx]; + let from = parent + .and_then(|p| node_points.get(p).copied()) + .unwrap_or(root_point); + let is_selected_edge = + idx == selected_idx || parent.map(|p| p == selected_idx).unwrap_or(false); + let edge_color = if is_selected_edge { + Color::LightCyan + } else { + Color::DarkGray + }; + ctx.draw(&canvas::Line { + x1: from.0, + y1: from.1, + x2: to.0, + y2: to.1, + color: edge_color, + }); + } + + ctx.draw(&canvas::Circle { + x: root_point.0, + y: root_point.1, + radius: 0.08, + color: Color::LightMagenta, + }); + + for (idx, entry) in app.worktrees.iter().enumerate() { + let point = node_points[idx]; + + if idx == selected_idx { + ctx.draw(&canvas::Circle { + x: point.0, + y: point.1, + radius: 0.09, + color: Color::Cyan, + }); + } + + let node_color = if idx == selected_idx { + Color::LightCyan + } else if entry.is_current { + Color::LightMagenta + } else if entry.dirty { + Color::Yellow + } else { + Color::White + }; + + ctx.draw(&canvas::Circle { + x: point.0, + y: point.1, + radius: 0.06, + color: node_color, + }); + } + }); + + frame.render_widget(Clear, inner); + frame.render_widget(canvas, inner); + + let main_label = canvas_root_label(app, root_branch.as_str()); + draw_canvas_label( + frame, + inner, + bounds, + root_point, + main_label.as_str(), + Style::default() + .fg(Color::Black) + .bg(Color::LightMagenta) + .add_modifier(Modifier::BOLD), + ); + + for (idx, entry) in app.worktrees.iter().enumerate() { + let selected = idx == selected_idx; + let label = canvas_node_label(app, entry, selected); + let style = if selected { + Style::default() + .fg(Color::Black) + .bg(Color::LightCyan) + .add_modifier(Modifier::BOLD) + } else if entry.is_current { + Style::default() + .fg(Color::Black) + .bg(Color::LightMagenta) + .add_modifier(Modifier::BOLD) + } else if entry.dirty { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::White) + }; + draw_canvas_label( + frame, + inner, + bounds, + node_points[idx], + label.as_str(), + style, + ); + } +} + +fn canvas_root_label(_app: &App, root_branch: &str) -> String { + format!("{}", truncate_text(root_branch, 14)) +} + +fn canvas_node_label(app: &App, entry: &WorktreeEntry, selected: bool) -> String { + let mut name = if entry.detached { + "detached".to_string() + } else { + entry.branch.clone() + }; + if name.len() > 16 { + name = truncate_text(name.as_str(), 16); + } + + let mut label = if selected { + let state = if entry.dirty { "*" } else { "" }; + format!("{}{}", name, state) + } else if entry.dirty { + format!("{}*", name) + } else { + name + }; + + if let Some(indicator) = worktree_node_activity_indicator(app, entry.path.as_str()) { + label.push(' '); + label.push_str(indicator); + } + + label +} + +#[derive(Clone, Copy)] +struct CanvasBounds { + min_x: f64, + max_x: f64, + min_y: f64, + max_y: f64, +} + +fn worktree_canvas_bounds(app: &App) -> CanvasBounds { + let span_x = 1.48 / app.worktree_canvas_zoom.max(0.65); + let span_y = 1.48 / app.worktree_canvas_zoom.max(0.65); + CanvasBounds { + min_x: -span_x + app.worktree_canvas_pan_x, + max_x: span_x + app.worktree_canvas_pan_x, + min_y: -span_y + app.worktree_canvas_pan_y, + max_y: span_y + app.worktree_canvas_pan_y, + } +} + +fn logical_to_canvas_point(point: (f32, f32)) -> (f64, f64) { + let x = (point.0 as f64 - 0.5) * 2.6; + let y = 0.9 - point.1 as f64 * 1.8; + (x, y) +} + +fn draw_canvas_label( + frame: &mut ratatui::Frame<'_>, + area: Rect, + bounds: CanvasBounds, + point: (f64, f64), + label: &str, + style: Style, +) { + let Some((sx, sy)) = canvas_point_to_screen(area, bounds, point) else { + return; + }; + let label_width = label.chars().count() as u16; + if label_width == 0 || label_width + 2 >= area.width { + return; + } + + let mut x = sx.saturating_sub(label_width / 2); + let min_x = area.x.saturating_add(1); + let max_x = area.right().saturating_sub(label_width + 1); + if x < min_x { + x = min_x; + } + if x > max_x { + x = max_x; + } + let y = sy + .saturating_add(1) + .clamp(area.y, area.bottom().saturating_sub(1)); + frame.render_widget( + Paragraph::new(label.to_string()).style(style), + Rect::new(x, y, label_width, 1), + ); +} + +fn canvas_point_to_screen( + area: Rect, + bounds: CanvasBounds, + point: (f64, f64), +) -> Option<(u16, u16)> { + if area.width == 0 || area.height == 0 { + return None; + } + let width = (bounds.max_x - bounds.min_x).max(0.001); + let height = (bounds.max_y - bounds.min_y).max(0.001); + let nx = (point.0 - bounds.min_x) / width; + let ny = (bounds.max_y - point.1) / height; + if !(0.0..=1.0).contains(&nx) || !(0.0..=1.0).contains(&ny) { + return None; + } + + let sx = area.x + (nx * area.width.saturating_sub(1) as f64).round() as u16; + let sy = area.y + (ny * area.height.saturating_sub(1) as f64).round() as u16; + Some((sx, sy)) +} + +fn worktree_node_activity_indicator<'a>(app: &'a App, path: &'a str) -> Option<&'static str> { + let session = app.agent_sessions.get(path)?; + let now = Instant::now(); + let is_working = session.state == AgentState::Launching + || (agent_session_is_live(session) && agent_session_is_active(session, now)); + if !is_working { + return None; + } + + const FRAMES: [&str; 8] = [ + "[| ]", "[ / ]", "[ - ]", "[ \\]", "[ |]", "[ / ]", "[ - ]", "[\\ ]", + ]; + let elapsed = now + .saturating_duration_since(session.launched_at) + .as_millis(); + let frame_idx = ((elapsed / 120) % FRAMES.len() as u128) as usize; + Some(FRAMES[frame_idx]) +} + +fn graph_layout(parents: &[Option]) -> Vec<(f32, f32)> { + let count = parents.len(); + if count == 0 { + return Vec::new(); + } + + let depths = graph_depths(parents); + let max_depth = depths.iter().copied().max().unwrap_or(0).max(1); + let mut by_depth: BTreeMap> = BTreeMap::new(); + for (idx, depth) in depths.iter().enumerate() { + by_depth.entry(*depth).or_default().push(idx); + } + + let mut positions = vec![(0.5f32, 0.5f32); count]; + for (depth, nodes) in by_depth { + let n = nodes.len().max(1); + for (rank, idx) in nodes.iter().enumerate() { + let x = (rank as f32 + 1.0) / (n as f32 + 1.0); + let y = 0.15 + ((depth as f32 + 1.0) / (max_depth as f32 + 1.0)) * 0.78; + positions[*idx] = (x, y); + } + } + + for _ in 0..24 { + let mut forces = vec![(0.0f32, 0.0f32); count]; + + for i in 0..count { + for j in (i + 1)..count { + let dx = positions[i].0 - positions[j].0; + let dy = positions[i].1 - positions[j].1; + let dist_sq = (dx * dx + dy * dy).max(0.0006); + let force = 0.0022 / dist_sq; + let nx = dx / dist_sq.sqrt(); + let ny = dy / dist_sq.sqrt(); + forces[i].0 += nx * force; + forces[i].1 += ny * force; + forces[j].0 -= nx * force; + forces[j].1 -= ny * force; + } + } + + for (idx, parent_opt) in parents.iter().enumerate() { + let (tx, ty) = if let Some(parent_idx) = parent_opt { + positions[*parent_idx] + } else { + (0.5, 0.06) + }; + + let dx = tx - positions[idx].0; + let dy = ty - positions[idx].1; + let dist = (dx * dx + dy * dy).sqrt().max(0.001); + let desired = if parent_opt.is_some() { 0.18 } else { 0.24 }; + let spring = (dist - desired) * 0.024; + forces[idx].0 += (dx / dist) * spring; + forces[idx].1 += (dy / dist) * spring; + + let target_y = 0.15 + ((depths[idx] as f32 + 1.0) / (max_depth as f32 + 1.0)) * 0.78; + forces[idx].1 += (target_y - positions[idx].1) * 0.015; + forces[idx].0 += (0.5 - positions[idx].0) * 0.002; + } + + for idx in 0..count { + positions[idx].0 = (positions[idx].0 + forces[idx].0).clamp(0.06, 0.94); + positions[idx].1 = (positions[idx].1 + forces[idx].1).clamp(0.12, 0.95); + } + } + + positions +} + +fn graph_depths(parents: &[Option]) -> Vec { + fn depth_for(i: usize, parents: &[Option], cache: &mut [Option]) -> usize { + if let Some(depth) = cache[i] { + return depth; + } + + let depth = match parents[i] { + Some(parent) if parent != i => depth_for(parent, parents, cache) + 1, + _ => 0, + }; + cache[i] = Some(depth); + depth + } + + let mut cache = vec![None; parents.len()]; + (0..parents.len()) + .map(|i| depth_for(i, parents, &mut cache)) + .collect() +} + +fn worktree_parent_map(worktrees: &[WorktreeEntry], root_branch: &str) -> Vec> { + let mut branch_to_idx: BTreeMap = BTreeMap::new(); + for (idx, wt) in worktrees.iter().enumerate() { + if !wt.detached && !wt.branch.is_empty() { + branch_to_idx.entry(wt.branch.clone()).or_insert(idx); + } + } + + let mut parents = vec![None; worktrees.len()]; + for (idx, wt) in worktrees.iter().enumerate() { + if wt.detached || is_root_branch(wt.branch.as_str(), root_branch) { + continue; + } + + if let Some(hint) = wt.parent_hint.as_deref() { + if let Some(parent_idx) = branch_to_idx.get(hint) { + if *parent_idx != idx { + parents[idx] = Some(*parent_idx); + continue; + } + } + } + + if let Some(parent_idx) = find_branch_parent_idx(idx, wt.branch.as_str(), &branch_to_idx) { + parents[idx] = Some(parent_idx); + } + } + + let root_idx = worktrees + .iter() + .enumerate() + .find_map(|(idx, wt)| { + if !wt.detached && wt.branch == root_branch && wt.is_current { + Some(idx) + } else { + None + } + }) + .or_else(|| { + worktrees.iter().enumerate().find_map(|(idx, wt)| { + if !wt.detached && wt.branch == root_branch { + Some(idx) + } else { + None + } + }) + }); + + if let Some(root_idx) = root_idx { + for (idx, wt) in worktrees.iter().enumerate() { + if idx == root_idx { + continue; + } + if wt.detached { + continue; + } + if parents[idx].is_none() { + parents[idx] = Some(root_idx); + } + } + } + + parents +} + +fn find_branch_parent_idx( + current_idx: usize, + branch: &str, + branch_to_idx: &BTreeMap, +) -> Option { + let mut parts: Vec<&str> = branch.split('/').collect(); + while parts.len() > 1 { + parts.pop(); + let candidate = parts.join("/"); + if let Some(idx) = branch_to_idx.get(candidate.as_str()) { + if *idx != current_idx { + return Some(*idx); + } + } + } + None +} + +fn is_root_branch(branch: &str, root_branch: &str) -> bool { + branch == root_branch +} + +fn worktree_parent_label(app: &App, parents: &[Option]) -> String { + if app.selected_worktree >= app.worktrees.len() { + return current_session_branch(app); + } + + if let Some(parent_idx) = parents.get(app.selected_worktree).and_then(|v| *v) { + if let Some(parent) = app.worktrees.get(parent_idx) { + if parent.detached { + return "detached".to_string(); + } + return parent.branch.clone(); + } + } + + current_session_branch(app) +} + +fn current_session_branch(app: &App) -> String { + let raw = app.branch.trim(); + let name = raw + .strip_prefix("HEAD (detached at ") + .and_then(|value| value.strip_suffix(')')) + .unwrap_or(raw); + if name.is_empty() { + "current".to_string() + } else { + name.to_string() + } +} + +fn draw_worktree_details_panel(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) { + let mut lines: Vec> = Vec::new(); + let root_branch = current_session_branch(app); + let parents = worktree_parent_map(&app.worktrees, root_branch.as_str()); + + if let Some(selected) = app.selected_worktree() { + lines.push(Line::from(vec![ + Span::styled("branch: ", Style::default().fg(Color::Gray)), + Span::styled(selected.branch.as_str(), Style::default().fg(Color::Cyan)), + ])); + lines.push(Line::from(vec![ + Span::styled("path: ", Style::default().fg(Color::Gray)), + Span::styled(selected.path.as_str(), Style::default().fg(Color::White)), + ])); + lines.push(Line::from(vec![ + Span::styled("head: ", Style::default().fg(Color::Gray)), + Span::styled( + selected.head.as_str(), + Style::default().fg(Color::LightBlue), + ), + ])); + lines.push(Line::from(vec![ + Span::styled("source: ", Style::default().fg(Color::Gray)), + Span::styled( + worktree_parent_label(app, &parents), + Style::default().fg(Color::LightMagenta), + ), + ])); + lines.push(Line::from("")); + + lines.push(Line::from(vec![ + Span::styled("dirty: ", Style::default().fg(Color::Gray)), + Span::styled( + if selected.dirty { "yes" } else { "no" }, + Style::default().fg(if selected.dirty { + Color::Yellow + } else { + Color::Green + }), + ), + Span::raw(" "), + Span::styled("locked: ", Style::default().fg(Color::Gray)), + Span::styled( + if selected.locked { "yes" } else { "no" }, + Style::default().fg(if selected.locked { + Color::Yellow + } else { + Color::Green + }), + ), + ])); + + lines.push(Line::from(vec![ + Span::styled("ahead: ", Style::default().fg(Color::Gray)), + Span::styled( + selected.ahead.to_string(), + Style::default().fg(Color::Green), + ), + Span::raw(" "), + Span::styled("behind: ", Style::default().fg(Color::Gray)), + Span::styled( + selected.behind.to_string(), + Style::default().fg(Color::Yellow), + ), + ])); + + lines.push(Line::from(vec![ + Span::styled("flags: ", Style::default().fg(Color::Gray)), + Span::styled( + worktree_flags(selected), + Style::default().fg(Color::LightMagenta), + ), + ])); + lines.push(Line::from("")); + let status_max = area.width.saturating_sub(4) as usize; + let status_text = sanitize_for_tui(app.status_line.as_str()); + let inner_height = area.height.saturating_sub(2) as usize; + let status_max_lines = inner_height.saturating_sub(lines.len() + 1).max(1); + lines.push(Line::from(vec![Span::styled( + "status:", + Style::default().fg(Color::Gray), + )])); + for wrapped in wrap_text_lines(status_text.as_str(), status_max.max(12), status_max_lines) { + lines.push(Line::from(vec![Span::styled( + wrapped, + Style::default().fg(Color::White), + )])); + } + } else { + lines.push(Line::from("No worktree selected")); + } + + let border_color = if app.worktree_focus == WorktreePane::Details { + Color::Cyan + } else { + Color::Gray + }; + + let panel = Paragraph::new(lines) + .block( + Block::default() + .title("details [?]") + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .style(Style::default().bg(Color::Black)), + ) + .style(Style::default().bg(Color::Black).fg(Color::White)) + .alignment(Alignment::Left); + + frame.render_widget(panel, area); +} + +fn draw_worktree_actions_panel(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) { + let border_color = if app.worktree_focus == WorktreePane::Actions { + Color::Cyan + } else { + Color::Gray + }; + + let lines = vec![ + Line::from(vec![ + Span::styled("w", Style::default().fg(Color::LightBlue)), + Span::raw(" file changes view"), + ]), + Line::from(vec![ + Span::styled("r", Style::default().fg(Color::Cyan)), + Span::raw(" refresh worktrees"), + ]), + Line::from(vec![ + Span::styled("q", Style::default().fg(Color::Red)), + Span::raw(" quit"), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("a", Style::default().fg(Color::LightGreen)), + Span::raw(" create worktree"), + ]), + Line::from(vec![ + Span::styled("o", Style::default().fg(Color::LightBlue)), + Span::raw(" open terminal popup"), + ]), + Line::from(vec![ + Span::styled("z", Style::default().fg(Color::LightBlue)), + Span::raw(" open terminal popup"), + ]), + Line::from(vec![ + Span::styled("f", Style::default().fg(Color::Cyan)), + Span::raw(" fetch parent"), + ]), + Line::from(vec![ + Span::styled("p", Style::default().fg(Color::Magenta)), + Span::raw(" add+commit+push"), + ]), + Line::from(vec![ + Span::styled("d", Style::default().fg(Color::LightRed)), + Span::raw(" delete selected"), + ]), + Line::from(vec![ + Span::styled("m", Style::default().fg(Color::LightGreen)), + Span::raw(" merge to parent"), + ]), + Line::from(vec![ + Span::styled("x", Style::default().fg(Color::Yellow)), + Span::raw(" prune stale"), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("tab", Style::default().fg(Color::LightBlue)), + Span::raw(" switch panel"), + ]), + Line::from(vec![ + Span::styled("?", Style::default().fg(Color::Yellow)), + Span::raw(" panel help"), + ]), + Line::from(vec![ + Span::styled("arrows", Style::default().fg(Color::LightBlue)), + Span::raw(" move on canvas"), + ]), + Line::from(vec![ + Span::styled("+/-", Style::default().fg(Color::LightBlue)), + Span::raw(" zoom canvas"), + ]), + Line::from(vec![ + Span::styled("0", Style::default().fg(Color::LightBlue)), + Span::raw(" reset camera"), + ]), + Line::from(vec![ + Span::styled("Shift+WASD", Style::default().fg(Color::LightBlue)), + Span::raw(" pan camera"), + ]), + Line::from(vec![ + Span::styled("h/l", Style::default().fg(Color::LightBlue)), + Span::raw(" left/right in level"), + ]), + Line::from(vec![ + Span::styled("j/k", Style::default().fg(Color::LightBlue)), + Span::raw(" child/parent level"), + ]), + ]; + + frame.render_widget( + Paragraph::new(lines) + .block( + Block::default() + .title("actions [?]") + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .style(Style::default().bg(Color::Black)), + ) + .style(Style::default().fg(Color::White)), + area, + ); +} + +fn draw_worktree_help_modal(frame: &mut ratatui::Frame<'_>, app: &App) { + let popup = centered_rect(64, 42, frame.area()); + frame.render_widget(Clear, popup); + + let lines = worktree_help_lines(app.worktree_focus); + let panel = Paragraph::new(lines) + .block( + Block::default() + .title("Panel Help") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .style(Style::default().bg(Color::Black)), + ) + .style(Style::default().fg(Color::White)); + + frame.render_widget(panel, popup); +} + +fn worktree_help_lines(pane: WorktreePane) -> Vec> { + match pane { + WorktreePane::Canvas => vec![ + Line::from("Worktree Graph"), + Line::from(""), + Line::from("- Top node (magenta) = current HEAD branch"), + Line::from("- Cyan ring = selected worktree"), + Line::from("- Yellow nodes = dirty (uncommitted changes)"), + Line::from("- Lines show parent branch relationships"), + Line::from(""), + Line::from("Navigation:"), + Line::from(" arrows - move by graph direction"), + Line::from(" h/l - left/right among siblings"), + Line::from(" j/k - down/up by graph level"), + Line::from(""), + Line::from("Camera:"), + Line::from(" +/- - zoom in/out"), + Line::from(" 0 - reset view"), + Line::from(" Shift+WASD - pan"), + Line::from(""), + Line::from(" ?: close this help"), + ], + WorktreePane::Details => vec![ + Line::from("Details panel"), + Line::from("- Shows branch/path/head and repo flags"), + Line::from("- Shows ahead/behind and dirty/locked state"), + Line::from("- Reflects current canvas selection"), + Line::from("- tab: move focus to next panel"), + Line::from("- ?: close this help"), + ], + WorktreePane::Actions => vec![ + Line::from("Actions panel"), + Line::from("- a: create worktree from branch name"), + Line::from("- o: open/reopen terminal popup for selected node"), + Line::from("- z: same as o (open/reopen terminal popup)"), + Line::from("- terminal popup: : enters CONTROL, Ctrl+G toggles INPUT/CONTROL"), + Line::from("- f: fetch connected parent node"), + Line::from("- p: selected worktree add+commit+push with message popup"), + Line::from("- d: delete selected worktree (safe checks)"), + Line::from("- m: merge selected branch into connected parent node"), + Line::from("- x: prune stale worktrees"), + Line::from("- ?: close this help"), + ], + } +} + +fn draw_worktree_create_modal(frame: &mut ratatui::Frame<'_>, app: &App) { + let popup = centered_rect(74, 30, frame.area()); + frame.render_widget(Clear, popup); + + let border = Block::default() + .title("Create Worktree") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)) + .border_style(Style::default().fg(Color::LightGreen)); + frame.render_widget(border, popup); + + let layout = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(2), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(popup); + + frame.render_widget( + Paragraph::new( + "Choose source above, then type worktree branch. Enter creates '.gitfetch-worktrees/'", + ) + .style(Style::default().fg(Color::Gray)), + layout[0], + ); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Base: ", Style::default().fg(Color::Gray)), + Span::styled( + worktree_create_base_label(app.new_worktree_base), + Style::default().fg(Color::LightGreen), + ), + Span::raw(" (use ←/→)"), + ])) + .block(Block::default().title("Source").borders(Borders::ALL)) + .style(Style::default().fg(Color::White)), + layout[1], + ); + + frame.render_widget( + Paragraph::new(app.new_worktree_branch.as_str()) + .block(Block::default().title("Branch").borders(Borders::ALL)) + .style(Style::default().fg(Color::Cyan)), + layout[2], + ); + + frame.render_widget( + Paragraph::new("Esc cancels") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Gray)), + layout[3], + ); +} + +fn draw_agent_popup(frame: &mut ratatui::Frame<'_>, app: &App) { + let popup = terminal_popup_rect(frame.area()); + frame.render_widget(Clear, popup); + + let border = Block::default() + .title("Terminal Session") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)) + .border_style(Style::default().fg(Color::Cyan)); + frame.render_widget(border, popup); + + let layout = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Min(8), + Constraint::Length(1), + ]) + .split(popup); + + let path = app + .agent_popup_path + .as_deref() + .unwrap_or("(no worktree selected)"); + let state = agent_state_for_path(app, path); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("worktree: ", Style::default().fg(Color::Gray)), + Span::styled(path, Style::default().fg(Color::White)), + Span::raw(" "), + Span::styled("terminal: ", Style::default().fg(Color::Gray)), + Span::styled("shell", Style::default().fg(Color::LightCyan)), + Span::raw(" "), + Span::styled("mode: ", Style::default().fg(Color::Gray)), + Span::styled( + terminal_popup_mode_text(app.terminal_popup_mode), + terminal_popup_mode_style(app.terminal_popup_mode), + ), + Span::raw(" "), + Span::styled("status: ", Style::default().fg(Color::Gray)), + Span::styled(agent_state_text(state), agent_state_style(state)), + ])), + layout[0], + ); + + let mut lines: Vec> = Vec::new(); + if let Some(session) = app.agent_sessions.get(path) { + let visible_rows = layout[2].height.saturating_sub(2) as usize; + let width = layout[2].width.saturating_sub(2).max(1); + lines = render_terminal_lines(session, width, visible_rows); + } + if lines.is_empty() { + lines.push(Line::from("(terminal booting...)")); + } + + frame.render_widget( + Paragraph::new(lines) + .block(Block::default().title("Terminal").borders(Borders::ALL)) + .style(Style::default().fg(Color::White)), + layout[2], + ); + frame.render_widget( + Paragraph::new(terminal_footer_text(app.terminal_popup_mode)) + .style(Style::default().fg(Color::Gray)), + layout[3], + ); +} + +fn terminal_popup_mode_text(mode: TerminalPopupMode) -> &'static str { + match mode { + TerminalPopupMode::Input => "INPUT", + TerminalPopupMode::Control => "CONTROL", + } +} + +fn terminal_popup_mode_style(mode: TerminalPopupMode) -> Style { + match mode { + TerminalPopupMode::Input => Style::default().fg(Color::LightGreen), + TerminalPopupMode::Control => Style::default().fg(Color::LightYellow), + } +} + +fn terminal_footer_text(mode: TerminalPopupMode) -> &'static str { + match mode { + TerminalPopupMode::Input => { + "INPUT mode: typing goes to terminal. : enters CONTROL (Ctrl+G also works)." + } + TerminalPopupMode::Control => { + "CONTROL mode: Esc background, q quit session, r restart, i return INPUT." + } + } +} + +fn agent_state_for_path(app: &App, path: &str) -> AgentState { + app.agent_sessions + .get(path) + .map(|s| s.state) + .unwrap_or(AgentState::Launching) +} + +fn agent_state_text(state: AgentState) -> &'static str { + match state { + AgentState::Launching => "loading", + AgentState::Running => "running", + AgentState::Done => "done", + AgentState::Failed => "failed", + } +} + +fn agent_state_style(state: AgentState) -> Style { + match state { + AgentState::Launching => Style::default().fg(Color::Yellow), + AgentState::Running => Style::default().fg(Color::LightCyan), + AgentState::Done => Style::default().fg(Color::Green), + AgentState::Failed => Style::default().fg(Color::Red), + } +} + +fn render_terminal_lines( + session: &AgentSession, + width: u16, + visible_rows: usize, +) -> Vec> { + if width == 0 || visible_rows == 0 { + return Vec::new(); + } + + let screen = session.parser.screen(); + let (rows, cols) = screen.size(); + let cols = cols.min(width); + let start_row = rows.saturating_sub(visible_rows as u16); + let mut out = Vec::new(); + + for row in start_row..rows { + let mut spans: Vec> = Vec::new(); + let mut run = String::new(); + let mut run_style: Option