diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cae4469 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,65 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build: + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact: vulp-linux-x86_64 + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact: vulp-windows-x86_64.exe + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + + - name: Build + run: cargo build --release --target ${{ matrix.target }} + + - name: Stage artifact (Unix) + if: runner.os != 'Windows' + run: | + mkdir -p dist + cp target/${{ matrix.target }}/release/vulp dist/${{ matrix.artifact }} + + - name: Stage artifact (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist + Copy-Item target/${{ matrix.target }}/release/vulp.exe dist/${{ matrix.artifact }} + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: dist/${{ matrix.artifact }} + + release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true diff --git a/Cargo.lock b/Cargo.lock index 84a58a2..28f168b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -457,6 +466,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -464,6 +488,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -472,6 +497,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -490,8 +543,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -505,27 +563,6 @@ dependencies = [ "thread_local", ] -[[package]] -name = "fxg" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "chrono", - "clap", - "crossterm 0.28.1", - "dirs", - "git2", - "inquire", - "keyring", - "ratatui", - "reqwest", - "serde", - "serde_json", - "tokio", - "toml", -] - [[package]] name = "fxhash" version = "0.2.1" @@ -1228,6 +1265,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.112" @@ -1236,6 +1282,7 @@ checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] @@ -1389,6 +1436,35 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "reqwest" version = "0.12.28" @@ -1594,6 +1670,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -1694,6 +1771,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strsim" version = "0.11.1" @@ -1998,6 +2081,76 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree-sitter" +version = "0.26.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dab76d0b724ba557954125188cf0633a1ca43199ced82d95c7b9c32cc3de1f3" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -2075,6 +2228,36 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vulp" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "clap", + "crossterm 0.28.1", + "dirs", + "futures", + "git2", + "inquire", + "keyring", + "libc", + "ratatui", + "reqwest", + "serde", + "serde_json", + "signal-hook", + "tokio", + "toml", + "tree-sitter", + "tree-sitter-go", + "tree-sitter-javascript", + "tree-sitter-python", + "tree-sitter-rust", + "tree-sitter-typescript", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 6b75536..98a5f9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,13 @@ [package] -name = "fxg" +name = "vulp" version = "0.1.0" edition = "2021" description = "Multi-repo git workflow manager" +authors = ["dualfroz"] +build = "build.rs" [[bin]] -name = "fxg" +name = "vulp" path = "src/main.rs" [dependencies] @@ -15,12 +17,21 @@ chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive"] } crossterm = "0.28" dirs = "5" -git2 = "0.19" +futures = "0.3" +git2 = { version = "0.19", features = ["vendored-openssl", "vendored-libgit2"] } inquire = "0.7" keyring = "3" ratatui = "0.28" +libc = "0.2" reqwest = { version = "0.12", features = ["json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +signal-hook = "0.3" tokio = { version = "1", features = ["full"] } toml = "0.8" +tree-sitter = "0.26.9" +tree-sitter-rust = "0.24.2" +tree-sitter-python = "0.25.0" +tree-sitter-javascript = "0.25.0" +tree-sitter-typescript = "0.23.2" +tree-sitter-go = "0.25.0" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c2a7f26 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 dualfroz (VulCode) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2b67101..a82107a 100644 --- a/README.md +++ b/README.md @@ -1,322 +1,127 @@ -# fxg +# Vulp -Multi-repository git workflow manager. Zarządzaj repozytoriami na GitHub, GitLab i Gitea z jednego miejsca. +Multi-repository git workflow manager. Jeden terminal, cały workspace: +stan wszystkich repozytoriów, batch sync, projekty, todos, PR-y/issues, +statystyki — plus pełne, bezpromptowe CLI, którego mogą używać skrypty +i asystenci AI. -## Install +- **TUI** (`vulp` bez argumentów) — interfejs dla ludzi +- **CLI** (`vulp `) — dla ludzi, skryptów i AI; `--json`, kody + wyjścia, zero interaktywnych pytań → pełna referencja: [docs/cli.md](docs/cli.md) + +## Instalacja + +Gotowa binarka (Linux/Windows), bez Rusta: ```bash -# Linux — opcjonalna zależność systemowa -sudo apt install libgit2-dev # Ubuntu/Debian -sudo pacman -S libgit2 # Arch - -cargo install --path . -fxg --version +# Linux +curl -fsSL https://raw.githubusercontent.com/dualfroz/fxg/main/install/vulp-installer.sh | bash ``` -Binarny plik ląduje w `~/.cargo/bin/fxg` — już jest w PATH po standardowej instalacji Rust. Działa na Windows i Linux. +```powershell +# Windows +irm https://raw.githubusercontent.com/dualfroz/fxg/main/install/vulp-installer.ps1 | iex +``` ---- +Albo ze źródeł: `cargo install --path .` ## Pierwsze uruchomienie ```bash -fxg config # ustaw workspace_root, username, email -fxg auth add --provider github --token ghp_xxxxx +vulp config # ustaw workspace_root, username, email +vulp auth import # zaimportuj konto z gh CLI (najprościej) +# albo: vulp auth add --provider github --token ghp_xxx +vulp # odpal TUI ``` -Bez tych dwóch kroków większość komend nie zadziała. +## TUI ---- +`vulp` otwiera hub z zakładkami (przełączanie: `Tab`/`←→` albo `1-9`): -## `fxg status` +| # | Zakładka | Co robi | +|---|----------|---------| +| 1 | **Overview** | bento-dashboard: statystyki workspace, projekty, top repo po commitach, scalony feed aktywności (commity + akcje vulpa), todos/PRs/issues | +| 2 | **Repos** | tabela wszystkich rep: branch, dirty/ahead/behind, język, gwiazdki; multi-select do operacji hurtowych | +| 3 | **Projects** | grupy repozytoriów (kolor + przypisania) | +| 4 | **Todos** | zadania ręczne + zaimportowane skanem TODO/FIXME z kodu | +| 5/6 | **PRs / Issues** | otwarte PR-y i issues ze wszystkich rep (cache, odświeżane `[R]`) | +| 7 | **Stats** | statystyki commitów per okres (1d–all) | +| 8 | **Activity** | feed: commity + akcje wykonane vulpem | +| 9 | **Settings** | edytor, schowek, workspace, konta, szablony repo, bulk import | -Otwiera TUI — tabelę wszystkich repozytoriów z `workspace_root`: +Najważniejsze klawisze (pełna lista: `?` w aplikacji): -``` -┌ fxg — Workspace Status · 4 repos ──────────────────────────────────────────────┐ -│ # NAME BRANCH AHEAD BEHIND DIRTY LAST COMMIT │ -│ 1 my-api main 2 0 ✗ 2h ago │ -│ 2 my-frontend develop 0 3 ✓ yesterday ← żółty │ -│ 3 shared-lib main 0 0 ✗ 5d ago │ -│ 4 infra feat/k8s 1 0 ✓ 30m ago ← żółty │ -│ │ -│ [↑/↓] navigate [Enter] drill-in [f] filter [q] quit │ -└─────────────────────────────────────────────────────────────────────────────────┘ -``` +- **`m` — kontekstowe menu akcji.** Na każdej zakładce pokazuje wszystkie + dostępne akcje z ich skrótami — nie musisz nic pamiętać. +- `Enter` — szczegóły repo/projektu (historia, branch'e, pliki, diff, + ustawienia zdalne, akcje: sync/release/collaboratorzy…) +- `Space` zaznacz do batcha · `s` sync (pull/commit+push) · `r` release +- `/` filtr · `S` sort i filtry · `d/b/f` tylko dirty/behind/ahead +- `N` nowe repo (clone/create/link) · `R` odśwież metadane z API +- `o` otwórz w edytorze · `W` w przeglądarce · `y`/`u` kopiuj ścieżkę/URL -Kolory: żółty = dirty (niezcommitowane zmiany), czerwony = behind (trzeba pullować). +## CLI (skrypty + AI) -**Enter na repozytorium** → widok szczegółowy: - -``` -┌ my-frontend · develop ─────────────────────────────────────────────────────────┐ -│ Branch: develop │ -│ Status: +0 ahead -3 behind dirty: yes │ -│ │ -│ ┌ Modified files ──────────────────────────┐ ┌ Recent commits ───────────────┐ │ -│ │ M src/components/Nav.tsx │ │ a3f1c2 fix: nav style 2h │ │ -│ │ M src/styles/theme.css │ │ b89d01 feat: dark mode 1d │ │ -│ │ ? src/utils/new-helper.ts │ │ c12345 chore: deps 3d │ │ -│ └──────────────────────────────────────────┘ └───────────────────────────────┘ │ -│ [p] pull [P] push [c] commit [b] branches [q] back │ -└─────────────────────────────────────────────────────────────────────────────────┘ -``` - -**Filtry (z CLI):** -```bash -fxg status --filter dirty # tylko repo z niezcommitowanymi zmianami -fxg status --filter behind # tylko repo, które trzeba pullować -fxg status --filter ahead # tylko repo gotowe do pusha -fxg status --filter clean # tylko czyste i zsynchronizowane -fxg status --workspace work # tylko repo w danym workspace -fxg status my-api # bezpośrednio widok szczegółowy konkretnego repo -``` - ---- - -## `fxg sync` - -Bez argumentów otwiera TUI — **3-etapowy flow**: - -### Etap 1 — wybór repozytoriów - -``` -┌ Select repositories (Space = toggle, Enter = confirm) ──────────────────────┐ -│ [ ] my-api main │ -│ [x] my-frontend develop ✓ ← dirty │ -│ [ ] shared-lib main │ -│ [x] infra feat/k8s ✓ ← dirty │ -│ │ -│ [Space] toggle [a] select all [Enter] confirm [q] quit │ -└──────────────────────────────────────────────────────────────────────────────┘ -``` - -`Space` = toggle zaznaczenia, `a` = zaznacz wszystkie, `Enter` = przejdź dalej. - -### Etap 2 — wybór akcji - -``` -┌ Action for: my-frontend, infra ────────────────────────────────────────────┐ -│ Pull │ -│ Commit │ -│ ▶ Push │ -│ New branch │ -│ Create PR │ -│ │ -│ [↑/↓] navigate [Enter] execute [Esc] back │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### Etap 3 — wyniki - -``` -┌ Progress ──────────────────────────────────────────────────────────────────┐ -│ ✓ my-frontend — push — Pushed develop │ -│ ✓ infra — push — Pushed feat/k8s │ -│ │ -│ [q] quit │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### Sync bez TUI (skrypty / CI) +Każda operacja TUI ma odpowiednik w CLI. Przykłady: ```bash -fxg sync pull --workspace work # pull wszystkich repo w workspace -fxg sync pull --all # pull wszystkich repo w workspace_root - -fxg sync commit -m "chore: sync" # commit wszystkich dirty repo (CWD) -fxg sync commit -m "chore: sync" --all # commit wszystkich dirty w workspace_root - -fxg sync push --all # push wszystkich -fxg sync push --all -m "chore: sync" # najpierw commit dirty, potem push -fxg sync push --all --force-with-lease # force push (bezpieczny) +vulp list --dirty --json # co wymaga uwagi (JSON dla AI) +vulp status # szczegóły jednego repo +vulp sync --project X --action commit-push -m "chore: update" +vulp release --tag v1.2.0 +vulp todo add "tytuł" && vulp todo done 3 +vulp pr create --repo R --title T --head branch +vulp repo collab --role push +vulp init nazwa --private # git init + repo na providerze + push +vulp stats --period 7d --json +vulp refresh # dociągnij meta/PR/issues z API ``` -> **Uwaga:** `commit` używa tej samej wiadomości dla wszystkich repo. Indywidualne wiadomości per-repo możliwe przez TUI (opcja Commit) — TUI pyta o wiadomość przed wykonaniem commita. +Kontrakt: `--json` na komendach odczytu, stdout = wynik / stderr = postęp, +kody wyjścia `0` ok · `1` błąd · `2` złe użycie · `3` auth · `4` nie znaleziono. +**Szczegóły i przepisy: [docs/cli.md](docs/cli.md).** ---- +## Konfiguracja -## `fxg init` - -Bez flagi `--provider` uruchamia **wizard 7-krokowy**: - -``` -══════════════════════════════════════════ - fxg — New Repository -══════════════════════════════════════════ - -Step 1/7 · Repository name: my-new-project - > walidacja: brak spacji, niepuste - -Step 2/7 · Provider & Account: - > github/personal ← domyślny (zaznaczony) - gitlab/work - -Step 3/7 · Visibility: - > private - public - internal - -Step 4/7 · Tags (comma-separated): - > rust, cli, tools - -Step 5/7 · Initial files: ← MultiSelect, domyślnie 4 pierwsze zaznaczone - [x] README.md - [x] .gitignore - [x] CHANGELOG.md - [x] .env.example - [ ] LICENSE ← opcja dostępna ale plik NIE jest generowany (TODO) - [ ] CONTRIBUTING.md ← j.w. - - Template: - > node - rust ← dla Rusta generuje /target\nCargo.lock\n - python - blank - -Step 6/7 · Extra branches (comma-separated): - > dev ← domyślnie - -Step 7/7 · Add collaborators? (y/N) - > username: alice - role: write - > Add another? N - -────────────────────────────────────────── -Proceed with creation? (Y/n) -``` - -**Co się dzieje po potwierdzeniu:** - -``` - ✓ Created remote repo my-new-project (github/personal) - ✓ Applied tags: rust, cli, tools - ✓ mkdir ~/projects/my-new-project - ✓ git init - ✓ Created initial files - ✓ Initial commit: "chore: init" - ✓ git remote add origin git@github.com:user/my-new-project.git - ✓ git push -u origin main - ✓ Created branch: dev - ✓ Added collaborator: alice (write) - - Done in 2.3s · https://github.com/user/my-new-project -``` - -**Tryb bez wizarda** (name + provider podane → pomija wizard): -```bash -fxg init my-project --provider github -fxg init my-project --provider github --status public --tags rust,cli --branch develop -``` - -**Batch** — wiele repo naraz: -```bash -fxg init --batch "api,frontend,shared-lib" # lista inline -fxg init --batch repos.txt # plik, jedno repo per linia -``` -W trybie batch: tworzy README, .gitignore, bez CHANGELOG i .env.example. - ---- - -## `fxg workspace` - -Grupy repozytoriów do zbiorczych operacji. - -```bash -fxg workspace create backend -fxg workspace add backend api shared-lib -fxg workspace list -fxg workspace remove backend -``` - -``` -Workspaces: - backend (2 repos) api, shared-lib - frontend (1 repo) my-frontend -``` - -Workspace używasz potem w innych komendach: `--workspace backend`. - ---- - -## `fxg auth` - -```bash -fxg auth # TUI z listą kont -fxg auth add --provider github --token ghp_xxx # dodaj konto -fxg auth add --provider gitlab --token glpat_xxx -fxg auth add --provider gitea --token xxx --url https://git.example.com -fxg auth add --provider github --token ghp_xxx --alias work # nazwany alias -fxg auth remove github/personal -fxg auth switch github/work # ustaw jako domyślne -fxg auth test # sprawdź domyślne konto -fxg auth test github/work -``` - -ID konta: `/`, np. `github/personal`, `gitea/selfhosted`. -Tokeny przechowywane w systemowym keychaine (Windows Credential Manager / macOS Keychain / Linux Secret Service) — nigdy w plikach plaintext. - ---- - -## `fxg config` - -Interaktywne TUI do edycji ustawień globalnych. Zapisywane do `~/.fxg/config.toml`. - -```bash -fxg config -``` - -Plik konfiguracyjny (`~/.fxg/config.toml`): - -```toml -workspace_root = "/home/user/projects" -data_dir = "/home/user/.fxg" -default_provider = "github" -default_branch = "main" -default_visibility = "private" -editor = "vim" -username = "your-name" -email = "you@example.com" -gitignore_template = "node" -readme_template = "heartbyte-default" -share_stats = false -timezone = "Europe/Warsaw" -vsc_data_dir = "/home/user/.fxg/vsc-data" -``` +`vulp config` (interaktywnie) albo edycja `~/.vulp/config.toml`: | Pole | Domyślnie | Opis | |---|---|---| -| `workspace_root` | `~/projects` | Gdzie fxg szuka lokalnych repozytoriów | -| `data_dir` | `~/.fxg` | Dane fxg (konta, workspace'y) | -| `default_provider` | `github` | Provider gdy nie podasz `--provider` | -| `default_branch` | `main` | Domyślna gałąź nowych repo | -| `default_visibility` | `private` | Widoczność nowych repo | -| `editor` | `$EDITOR` lub `vim` | Edytor w interaktywnych flowach | -| `username` | _(puste)_ | Git username — używany w commitach | -| `email` | _(puste)_ | Git email — używany w commitach | -| `gitignore_template` | `node` | Template `.gitignore` (`node`, `rust`, `python`) | -| `timezone` | `Europe/Warsaw` | Strefa czasowa w statystykach | -| `vsc_data_dir` | `~/.fxg/vsc-data` | Dane VSCode time tracking | - ---- +| `workspace_root` | `~/projects` | katalog skanowany w poszukiwaniu rep | +| `workspace_groups` | `[]` | foldery-grupy, których dzieci są repami (np. `["clients", "opensource"]`) | +| `data_dir` | `~/.vulp` | dane vulpa | +| `default_provider` | `github` | provider bez `--provider` | +| `default_branch` / `default_visibility` | `main` / `private` | dla nowych rep | +| `editor` | `$EDITOR`/`vim` | do `o` / otwierania plików | +| `clipboard_cmd` | auto | komenda kopiowania (`wl-copy`, `xclip …`) | +| `username` / `email` | — | tożsamość gita | ## Pliki danych ``` -~/.fxg/ -├── config.toml # ustawienia globalne -├── accounts.toml # konta (bez tokenów — te są w keychaine) -├── workspaces.toml # definicje workspace'ów -└── vsc-data/ # dane śledzenia czasu z VSCode (dla fxg stats time) +~/.vulp/ +├── config.toml # ustawienia +├── accounts.toml # konta (tokeny w keyringu / .tokens 0600) +├── projects.toml # projekty (grupy rep) +├── todos.toml # zadania +├── labels.toml # etykiety wyświetlania rep +├── bookmarks.toml / pins.toml +├── templates.toml # szablony nowych rep +├── meta_cache.toml # cache metadanych/PR/issues z API +└── activity.log # log akcji vulpa (JSONL) ``` ---- - ## Providery -| Provider | wartość `--provider` | Uwagi | +| Provider | `--provider` | Zakres wsparcia | |---|---|---| -| GitHub | `github` | github.com | -| GitLab | `gitlab` | gitlab.com | -| Gitea | `gitea` | self-hosted, wymaga `--url` | -| Forgejo | `forgejo` | fork Gitea, wymaga `--url` | +| GitHub | `github` | pełny (repo, PR, issues, release, collaboratorzy, search) | +| GitLab | `gitlab` | podstawowy (tworzenie rep, collaboratorzy) | +| Gitea/Forgejo | `gitea` | podstawowy (tworzenie rep, collaboratorzy; wymaga `--url`) | + +Listy PR/issues są dziś GitHub-only — na innych providerach zakładki 5/6 +będą puste (known limitation). + +## Licencja + +MIT — patrz [LICENSE](LICENSE). diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..fc321c1 --- /dev/null +++ b/build.rs @@ -0,0 +1,22 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Civil date from days since Unix epoch (Howard Hinnant's algorithm) — +/// avoids pulling chrono into build-dependencies for one date string. +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +fn main() { + let secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); + let (y, m, d) = civil_from_days(secs / 86_400); + println!("cargo:rustc-env=VULP_BUILD_DATE={y:04}-{m:02}-{d:02}"); +} diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..38a9463 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,115 @@ +# vulp CLI — reference + +Complete non-interactive surface. Written for machine consumers (AI agents, +scripts) as much as humans: every read command supports `--json`, nothing +ever prompts, and exit codes are meaningful. + +``` +vulp # no args → interactive TUI (humans) +vulp [--json] # non-interactive (scripts/AI) +``` + +## Contract + +- **`--json`** (global flag): machine-readable JSON on stdout. Available on: + `list`, `status`, `scan`, `stats`, `activity`, `todo list`, `pr list`, + `issue list`, `project list`, `accounts`, `refresh`. +- **No prompts, ever.** Missing input = error + non-zero exit. +- **stdout** = results only. **stderr** = progress and errors. +- **Exit codes:** `0` ok · `1` execution error · `2` bad usage · + `3` auth missing/invalid · `4` not found (repo/todo/project). +- Repo arguments match the display label **or** the directory basename, + case-insensitively (e.g. `vulp status vulp` matches "Vulp - Open Source!"). +- Mutating commands write to the same `~/.vulp/activity.log` as the TUI, + so CLI actions appear in the TUI's Overview feed. + +## Workspace (local, no network) + +``` +vulp scan # repo count + dirty/behind/ahead summary +vulp list [--dirty] [--behind] [--ahead] [--lang ts] [--project NAME] +vulp status # branch, dirty, ahead/behind, last commit... +vulp stats [--period 1d|7d|30d|90d|1y|all] # commits per repo in period +vulp activity [--limit N] [--kind push|pull|release|"new repo"|todo|pr|issue|commit] +``` + +`vulp list --json` returns an array of repo objects: +```json +[{ "name": "...", "path": "...", "branch": "main", "ahead": 0, "behind": 0, + "is_dirty": true, "last_commit_msg": "...", "last_commit_age": "2h ago", + "last_commit_ts": 1780000000, "last_tag": null, "visibility": null, + "owner": "dualfroz", "has_license": false, "lang": "TypeScript 96%", + "bookmarked": false, "stars": null, "open_issues": null, "open_prs": null }] +``` + +## Git operations + +``` +vulp sync ... [--action pull|push|commit-push] [-m MSG] +vulp sync --all --action pull +vulp sync --project NAME --action push +vulp release --tag v1.2.0 [--notes "..."] # git tag + push + provider release +``` + +`commit-push` requires `-m/--message`. `sync` exits 1 if any target failed +(per-repo progress on stderr). + +## Todos + +``` +vulp todo list [--status open|in-progress|done|cancelled] [--json] +vulp todo add "Title" [--desc "..."] [--project NAME] +vulp todo start|done|cancel|rm +``` + +## Projects (repo groupings) + +``` +vulp project list [--json] +vulp project create NAME [--color "#e74c3c"] +vulp project assign NAME ... +vulp project rm NAME +``` + +## Provider API (needs `vulp auth import` first) + +``` +vulp pr list [--repo R] [--json] # cached — run `vulp refresh` for fresh +vulp pr create --repo R --title T --head BRANCH [--base main] +vulp issue list [--repo R] [--json] +vulp issue create --repo R --title T [--body "..."] +vulp refresh # re-fetch meta + PRs + issues into cache +vulp init NAME [--private] [--description "..."] # local init + create remote + push +vulp accounts [--json] +``` + +`pr/issue list` read the on-disk cache (`~/.vulp/`) — the same one the TUI +uses; run `vulp refresh` first when freshness matters. + +## Per-repo utilities + +``` +vulp repo url # print origin URL +vulp repo path # print absolute path +vulp repo open # open provider page in browser +vulp repo label "Display Name" # empty name clears the label +vulp repo bookmark # toggle +vulp repo pin # toggle +vulp repo collab [--role pull|push|admin] +``` + +## Recipes for AI agents + +```bash +# What needs attention right now? +vulp list --dirty --json + +# Commit and push everything in one project +vulp sync --project client-panel --action commit-push -m "chore: batch update" + +# Fresh cross-repo picture (meta + PRs + issues), then read it +vulp refresh --json && vulp pr list --json + +# Weekly activity summary +vulp stats --period 7d --json +``` diff --git a/docs/cross-platform.md b/docs/cross-platform.md index c1ccd97..5324488 100644 --- a/docs/cross-platform.md +++ b/docs/cross-platform.md @@ -1,4 +1,4 @@ -# fxg — strategia cross-platform +# vulp — strategia cross-platform > Cel: jedna binarka, trzy systemy, instalacja w 30 sekund, zero wiedzy technicznej wymaganej. @@ -14,9 +14,9 @@ Obecny problem z `keyring`: na Linuksie wymaga `libdbus` lub `libsecret`. Do roz ## Cel UX instalacji -**Windows:** Pobierasz plik `.exe`. Klikasz go dwukrotnie. Otwiera się terminal z konfiguratorem. Po 2 minutach piszesz `fxg status` i to działa. +**Windows:** Pobierasz plik `.exe`. Klikasz go dwukrotnie. Otwiera się terminal z konfiguratorem. Po 2 minutach piszesz `vulp status` i to działa. -**Linux:** Wklejasz jedną linię w terminal. Po 10 sekundach piszesz `fxg status` i to działa. +**Linux:** Wklejasz jedną linię w terminal. Po 10 sekundach piszesz `vulp status` i to działa. **macOS:** `brew install`. Jedna komenda. @@ -35,19 +35,19 @@ strategy: include: - os: ubuntu-latest target: x86_64-unknown-linux-musl # statyczna binarka — działa na każdym Linuksie - artifact: fxg-linux-x64 + artifact: vulp-linux-x64 - os: ubuntu-latest target: aarch64-unknown-linux-musl # ARM (np. Raspberry Pi, nowe serwery) - artifact: fxg-linux-arm64 + artifact: vulp-linux-arm64 - os: windows-latest target: x86_64-pc-windows-msvc - artifact: fxg-windows-x64.exe + artifact: vulp-windows-x64.exe - os: macos-latest target: x86_64-apple-darwin - artifact: fxg-macos-x64 + artifact: vulp-macos-x64 - os: macos-latest target: aarch64-apple-darwin # Apple Silicon (M1/M2/M3) - artifact: fxg-macos-arm64 + artifact: vulp-macos-arm64 ``` > Używamy `x86_64-unknown-linux-musl` zamiast `gnu` — binarka statyczna, nie wymaga żadnych systemowych `.so`. Keyring na Linuksie fallback do pliku szyfrowanego (jeśli brak dbus). @@ -61,15 +61,15 @@ Pliki lądują w GitHub Releases jako Assets. Użytkownik pobiera odpowiedni pli ### Skrypt jednolinijkowy (docelowy UX) ```bash -curl -fsSL https://github.com/twoj-user/fxg/releases/latest/download/install.sh | bash +curl -fsSL https://github.com/twoj-user/vulp/releases/latest/download/install.sh | bash ``` Skrypt robi: 1. Wykrywa architekturę (`uname -m`) 2. Pobiera odpowiednią binarkę z GitHub Releases -3. Umieszcza ją w `~/.local/bin/fxg` +3. Umieszcza ją w `~/.local/bin/vulp` 4. Dodaje `~/.local/bin` do `$PATH` w `.bashrc`/`.zshrc` jeśli jeszcze nie ma -5. Jeśli brak konfiguracji → uruchamia `fxg setup` (first-run wizard) +5. Jeśli brak konfiguracji → uruchamia `vulp setup` (first-run wizard) ```bash #!/bin/bash @@ -77,17 +77,17 @@ set -e ARCH=$(uname -m) OS=linux case $ARCH in - x86_64) FILE="fxg-linux-x64" ;; - aarch64) FILE="fxg-linux-arm64" ;; + x86_64) FILE="vulp-linux-x64" ;; + aarch64) FILE="vulp-linux-arm64" ;; *) echo "Nieobsługiwana architektura: $ARCH"; exit 1 ;; esac -LATEST=$(curl -s https://api.github.com/repos/twoj-user/fxg/releases/latest | grep tag_name | cut -d '"' -f 4) -URL="https://github.com/twoj-user/fxg/releases/download/${LATEST}/${FILE}" +LATEST=$(curl -s https://api.github.com/repos/twoj-user/vulp/releases/latest | grep tag_name | cut -d '"' -f 4) +URL="https://github.com/twoj-user/vulp/releases/download/${LATEST}/${FILE}" mkdir -p ~/.local/bin -curl -fsSL "$URL" -o ~/.local/bin/fxg -chmod +x ~/.local/bin/fxg +curl -fsSL "$URL" -o ~/.local/bin/vulp +chmod +x ~/.local/bin/vulp # Dodaj do PATH jeśli brak if ! echo "$PATH" | grep -q "$HOME/.local/bin"; then @@ -95,8 +95,8 @@ if ! echo "$PATH" | grep -q "$HOME/.local/bin"; then export PATH="$HOME/.local/bin:$PATH" fi -echo "fxg zainstalowany. Uruchamianie konfiguracji..." -fxg setup +echo "vulp zainstalowany. Uruchamianie konfiguracji..." +vulp setup ``` ### Opcjonalnie: pakiety .deb i .rpm @@ -105,10 +105,10 @@ Generowane przez `cargo-deb` i `cargo-rpm` w CI — przydatne dla systemów zarz ```bash # Ubuntu/Debian -sudo dpkg -i fxg_0.1.0_amd64.deb +sudo dpkg -i vulp_0.1.0_amd64.deb # Fedora/RHEL/CentOS -sudo rpm -i fxg-0.1.0.x86_64.rpm +sudo rpm -i vulp-0.1.0.x86_64.rpm ``` --- @@ -117,18 +117,18 @@ sudo rpm -i fxg-0.1.0.x86_64.rpm ### Opcja A: Kliknięcie `.exe` (docelowy UX dla grafika) -Gdy użytkownik dwukrotnie kliknie `fxg-windows-x64.exe` bez żadnych argumentów i brak konfiguracji, zamiast wyświetlić help i zamknąć okno — **otwiera PowerShell z konfiguratorem**. +Gdy użytkownik dwukrotnie kliknie `vulp-windows-x64.exe` bez żadnych argumentów i brak konfiguracji, zamiast wyświetlić help i zamknąć okno — **otwiera PowerShell z konfiguratorem**. Implementacja w `src/main.rs`: ```rust #[cfg(windows)] fn ensure_console() { // Jeśli uruchomiono przez dwuklik (nie z terminala), otwórz nowe okno konsoli - // i uruchom fxg setup w nim + // i uruchom vulp setup w nim use std::os::windows::process::CommandExt; if !is_launched_from_terminal() { std::process::Command::new("cmd") - .args(["/k", "fxg.exe", "setup"]) + .args(["/k", "vulp.exe", "setup"]) .creation_flags(0x00000010) // CREATE_NEW_CONSOLE .spawn().ok(); std::process::exit(0); @@ -140,19 +140,19 @@ fn ensure_console() { ```powershell # Wklej w PowerShell jako Administrator: -iwr https://github.com/twoj-user/fxg/releases/latest/download/install.ps1 | iex +iwr https://github.com/twoj-user/vulp/releases/latest/download/install.ps1 | iex ``` Skrypt: -1. Pobiera `fxg-windows-x64.exe` -2. Umieszcza w `%USERPROFILE%\AppData\Local\fxg\` +1. Pobiera `vulp-windows-x64.exe` +2. Umieszcza w `%USERPROFILE%\AppData\Local\vulp\` 3. Dodaje ten folder do `$PATH` (przez `[Environment]::SetEnvironmentVariable`) -4. Uruchamia `fxg setup` w nowym oknie terminalu +4. Uruchamia `vulp setup` w nowym oknie terminalu ### Opcja C: WinGet (Microsoft Store CLI) — przyszłość ``` -winget install fxg +winget install vulp ``` Wymaga zgłoszenia pakietu do repozytorium `winget-pkgs`. @@ -164,30 +164,30 @@ Wymaga zgłoszenia pakietu do repozytorium `winget-pkgs`. ### Homebrew (docelowy UX) ```bash -brew install twoj-user/tap/fxg +brew install twoj-user/tap/vulp ``` Wymaga stworzenia własnego "tap" (repozytorium z formularzem Homebrew): ```ruby -# Formula/fxg.rb -class Fxg < Formula +# Formula/vulp.rb +class Vulp < Formula desc "Multi-repository git workflow manager" - homepage "https://github.com/twoj-user/fxg" + homepage "https://github.com/twoj-user/vulp" version "0.1.0" on_macos do if Hardware::CPU.arm? - url "https://github.com/twoj-user/fxg/releases/download/v0.1.0/fxg-macos-arm64" + url "https://github.com/twoj-user/vulp/releases/download/v0.1.0/vulp-macos-arm64" sha256 "..." else - url "https://github.com/twoj-user/fxg/releases/download/v0.1.0/fxg-macos-x64" + url "https://github.com/twoj-user/vulp/releases/download/v0.1.0/vulp-macos-x64" sha256 "..." end end def install - bin.install "fxg-macos-arm64" => "fxg" + bin.install "vulp-macos-arm64" => "vulp" end end ``` @@ -196,7 +196,7 @@ end ## First-run wizard -Kluczowy element UX. Gdy użytkownik uruchamia **dowolną komendę** po raz pierwszy (brak `~/.fxg/config.toml`), zamiast error'a dostaje automatyczny setup. +Kluczowy element UX. Gdy użytkownik uruchamia **dowolną komendę** po raz pierwszy (brak `~/.vulp/config.toml`), zamiast error'a dostaje automatyczny setup. Implementacja w `src/app.rs`, przed każdym `match cli.command`: @@ -205,7 +205,7 @@ Implementacja w `src/app.rs`, przed każdym `match cli.command`: pub async fn run(cli: Cli) -> Result<()> { // Jeśli brak konfiguracji → uruchom first-run setup if !config::config_path().exists() { - println!("Wygląda na to, że uruchamiasz fxg po raz pierwszy."); + println!("Wygląda na to, że uruchamiasz vulp po raz pierwszy."); println!("Przeprowadźmy krótką konfigurację (zajmie ~2 minuty).\n"); first_run_setup().await?; } @@ -223,7 +223,7 @@ async fn first_run_setup() -> Result<()> { // Uruchom auth add wizard auth::run_add_wizard(&cfg).await?; } - println!("\nGotowe! Wpisz 'fxg status' żeby zobaczyć swoje projekty."); + println!("\nGotowe! Wpisz 'vulp status' żeby zobaczyć swoje projekty."); Ok(()) } ``` @@ -235,7 +235,7 @@ async fn first_run_setup() -> Result<()> { | Zależność | Problem | Rozwiązanie | |---|---|---| | `libgit2` | Wymagana na Linuksie przy dynamicznym linkowaniu | Używamy `git2` z feature `vendored` — kompiluje libgit2 statycznie | -| `keyring` / `libsecret` | Wymaga `dbus` na Linuksie headless | Fallback do zaszyfrowanego pliku `~/.fxg/credentials.enc` gdy brak dbus | +| `keyring` / `libsecret` | Wymaga `dbus` na Linuksie headless | Fallback do zaszyfrowanego pliku `~/.vulp/credentials.enc` gdy brak dbus | | `openssl` | Różne wersje na różnych systemach | Feature `native-tls` → `rustls` — TLS w całości wbudowany w Rust | ```toml @@ -250,7 +250,7 @@ keyring = { version = "3", features = ["sync-secret-service"] } ## O nazwie -Aktualna nazwa `fxg` ("finally executable git") to placeholder — dobra jako codename, ale przed publicznym wydaniem warto rozważyć zmianę. +Aktualna nazwa `vulp` ("finally executable git") to placeholder — dobra jako codename, ale przed publicznym wydaniem warto rozważyć zmianę. Kryteria dobrej nazwy narzędzia CLI: - Krótka (2–4 znaki idealne, max 6) diff --git a/docs/guide-designer.md b/docs/guide-designer.md index 1daf8d0..49f7e5b 100644 --- a/docs/guide-designer.md +++ b/docs/guide-designer.md @@ -1,4 +1,4 @@ -# Poradnik dla grafika — jak używać fxg +# Poradnik dla grafika — jak używać vulp > Ten poradnik zakłada, że nigdy wcześniej nie używałeś terminalu do pracy z kodem. Spokojnie — to jest prostsze niż myślisz. @@ -6,7 +6,7 @@ ## Co to jest i po co to komuś? -Wyobraź sobie, że każdy projekt (strona, aplikacja, assets) mieszka w osobnym folderze na GitHubie. `fxg` to program, który pozwala ci zarządzać tymi wszystkimi folderami jednocześnie — zobaczyć co się zmieniło, wysłać zmiany na serwer, pobrać najnowszą wersję — wszystko bez klikania po interfejsie GitHuba. +Wyobraź sobie, że każdy projekt (strona, aplikacja, assets) mieszka w osobnym folderze na GitHubie. `vulp` to program, który pozwala ci zarządzać tymi wszystkimi folderami jednocześnie — zobaczyć co się zmieniło, wysłać zmiany na serwer, pobrać najnowszą wersję — wszystko bez klikania po interfejsie GitHuba. **Dla Ciebie oznacza to:** zamiast wchodzić na stronę GitHuba i klikać w każde repozytorium osobno, wpisujesz jedną komendę i widzisz status wszystkiego. @@ -16,10 +16,10 @@ Wyobraź sobie, że każdy projekt (strona, aplikacja, assets) mieszka w osobnym ### Windows -1. Pobierz plik `fxg.exe` z sekcji [Releases](https://github.com/twoj-team/fxg/releases) (zakładka "Assets") +1. Pobierz plik `vulp.exe` z sekcji [Releases](https://github.com/twoj-team/vulp/releases) (zakładka "Assets") 2. Przenieś plik do folderu `C:\Users\TwojeImie\` (czyli do swojego folderu domowego) 3. Otwórz **Terminal** (wyszukaj "Terminal" w menu Start, lub `Windows + R` → wpisz `cmd` → Enter) -4. Wpisz `fxg --version` i naciśnij Enter — jeśli widzisz numer wersji, instalacja się udała +4. Wpisz `vulp --version` i naciśnij Enter — jeśli widzisz numer wersji, instalacja się udała > Jeśli system pyta "Czy chcesz uruchomić ten plik?" — kliknij "Tak" / "Uruchom mimo to". @@ -27,14 +27,14 @@ Wyobraź sobie, że każdy projekt (strona, aplikacja, assets) mieszka w osobnym Otwórz terminal i wklej: ``` -curl -fsSL https://github.com/twoj-team/fxg/install.sh | bash +curl -fsSL https://github.com/twoj-team/vulp/install.sh | bash ``` Gotowe. ### macOS ``` -brew install twoj-team/tap/fxg +brew install twoj-team/tap/vulp ``` (wymaga Homebrew — jeśli nie masz, zapytaj kolegę programistę) @@ -45,7 +45,7 @@ brew install twoj-team/tap/fxg Otwórz terminal i wpisz: ``` -fxg config +vulp config ``` Pojawi się ekran konfiguracji. Ustaw: @@ -66,7 +66,7 @@ Potrzebujesz **tokenu dostępu** z GitHuba. Oto jak go zdobyć: 1. Wejdź na [github.com/settings/tokens](https://github.com/settings/tokens) 2. Kliknij **"Generate new token (classic)"** -3. Wpisz nazwę (np. "fxg na moim laptopie") +3. Wpisz nazwę (np. "vulp na moim laptopie") 4. Zaznacz: `repo` (cały zakres) 5. Kliknij **"Generate token"** na dole 6. **Skopiuj token** — zobaczysz go tylko raz! @@ -74,7 +74,7 @@ Potrzebujesz **tokenu dostępu** z GitHuba. Oto jak go zdobyć: Teraz wróć do terminalu i wpisz (zamień `TWOJ_TOKEN` na skopiowany token): ``` -fxg auth add --provider github --token TWOJ_TOKEN +vulp auth add --provider github --token TWOJ_TOKEN ``` Gotowe. Token jest bezpiecznie zapisany na Twoim komputerze. @@ -86,7 +86,7 @@ Gotowe. Token jest bezpiecznie zapisany na Twoim komputerze. ### Sprawdź co się dzieje we wszystkich projektach ``` -fxg status +vulp status ``` Zobaczysz tabelę z projektami. Co oznaczają kolumny: @@ -112,7 +112,7 @@ Zobaczysz tabelę z projektami. Co oznaczają kolumny: Jeśli `BEHIND` pokazuje liczbę większą od 0, ktoś dodał zmiany na serwerze. Pobierz je: ``` -fxg sync pull --all +vulp sync pull --all ``` To pobierze najnowszą wersję **wszystkich** projektów naraz. @@ -124,7 +124,7 @@ To pobierze najnowszą wersję **wszystkich** projektów naraz. Masz nowe pliki (np. nowe grafiki, zaktualizowane assets)? Użyj interaktywnego trybu: ``` -fxg sync +vulp sync ``` Pojawi się ekran z listą projektów. Obsługa: @@ -142,7 +142,7 @@ Pojawi się ekran z listą projektów. Obsługa: ### Szybki push z linii komend (jeśli wiesz co robisz) ``` -fxg sync push --all -m "aktualizacja grafik landing page" +vulp sync push --all -m "aktualizacja grafik landing page" ``` To jednocześnie commita (z wiadomością po `-m`) i pushuje wszystkie zmienione projekty. @@ -165,23 +165,23 @@ To jednocześnie commita (z wiadomością po `-m`) i pushuje wszystkie zmienione ## Coś nie działa? -**Terminal nie zna komendy `fxg`** -- Windows: sprawdź czy plik `fxg.exe` jest w folderze który jest w PATH. Najprostsze rozwiązanie: przenieś plik do `C:\Windows\System32\` -- Linux/Mac: sprawdź czy `~/.cargo/bin` jest w PATH, lub uruchom ręcznie: `./fxg` +**Terminal nie zna komendy `vulp`** +- Windows: sprawdź czy plik `vulp.exe` jest w folderze który jest w PATH. Najprostsze rozwiązanie: przenieś plik do `C:\Windows\System32\` +- Linux/Mac: sprawdź czy `~/.cargo/bin` jest w PATH, lub uruchom ręcznie: `./vulp` **"Permission denied" lub "access denied"** -- Linux/Mac: wpisz `chmod +x fxg` przed pierwszym uruchomieniem +- Linux/Mac: wpisz `chmod +x vulp` przed pierwszym uruchomieniem **"Authentication failed" przy push/pull** - Token prawdopodobnie wygasł lub nie ma uprawnień do `repo` -- Wygeneruj nowy token i dodaj: `fxg auth add --provider github --token NOWY_TOKEN` +- Wygeneruj nowy token i dodaj: `vulp auth add --provider github --token NOWY_TOKEN` -**Nie widzę swoich projektów w `fxg status`** -- Sprawdź czy `workspace_root` w `fxg config` wskazuje na właściwy folder +**Nie widzę swoich projektów w `vulp status`** +- Sprawdź czy `workspace_root` w `vulp config` wskazuje na właściwy folder - Folder musi zawierać podfoldery z projektami (każdy projekt to osobny podfolder z `.git` w środku) -**Zmiany się nie wysyłają, `fxg sync push` nic nie robi** -- Najpierw sprawdź `fxg status` — kolumna `DIRTY` musi być `✓` żeby był co wysyłać +**Zmiany się nie wysyłają, `vulp sync push` nic nie robi** +- Najpierw sprawdź `vulp status` — kolumna `DIRTY` musi być `✓` żeby był co wysyłać - Jeśli `DIRTY` jest `✗`, ale `AHEAD` > 0 — wystarczy sam push bez commita --- @@ -189,10 +189,10 @@ To jednocześnie commita (z wiadomością po `-m`) i pushuje wszystkie zmienione ## Szybka ściąga ``` -fxg status # sprawdź co się dzieje -fxg sync pull --all # pobierz najnowsze wersje -fxg sync # interaktywny ekran: commit + push -fxg sync push --all -m "opis zmian" # szybki commit + push -fxg auth add --provider github --token TOKEN # dodaj konto -fxg config # zmień ustawienia +vulp status # sprawdź co się dzieje +vulp sync pull --all # pobierz najnowsze wersje +vulp sync # interaktywny ekran: commit + push +vulp sync push --all -m "opis zmian" # szybki commit + push +vulp auth add --provider github --token TOKEN # dodaj konto +vulp config # zmień ustawienia ``` diff --git a/docs/qol-ideas.md b/docs/qol-ideas.md index 3642ad4..2149e44 100644 --- a/docs/qol-ideas.md +++ b/docs/qol-ideas.md @@ -1,4 +1,4 @@ -# fxg — pomysły na QoL (Quality of Life) +# vulp — pomysły na QoL (Quality of Life) > Dokument roboczy. Każdy pomysł ma tytuł, opis, przykład użycia i szacunkową trudność implementacji. @@ -6,21 +6,21 @@ ## Wysoki priorytet -### 1. `fxg clone` — pobierz wszystkie repozytoria jedną komendą +### 1. `vulp clone` — pobierz wszystkie repozytoria jedną komendą **Problem:** Na nowym laptopie masz 50 repo na GitHubie. Musisz je klonować jedno po drugim. Żmudne. **Rozwiązanie:** Komenda pobierająca listę repo z API i klonująca je do `workspace_root`. ```bash -fxg clone --mine # repo których jesteś właścicielem -fxg clone --contributed # repo w których robiłeś commity (contributor) -fxg clone --org mojaFirma # wszystkie repo organizacji -fxg clone --starred # repo które oznaczyłeś gwiazdką +vulp clone --mine # repo których jesteś właścicielem +vulp clone --contributed # repo w których robiłeś commity (contributor) +vulp clone --org mojaFirma # wszystkie repo organizacji +vulp clone --starred # repo które oznaczyłeś gwiazdką # z filtrowaniem -fxg clone --mine --filter "frontend" # tylko repo z "frontend" w nazwie -fxg clone --mine --limit 20 # max 20 repo +vulp clone --mine --filter "frontend" # tylko repo z "frontend" w nazwie +vulp clone --mine --limit 20 # max 20 repo ``` **Implementacja:** Wywołać `provider.list_repos()` (nowa metoda w trait `Provider`), przefiltrować, wywołać `git clone` dla każdego. Wyświetlić progress w TUI podobny do `sync`. @@ -37,15 +37,15 @@ fxg clone --mine --limit 20 # max 20 repo ```bash # przy tworzeniu repo -fxg init moj-projekt --tags dev,frontend +vulp init moj-projekt --tags dev,frontend # retroaktywnie -fxg tag moj-projekt add client-acme -fxg tag moj-projekt remove dev +vulp tag moj-projekt add client-acme +vulp tag moj-projekt remove dev # reorganizacja lokalna — przenieś foldery według tagów -fxg workspace organize # pyta o potwierdzenie przed każdym przeniesieniem -fxg workspace organize --dry-run # pokaż co by się przeniosło, bez wykonania +vulp workspace organize # pyta o potwierdzenie przed każdym przeniesieniem +vulp workspace organize --dry-run # pokaż co by się przeniosło, bez wykonania ``` **Wynikowa struktura:** @@ -67,21 +67,21 @@ fxg workspace organize --dry-run # pokaż co by się przeniosło, bez wykonani --- -### 3. Domyślna paczka plików przy `fxg init` +### 3. Domyślna paczka plików przy `vulp init` **Problem:** Przy tworzeniu repo trzeba pamiętać o zaznaczeniu README, CHANGELOG, .env.example w wizardzie. Zawsze się o czymś zapomni. **Rozwiązanie:** Konfigurowalna lista plików tworzonych domyślnie (bez pytania), z możliwością wyłączenia. ```toml -# ~/.fxg/config.toml +# ~/.vulp/config.toml default_init_files = ["README.md", "CHANGELOG.md", ".env.example", ".gitignore"] ``` ```bash -fxg init moj-projekt # tworzy domyślny zestaw -fxg init moj-projekt --no-defaults # tworzy puste repo -fxg init moj-projekt --files readme,license # tylko te dwa +vulp init moj-projekt # tworzy domyślny zestaw +vulp init moj-projekt --no-defaults # tworzy puste repo +vulp init moj-projekt --files readme,license # tylko te dwa ``` Dodatkowo: **szablony zawartości pliku README** konfigurowalne w `config.toml` (`readme_template = "heartbyte-default"` już istnieje w konfigu, ale nie jest zaimplementowane). @@ -90,15 +90,15 @@ Dodatkowo: **szablony zawartości pliku README** konfigurowalne w `config.toml` --- -### 4. `fxg sync branch cleanup` — sprzątanie zmergowanych gałęzi +### 4. `vulp sync branch cleanup` — sprzątanie zmergowanych gałęzi **Problem:** Po miesiącach pracy masz 30 lokalnych gałęzi, z których 25 jest już zmergowanych do main. Zajmują miejsce, mylą. **Rozwiązanie:** ```bash -fxg sync branch cleanup # interaktywny wybór (TUI lista) -fxg sync branch cleanup --all # usuń wszystkie zmergowane bez pytania -fxg sync branch cleanup --dry-run # pokaż co by się usunęło +vulp sync branch cleanup # interaktywny wybór (TUI lista) +vulp sync branch cleanup --all # usuń wszystkie zmergowane bez pytania +vulp sync branch cleanup --dry-run # pokaż co by się usunęło ``` Usuwa gałęzie które są już zmergowane do `main`/`master` — lokalnie i opcjonalnie zdalnie. @@ -113,30 +113,30 @@ Usuwa gałęzie które są już zmergowane do `main`/`master` — lokalnie i opc ### 5. Shell completions (bash / zsh / fish / PowerShell) -Wpisanie `fxg ` podpowiada: +Wpisanie `vulp ` podpowiada: - subkomendy (`status`, `sync`, `init`...) -- nazwy repo dla `fxg status ` → lista repo z workspace_root +- nazwy repo dla `vulp status ` → lista repo z workspace_root - nazwy workspace dla `--workspace ` -- aliasy kont dla `fxg auth switch ` +- aliasy kont dla `vulp auth switch ` ```bash -fxg completions bash >> ~/.bashrc -fxg completions zsh >> ~/.zshrc -fxg completions fish > ~/.config/fish/completions/fxg.fish -fxg completions powershell >> $PROFILE +vulp completions bash >> ~/.bashrc +vulp completions zsh >> ~/.zshrc +vulp completions fish > ~/.config/fish/completions/vulp.fish +vulp completions powershell >> $PROFILE ``` -**Implementacja:** `clap` ma wbudowany generator completion przez crate `clap_complete`. Dynamiczne uzupełnianie nazw repo wymaga osobnego skryptu który wywołuje `fxg --list-repos` (nowa ukryta flaga). +**Implementacja:** `clap` ma wbudowany generator completion przez crate `clap_complete`. Dynamiczne uzupełnianie nazw repo wymaga osobnego skryptu który wywołuje `vulp --list-repos` (nowa ukryta flaga). **Trudność:** niska (statyczne) / średnia (dynamiczne nazwy repo). --- -### 6. `fxg status --watch` — live monitoring workspace +### 6. `vulp status --watch` — live monitoring workspace ```bash -fxg status --watch # odświeża TUI co 30 sekund -fxg status --watch --interval 10 # co 10 sekund +vulp status --watch # odświeża TUI co 30 sekund +vulp status --watch --interval 10 # co 10 sekund ``` TUI pokazuje kiedy ostatnio odświeżono. Przy zmianie statusu (np. nowy commit na serwerze) wiersz miga przez chwilę na żółto. @@ -147,13 +147,13 @@ TUI pokazuje kiedy ostatnio odświeżono. Przy zmianie statusu (np. nowy commit --- -### 7. Szablony projektów (`fxg init --template`) +### 7. Szablony projektów (`vulp init --template`) ```bash -fxg init moj-projekt --template react-app -fxg init moj-projekt --template rust-cli -fxg init moj-projekt --template python-fastapi -fxg init moj-projekt --template node-express +vulp init moj-projekt --template react-app +vulp init moj-projekt --template rust-cli +vulp init moj-projekt --template python-fastapi +vulp init moj-projekt --template node-express ``` Każdy szablon tworzy kompletną strukturę katalogów i pliki startowe: @@ -161,18 +161,18 @@ Każdy szablon tworzy kompletną strukturę katalogów i pliki startowe: - `rust-cli` → `src/main.rs`, `Cargo.toml` z clap+anyhow, `README.md` - `python-fastapi` → `main.py`, `requirements.txt`, `.env.example`, `Dockerfile` -Szablony przechowywane w `~/.fxg/templates/` jako foldery — użytkownik może dodać własne. +Szablony przechowywane w `~/.vulp/templates/` jako foldery — użytkownik może dodać własne. **Trudność:** średnia — logika tworzenia plików jest już w `init/mod.rs`, potrzebny nowy system szablonów. --- -### 8. `fxg pr list` — przegląd otwartych PR +### 8. `vulp pr list` — przegląd otwartych PR ```bash -fxg pr list # wszystkie PR we wszystkich repo workspace -fxg pr list --repo moj-projekt # tylko jedno repo -fxg pr list --mine # tylko PR gdzie jesteś autorem lub reviewerem +vulp pr list # wszystkie PR we wszystkich repo workspace +vulp pr list --repo moj-projekt # tylko jedno repo +vulp pr list --mine # tylko PR gdzie jesteś autorem lub reviewerem ``` Wyświetla TUI tabelę: repo | numer | tytuł | autor | data | etykiety. @@ -185,10 +185,10 @@ Enter na PR otwiera URL w przeglądarce. --- -### 9. Globalny `.fxgignore` — repo do pominięcia +### 9. Globalny `.vulpignore` — repo do pominięcia ``` -# ~/.fxg/ignore +# ~/.vulp/ignore archived-* # wszystkie repo zaczynające się od "archived-" old-monorepo # konkretne repo po nazwie ``` @@ -201,12 +201,12 @@ old-monorepo # konkretne repo po nazwie --- -### 10. `fxg stats --trend` — wykres aktywności w czasie +### 10. `vulp stats --trend` — wykres aktywności w czasie ```bash -fxg stats --trend # ostatnie 30 dni -fxg stats --trend --days 90 -fxg stats --trend --repo api-backend +vulp stats --trend # ostatnie 30 dni +vulp stats --trend --days 90 +vulp stats --trend --repo api-backend ``` ASCII wykres słupkowy commitów per dzień: @@ -230,12 +230,12 @@ May 22 ██████████████████░░░ 18 ### 11. VSCode Extension — automatyczne time tracking -Aktualnie `fxg stats time` czyta pliki JSON z `~/.fxg/vsc-data/`. Trzeba je tworzyć ręcznie lub osobnym narzędziem. +Aktualnie `vulp stats time` czyta pliki JSON z `~/.vulp/vsc-data/`. Trzeba je tworzyć ręcznie lub osobnym narzędziem. -**Rozwiązanie:** Towarzyszące rozszerzenie VS Code które automatycznie zapisuje czas spędzony na plikach w formacie rozumianym przez `fxg`. +**Rozwiązanie:** Towarzyszące rozszerzenie VS Code które automatycznie zapisuje czas spędzony na plikach w formacie rozumianym przez `vulp`. ``` -fxg stats time # pokazuje automatycznie zbierane dane +vulp stats time # pokazuje automatycznie zbierane dane ``` --- @@ -257,8 +257,8 @@ Identyczny mechanizm jak audit log w innych projektach z tego zespołu. ### 13. AI-assisted commit messages ```bash -fxg sync commit --ai # generuje wiadomość z diff za pomocą AI API -fxg sync commit --ai --edit # generuje, ale otwiera edytor do poprawki przed commitem +vulp sync commit --ai # generuje wiadomość z diff za pomocą AI API +vulp sync commit --ai --edit # generuje, ale otwiera edytor do poprawki przed commitem ``` Wysyła `git diff --staged` do API (np. Claude lub OpenAI), dostaje propozycję wiadomości w stylu conventional commits. @@ -267,7 +267,7 @@ Wymaga klucza API w konfiguracji. --- -### 14. `fxg release` multi-platform publish +### 14. `vulp release` multi-platform publish Po stworzeniu release tagu — automatyczne wyzwolenie publishu na: - `crates.io` (dla Rust) @@ -280,7 +280,7 @@ Wykrywa typ projektu przez `detect_project_type()` (już istnieje w `summary/mod ### 15. Interaktywny diff viewer -W widoku szczegółowym repo (`fxg status `) — klawisz `d` otwiera inlinowy diff zmienionych plików z kolorowym podświetleniem: +W widoku szczegółowym repo (`vulp status `) — klawisz `d` otwiera inlinowy diff zmienionych plików z kolorowym podświetleniem: ``` M src/components/Nav.tsx [d] diff [s] stage [r] revert @@ -297,7 +297,7 @@ W widoku szczegółowym repo (`fxg status `) — klawisz `d` otwiera inlin | # | Pomysł | Priorytet | Trudność | |---|---|---|---| -| 1 | `fxg clone --mine/--contributed` | Wysoki | Średnia | +| 1 | `vulp clone --mine/--contributed` | Wysoki | Średnia | | 2 | Tagi → organizacja folderów | Wysoki | Średnia | | 3 | Domyślna paczka plików przy init | Wysoki | Niska | | 4 | `branch cleanup` | Wysoki | Niska | @@ -305,7 +305,7 @@ W widoku szczegółowym repo (`fxg status `) — klawisz `d` otwiera inlin | 6 | `status --watch` | Średni | Niska | | 7 | Szablony projektów | Średni | Średnia | | 8 | `pr list` | Średni | Średnia | -| 9 | `.fxgignore` | Średni | Niska | +| 9 | `.vulpignore` | Średni | Niska | | 10 | `stats --trend` | Średni | Niska | | 11 | VSCode extension | Przyszłość | Wysoka | | 12 | Webhook przy sync | Przyszłość | Niska | diff --git a/install/vulp-installer.ps1 b/install/vulp-installer.ps1 new file mode 100644 index 0000000..ce72b37 --- /dev/null +++ b/install/vulp-installer.ps1 @@ -0,0 +1,31 @@ +# Installs the latest vulp release for Windows x86_64. +# Usage: irm https://raw.githubusercontent.com///main/install/vulp-installer.ps1 | iex +$ErrorActionPreference = "Stop" + +$Repo = if ($env:VULP_REPO) { $env:VULP_REPO } else { "dualfroz/fxg" } +$InstallDir = if ($env:VULP_INSTALL_DIR) { $env:VULP_INSTALL_DIR } else { "$env:LOCALAPPDATA\vulp" } +$BinName = "vulp.exe" + +Write-Host "Fetching latest vulp release info from github.com/$Repo..." +$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" +$asset = $release.assets | Where-Object { $_.name -like "*vulp-windows-x86_64*" } | Select-Object -First 1 + +if (-not $asset) { + Write-Error "vulp-installer: couldn't find a windows-x86_64 asset in the latest release." + exit 1 +} + +New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null +$dest = Join-Path $InstallDir $BinName +Write-Host "Downloading $($asset.browser_download_url)..." +Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $dest + +$userPath = [Environment]::GetEnvironmentVariable("Path", "User") +if ($userPath -notlike "*$InstallDir*") { + [Environment]::SetEnvironmentVariable("Path", "$userPath;$InstallDir", "User") + $env:Path = "$env:Path;$InstallDir" + Write-Host "Added $InstallDir to your user PATH (open a new terminal for it to take effect everywhere)." +} + +Write-Host "Installed vulp to $dest" +& $dest diff --git a/install/vulp-installer.sh b/install/vulp-installer.sh new file mode 100755 index 0000000..f8f8577 --- /dev/null +++ b/install/vulp-installer.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Installs the latest vulp release for Linux x86_64. +# Usage: curl -fsSL https://raw.githubusercontent.com///main/install/vulp-installer.sh | bash +set -euo pipefail + +REPO="${VULP_REPO:-dualfroz/fxg}" +INSTALL_DIR="${VULP_INSTALL_DIR:-$HOME/.local/bin}" +BIN_NAME="vulp" + +os="$(uname -s)" +arch="$(uname -m)" +if [ "$os" != "Linux" ] || [ "$arch" != "x86_64" ]; then + echo "vulp-installer: no prebuilt binary for $os/$arch (only Linux x86_64 is published)." >&2 + exit 1 +fi + +echo "Fetching latest vulp release info from github.com/$REPO..." +release_json="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest")" +asset_url="$(printf '%s' "$release_json" \ + | grep -o '"browser_download_url": *"[^"]*vulp-linux-x86_64[^"]*"' \ + | head -n1 \ + | sed -E 's/.*"(https:[^"]+)"/\1/')" + +if [ -z "$asset_url" ]; then + echo "vulp-installer: couldn't find a linux-x86_64 asset in the latest release." >&2 + exit 1 +fi + +mkdir -p "$INSTALL_DIR" +tmp="$(mktemp)" +echo "Downloading $asset_url..." +curl -fsSL "$asset_url" -o "$tmp" +chmod +x "$tmp" +mv "$tmp" "$INSTALL_DIR/$BIN_NAME" + +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) + shell_rc="" + case "${SHELL:-}" in + */zsh) shell_rc="$HOME/.zshrc" ;; + */bash) shell_rc="$HOME/.bashrc" ;; + esac + if [ -n "$shell_rc" ]; then + echo "export PATH=\"$INSTALL_DIR:\$PATH\"" >> "$shell_rc" + echo "Added $INSTALL_DIR to PATH in $shell_rc (restart your shell, or run: export PATH=\"$INSTALL_DIR:\$PATH\")" + else + echo "Add $INSTALL_DIR to your PATH to use 'vulp' from anywhere." + fi + ;; +esac + +echo "Installed vulp to $INSTALL_DIR/$BIN_NAME" +exec "$INSTALL_DIR/$BIN_NAME" diff --git a/src/activity/mod.rs b/src/activity/mod.rs new file mode 100644 index 0000000..60890d0 --- /dev/null +++ b/src/activity/mod.rs @@ -0,0 +1,92 @@ +//! Local activity log (`~/.vulp/activity.log`, one JSON line per event) for +//! actions vulp itself performed — feeds the hub's Activity tab alongside +//! (but separately from) the git-log-sourced commit feed, which sees real +//! commit history regardless of which tool made it. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// The kind of action recorded in one activity log entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ActivityKind { Commit, Push, Pull, Release, NewRepo, Todo, Pr, Issue } + +impl ActivityKind { + /// Display label shown in the Activity tab's table. + pub fn label(&self) -> &'static str { + match self { + Self::Commit => "commit", + Self::Push => "push", + Self::Pull => "pull", + Self::Release => "release", + Self::NewRepo => "new repo", + Self::Todo => "todo", + Self::Pr => "pr", + Self::Issue => "issue", + } + } + + /// Cycle order for the Activity tab's kind filter (`All` is handled + /// separately as `None`). + pub fn cycle() -> &'static [ActivityKind] { + &[ + Self::Commit, Self::Push, Self::Pull, Self::Release, + Self::NewRepo, Self::Todo, Self::Pr, Self::Issue, + ] + } +} + +/// One recorded activity-log event. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActivityEntry { + pub ts: i64, + pub kind: ActivityKind, + pub repo: String, + pub detail: String, +} + +fn log_path() -> PathBuf { + dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")).join(".vulp").join("activity.log") +} + +/// Appends one event to the local activity log. Only called from code paths +/// where vulp itself performs the action (push/pull/release/repo-create/ +/// todo-scan) — it has no way to see git activity done outside vulp, which +/// the Activity tab's commit feed (sourced straight from git log) covers +/// separately and accurately regardless of how it happened. +pub fn log(entry: &ActivityEntry) { + let Ok(line) = serde_json::to_string(entry) else { return }; + let path = log_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) { + use std::io::Write; + let _ = writeln!(f, "{line}"); + } +} + +/// Loads every logged entry, skipping any line that fails to parse (a +/// truncated last line from a crash, for instance) rather than discarding +/// the whole log. +pub fn load_logged() -> Vec { + let Ok(content) = std::fs::read_to_string(log_path()) else { return vec![] }; + content.lines().filter_map(|l| serde_json::from_str(l).ok()).collect() +} + +/// Keeps the append-only log from growing forever: when it exceeds +/// `max_entries`, rewrites the file with only the newest `max_entries`. +/// Called once at hub startup — cheap no-op below the threshold. +pub fn compact(max_entries: usize) { + let entries = load_logged(); + if entries.len() <= max_entries { return; } + let keep = &entries[entries.len() - max_entries..]; + let body: String = keep.iter() + .filter_map(|e| serde_json::to_string(e).ok()) + .map(|l| l + "\n") + .collect(); + let path = log_path(); + let tmp = path.with_extension("log.tmp"); + if std::fs::write(&tmp, body).is_ok() { + let _ = std::fs::rename(&tmp, &path); + } +} diff --git a/src/app.rs b/src/app.rs index 04bd7dc..0587067 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,37 +1,27 @@ +//! Top-level command dispatch: loads config, then routes to the TUI hub, +//! one of the setup subcommands, or the non-interactive CLI surface. + use crate::cli::{Cli, Commands}; use anyhow::Result; -const NOT_YET: &str = "\ - This CLI command is not yet implemented. - Launch the TUI with: fxg -"; - +/// Dispatches a parsed `Cli` invocation. With no subcommand, this launches +/// straight into the TUI hub — `vulp` is meant to be used interactively, so +/// the bare command is the primary entry point rather than a help screen. +/// Every other subcommand (besides `config`/`auth`) goes to `cli_cmds`, +/// the machine-friendly non-interactive surface. pub async fn run(cli: Cli) -> Result<()> { if cli.debug { crate::debug::enable(); } let cfg = crate::config::load_config()?; match cli.command { // ── no subcommand → TUI hub ── - None => crate::status::run( - crate::cli::StatusArgs { repo: None, workspace: None, filter: None }, - &cfg, - ).await, + None => crate::status::run(&cfg).await, - // ── always-on: config + auth ── - Some(Commands::Config) => crate::config::run(cfg).await, - Some(Commands::Auth(args)) => crate::auth::run(args, &cfg).await, + // ── setup, needed before the TUI has anything to show ── + Some(Commands::Config) => crate::config::run(cfg).await, + Some(Commands::Auth(args)) => crate::auth::run(args, &cfg).await, - // ── everything else: stub ── - Some(Commands::Init(_)) => { eprintln!("{}", NOT_YET); Ok(()) } - Some(Commands::Status(_)) => { eprintln!("{}", NOT_YET); Ok(()) } - Some(Commands::Sync(_)) => { eprintln!("{}", NOT_YET); Ok(()) } - Some(Commands::Release(_)) => { eprintln!("{}", NOT_YET); Ok(()) } - Some(Commands::Refactor(_)) => { eprintln!("{}", NOT_YET); Ok(()) } - Some(Commands::Stats(_)) => { eprintln!("{}", NOT_YET); Ok(()) } - Some(Commands::Workspace(_)) => { eprintln!("{}", NOT_YET); Ok(()) } - Some(Commands::Summary(_)) => { eprintln!("{}", NOT_YET); Ok(()) } - Some(Commands::Adopt(_)) => { eprintln!("{}", NOT_YET); Ok(()) } - Some(Commands::Manage(_)) => { eprintln!("{}", NOT_YET); Ok(()) } - Some(Commands::Util(_)) => { eprintln!("{}", NOT_YET); Ok(()) } + // ── everything else: non-interactive CLI ── + Some(cmd) => crate::cli_cmds::run(cmd, cli.json, &cfg).await, } } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 3418659..fe33d27 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,3 +1,8 @@ +//! Provider-account management: storing account metadata in +//! `~/.vulp/accounts.toml` and the matching access tokens via the OS keyring +//! (with an encrypted-permission file fallback), plus the `vulp auth` CLI +//! subcommand and importing credentials straight from the `gh` CLI. + pub mod ui; use crate::cli::{AuthAction, AuthArgs}; @@ -6,6 +11,9 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +/// One configured provider account. `alias` distinguishes multiple accounts +/// on the same provider (e.g. a personal and a work GitHub account); +/// `id()` (provider/alias) is the key used everywhere else to reference one. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Account { pub provider: String, @@ -14,9 +22,20 @@ pub struct Account { pub email: String, pub base_url: Option, pub is_default: bool, + /// Date (YYYY-MM-DD) this account was added to vulp. Absent on accounts + /// saved before the field existed. + #[serde(default)] + pub added_at: Option, +} + +/// Today as YYYY-MM-DD, for stamping `Account::added_at`. +fn today() -> String { + chrono::Utc::now().format("%Y-%m-%d").to_string() } impl Account { + /// The stable key used to look up this account's token and to reference + /// it from CLI args (`provider/alias`). pub fn id(&self) -> String { format!("{}/{}", self.provider, self.alias) } @@ -29,14 +48,19 @@ struct AccountsFile { accounts: Vec, } +/// Path to the accounts file, `/accounts.toml`. pub fn accounts_path(cfg: &AppConfig) -> PathBuf { cfg.data_dir.join("accounts.toml") } +/// Loads all configured accounts (empty if none have been added yet). pub fn load_accounts(cfg: &AppConfig) -> Result> { load_accounts_path(&cfg.data_dir) } +/// Like `load_accounts`, but takes the data directory directly — used by +/// callers (like the detail view) that only have a `PathBuf` on hand, not a +/// full `AppConfig`. pub fn load_accounts_path(data_dir: &std::path::Path) -> Result> { let path = data_dir.join("accounts.toml"); if path.exists() { @@ -48,6 +72,7 @@ pub fn load_accounts_path(data_dir: &std::path::Path) -> Result> { } } +/// Overwrites the accounts file with `accounts`. pub fn save_accounts(accounts: &[Account], cfg: &AppConfig) -> Result<()> { let path = accounts_path(cfg); if let Some(dir) = path.parent() { @@ -59,16 +84,22 @@ pub fn save_accounts(accounts: &[Account], cfg: &AppConfig) -> Result<()> { } // ── token storage (keyring with file fallback) ──────────────────────────────── +// +// The file at `.tokens` (mode 0600) is the actual source of truth — the OS +// keyring is written to as a convenience but never required to be present, +// since headless/CI environments and some Linux setups don't have a working +// keyring backend at all. -const SERVICE: &str = "fxg"; +const SERVICE: &str = "vulp"; fn tokens_file() -> std::path::PathBuf { dirs::home_dir() .unwrap_or_else(|| std::path::PathBuf::from("~")) - .join(".fxg") + .join(".vulp") .join(".tokens") } +/// Reads one token by id from the plaintext-but-0600 tokens file. fn file_read_token(id: &str) -> Option { let raw = std::fs::read_to_string(tokens_file()).ok()?; for line in raw.lines() { @@ -81,6 +112,8 @@ fn file_read_token(id: &str) -> Option { None } +/// Writes or replaces one token's line in the tokens file, then restricts +/// the file to owner read/write (0600) so other local users can't read it. fn file_write_token(id: &str, token: &str) -> Result<()> { let path = tokens_file(); if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } @@ -104,6 +137,7 @@ fn file_write_token(id: &str, token: &str) -> Result<()> { Ok(()) } +/// Removes one token's line from the tokens file, if present. fn file_delete_token(id: &str) -> Result<()> { let path = tokens_file(); if !path.exists() { return Ok(()); } @@ -116,6 +150,8 @@ fn file_delete_token(id: &str) -> Result<()> { Ok(()) } +/// Stores a token for `id` in the tokens file, and best-effort mirrors it +/// into the OS keyring. pub fn store_token(id: &str, token: &str) -> Result<()> { // File is always the source of truth — keyring is best-effort only file_write_token(id, token)?; @@ -125,6 +161,9 @@ pub fn store_token(id: &str, token: &str) -> Result<()> { Ok(()) } +/// Retrieves the token for `id`, checking the tokens file first and falling +/// back to the OS keyring (migrating it into the file on a keyring hit, so +/// the fallback path is only ever needed once per token). pub fn get_token(id: &str) -> Result { // Read from file first (reliable); keyring as fallback for tokens stored before this fix if let Some(t) = file_read_token(id) { @@ -137,11 +176,22 @@ pub fn get_token(id: &str) -> Result { return Ok(t); } } + // Last resort for GitHub accounts: borrow the gh CLI's token. Not + // persisted — gh rotates oauth tokens, so ask it fresh each time. + if id.starts_with("github/") { + if let Ok(out) = std::process::Command::new("gh").args(["auth", "token"]).output() { + let t = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if out.status.success() && !t.is_empty() { + return Ok(t); + } + } + } Err(anyhow::anyhow!( - "no token for '{}' — run: fxg auth import (or fxg auth add --token )", id + "no token for '{}' — run: vulp auth import (or vulp auth add --token )", id )) } +/// Removes the token for `id` from both the tokens file and the OS keyring. pub fn delete_token(id: &str) -> Result<()> { file_delete_token(id)?; if let Ok(entry) = keyring::Entry::new(SERVICE, id) { @@ -152,6 +202,8 @@ pub fn delete_token(id: &str) -> Result<()> { // ── mutation helpers ────────────────────────────────────────────────────────── +/// Inserts or replaces `account` (matched by id) and stores its token, +/// persisting both in one call so the two never drift out of sync. fn add_account( accounts: &mut Vec, account: Account, @@ -166,6 +218,7 @@ fn add_account( Ok(()) } +/// Removes the account matching `id` and deletes its stored token. fn remove_account(accounts: &mut Vec, id: &str, cfg: &AppConfig) -> Result<()> { accounts.retain(|a| a.id() != id); save_accounts(accounts, cfg)?; @@ -173,7 +226,9 @@ fn remove_account(accounts: &mut Vec, id: &str, cfg: &AppConfig) -> Res Ok(()) } -fn switch_default(accounts: &mut Vec, id: &str, cfg: &AppConfig) -> Result<()> { +/// Marks the account matching `id` as the default and every other account +/// as non-default. Errors if `id` doesn't match any configured account. +fn switch_default(accounts: &mut [Account], id: &str, cfg: &AppConfig) -> Result<()> { let exists = accounts.iter().any(|a| a.id() == id); anyhow::ensure!(exists, "account '{}' not found", id); for a in accounts.iter_mut() { @@ -185,6 +240,9 @@ fn switch_default(accounts: &mut Vec, id: &str, cfg: &AppConfig) -> Res // ── gh CLI import ───────────────────────────────────────────────────────────── +/// Runs `gh `, returning trimmed stdout on success — `None` on any +/// failure or empty/"null" output, so callers can use `?`/`ok_or_else` +/// without separately checking exit status. fn gh_output(args: &[&str]) -> Option { std::process::Command::new("gh") .args(args) @@ -196,6 +254,9 @@ fn gh_output(args: &[&str]) -> Option { .filter(|s| !s.is_empty() && s != "null") } +/// Imports a GitHub account using credentials already authenticated via the +/// `gh` CLI — no token entry needed from the user. Becomes the new default +/// account. fn import_from_gh(accounts: &mut Vec, cfg: &AppConfig) -> Result<()> { let token = gh_output(&["auth", "token"]) .ok_or_else(|| anyhow::anyhow!( @@ -215,6 +276,7 @@ fn import_from_gh(accounts: &mut Vec, cfg: &AppConfig) -> Result<()> { email, base_url: None, is_default: true, + added_at: Some(today()), }; // mark all others as non-default @@ -227,6 +289,9 @@ fn import_from_gh(accounts: &mut Vec, cfg: &AppConfig) -> Result<()> { // ── entry point ─────────────────────────────────────────────────────────────── +/// Entry point for `vulp auth`. With no subcommand, opens the interactive +/// account-management TUI; otherwise runs the requested action directly and +/// prints a one-line confirmation. pub async fn run(args: AuthArgs, cfg: &AppConfig) -> Result<()> { let mut accounts = load_accounts(cfg)?; @@ -242,6 +307,7 @@ pub async fn run(args: AuthArgs, cfg: &AppConfig) -> Result<()> { let account = Account { provider, alias, username: cfg.username.clone(), email: cfg.email.clone(), base_url: url, is_default: accounts.is_empty(), + added_at: Some(today()), }; add_account(&mut accounts, account, &token, cfg)?; println!("✓ Account added"); diff --git a/src/auth/ui.rs b/src/auth/ui.rs index f51c144..62fd5bb 100644 --- a/src/auth/ui.rs +++ b/src/auth/ui.rs @@ -1,3 +1,9 @@ +//! Standalone TUI for `vulp auth` with no subcommand: lists configured +//! accounts and lets the user remove one or change the default. Adding an +//! account isn't done here — it needs an interactive token prompt, which +//! doesn't fit cleanly inside this raw-mode loop, so that's deferred to the +//! `vulp auth add` CLI form instead. + use crate::auth::Account; use crate::config::AppConfig; use crate::tui; @@ -17,10 +23,12 @@ struct State { quit: bool, } +/// Draws the account table (provider, alias, email, default marker) and the +/// key-hint/status line below it. fn render(f: &mut Frame, state: &State) { let area = f.area(); let outer = Block::default() - .title(" fxg — Authenticated Accounts ") + .title(" vulp — Authenticated Accounts ") .borders(Borders::ALL); let inner = outer.inner(area); f.render_widget(outer, area); @@ -69,6 +77,8 @@ fn render(f: &mut Frame, state: &State) { ); } +/// Runs the account-management TUI until the user quits, returning the +/// (possibly modified) account list for the caller to persist. pub fn run_auth_tui(mut accounts: Vec, _cfg: &AppConfig) -> Result> { let mut state = State { accounts: accounts.clone(), selected: 0, status: None, quit: false }; @@ -87,7 +97,7 @@ pub fn run_auth_tui(mut accounts: Vec, _cfg: &AppConfig) -> Result { // must restore terminal temporarily for inquire // handled outside the raw-mode loop - state.status = Some(" Press [q] first, then use: fxg auth add --provider

--token ".into()); + state.status = Some(" Press [q] first, then use: vulp auth add --provider

--token ".into()); } KeyCode::Char('r') => { if let Some(a) = state.accounts.get(state.selected) { diff --git a/src/cache.rs b/src/cache.rs index a4e3f0d..52b69eb 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,8 +1,16 @@ +//! On-disk cache (`~/.vulp/meta_cache.toml`) of provider-API metadata +//! (stars, issue/PR counts, visibility) and the PRs/issues lists themselves, +//! keyed by repo name. Lets the hub show this data immediately on startup +//! without blocking on a fresh API round-trip for every repo. + use crate::providers::{IssueDetail, PrDetail}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; +/// Cached provider-API fields for one repo. All `Option` since a repo with +/// no configured provider account (or an unauthenticated one) simply has +/// nothing to cache yet. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct RepoMeta { pub stars: Option, @@ -13,6 +21,8 @@ pub struct RepoMeta { pub fetched_at: i64, } +/// Everything cached for one repo: its metadata plus its last-fetched PRs +/// and issues lists. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CacheEntry { #[serde(default)] @@ -23,6 +33,7 @@ pub struct CacheEntry { pub issues: Vec, } +/// The full on-disk cache, keyed by repo name. #[derive(Debug, Default, Serialize, Deserialize)] pub struct MetaCache { #[serde(default)] @@ -30,15 +41,19 @@ pub struct MetaCache { } fn file_path() -> PathBuf { - dirs::home_dir().unwrap_or_default().join(".fxg").join("meta_cache.toml") + dirs::home_dir().unwrap_or_default().join(".vulp").join("meta_cache.toml") } +/// Loads the cache from disk, or an empty one if the file doesn't exist or +/// fails to parse — a corrupt or missing cache is never fatal, since +/// everything in it can be refetched from the provider API. pub fn load() -> MetaCache { let p = file_path(); if !p.exists() { return MetaCache::default(); } toml::from_str(&std::fs::read_to_string(p).unwrap_or_default()).unwrap_or_default() } +/// Writes the cache to disk, creating `~/.vulp/` if needed. pub fn save(cache: &MetaCache) -> anyhow::Result<()> { let p = file_path(); if let Some(parent) = p.parent() { std::fs::create_dir_all(parent)?; } @@ -47,18 +62,22 @@ pub fn save(cache: &MetaCache) -> anyhow::Result<()> { } impl MetaCache { + /// Looks up the cache entry for one repo by name. pub fn get(&self, repo_name: &str) -> Option<&CacheEntry> { self.repos.get(repo_name) } + /// Replaces the cached metadata for one repo, creating its entry if needed. pub fn set_meta(&mut self, repo_name: &str, meta: RepoMeta) { self.repos.entry(repo_name.to_string()).or_default().meta = meta; } + /// Replaces the cached PR list for one repo, creating its entry if needed. pub fn set_prs(&mut self, repo_name: &str, prs: Vec) { self.repos.entry(repo_name.to_string()).or_default().prs = prs; } + /// Replaces the cached issue list for one repo, creating its entry if needed. pub fn set_issues(&mut self, repo_name: &str, issues: Vec) { self.repos.entry(repo_name.to_string()).or_default().issues = issues; } diff --git a/src/cli.rs b/src/cli.rs index 56280ca..05c8d32 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,45 +1,214 @@ +//! Command-line surface: `clap` argument definitions. `vulp` with no +//! subcommand launches the TUI hub; every hub operation is also reachable +//! through a subcommand here so scripts and AI agents can drive vulp without +//! a terminal UI. Machine consumers: pass `--json` and see `docs/cli.md`. +//! +//! Exit codes: 0 ok · 1 execution error · 2 bad usage (clap) · +//! 3 auth missing/invalid · 4 not found (repo/todo/project). + use clap::{Args, Parser, Subcommand}; #[derive(Parser)] -#[command(name = "fxg", about = "Multi-repo git workflow manager", version)] +#[command(name = "vulp", about = "Multi-repo git workflow manager", version)] pub struct Cli { #[command(subcommand)] pub command: Option, /// Show timing debug panel at bottom of TUI #[arg(long, hide = true)] pub debug: bool, + /// Machine-readable JSON output on stdout (list/status/stats/... commands) + #[arg(long, global = true)] + pub json: bool, } #[derive(Subcommand)] pub enum Commands { - /// Configure fxg settings + /// Configure vulp settings Config, /// Manage provider accounts Auth(AuthArgs), - /// Initialize new repositories - Init(InitArgs), - /// View repository status - Status(StatusArgs), - /// Sync repositories (pull / commit / push / branch / PR) - Sync(SyncArgs), - /// Manage releases, tags and changelogs - Release(ReleaseArgs), - /// Rename, find and clean code - Refactor(RefactorArgs), - /// Code statistics and metrics - Stats(StatsArgs), - /// Manage repo workspaces - Workspace(WorkspaceArgs), - /// Summarize repository structure, languages, git history and dependencies - Summary(SummaryArgs), - /// Import existing local folders into git + create remote repo - Adopt(AdoptArgs), - /// Manage remote repo properties (visibility, topics, rename, delete, archive) - Manage(ManageArgs), - /// Utility commands: unified log, secret scanning, doc generation - Util(UtilArgs), + + /// List workspace repos (name, branch, dirty/ahead/behind, language) + List { + /// Only repos with uncommitted changes + #[arg(long)] dirty: bool, + /// Only repos behind their remote + #[arg(long)] behind: bool, + /// Only repos ahead of their remote + #[arg(long)] ahead: bool, + /// Filter by language (substring, case-insensitive) + #[arg(long)] lang: Option, + /// Filter by vulp project name + #[arg(long)] project: Option, + }, + /// Detailed status of one repo + Status { repo: String }, + /// Rescan the workspace and print a summary + Scan, + + /// Pull/push/commit+push one or more repos + Sync { + /// Repo names (omit with --all or --project) + repos: Vec, + /// Sync every repo in the workspace + #[arg(long)] all: bool, + /// Sync every repo in a vulp project + #[arg(long)] project: Option, + /// pull | push | commit-push + #[arg(long, default_value = "pull")] action: String, + /// Commit message (required for commit-push) + #[arg(short, long)] message: Option, + }, + /// Tag a release and publish it on the provider + Release { + repo: String, + /// Tag, e.g. v1.2.0 + #[arg(long)] tag: String, + /// Release notes body + #[arg(long, default_value = "")] notes: String, + }, + + /// Manage todos + Todo { + #[command(subcommand)] + action: TodoCmd, + }, + /// List / create pull requests + Pr { + #[command(subcommand)] + action: PrCmd, + }, + /// List / create issues + Issue { + #[command(subcommand)] + action: IssueCmd, + }, + /// Manage vulp projects (repo groupings) + Project { + #[command(subcommand)] + action: ProjectCmd, + }, + /// Per-repo utilities (url/path/open/label/bookmark/pin/collab) + Repo { + #[command(subcommand)] + action: RepoCmd, + }, + /// Create a new repo: local init + create on provider + push + Init { + name: String, + /// Create as private on the provider + #[arg(long)] private: bool, + /// Repo description + #[arg(long, default_value = "")] description: String, + }, + + /// Workspace-wide commit statistics + Stats { + /// 1d | 7d | 30d | 90d | 1y | all + #[arg(long, default_value = "30d")] period: String, + }, + /// Recent vulp-performed actions (activity log) + Activity { + #[arg(long, default_value_t = 20)] limit: usize, + /// commit | push | pull | release | new repo | todo | pr | issue + #[arg(long)] kind: Option, + }, + /// List configured provider accounts + Accounts, + /// Fetch metadata/PRs/issues + merged/closed counters from provider APIs + Refresh, } +#[derive(Subcommand)] +pub enum TodoCmd { + /// List todos + List { + /// open | in-progress | done | cancelled + #[arg(long)] status: Option, + }, + /// Add a todo + Add { + title: String, + #[arg(long, default_value = "")] desc: String, + #[arg(long)] project: Option, + }, + /// Mark a todo done + Done { id: u64 }, + /// Mark a todo in-progress + Start { id: u64 }, + /// Mark a todo cancelled + Cancel { id: u64 }, + /// Delete a todo + Rm { id: u64 }, +} + +#[derive(Subcommand)] +pub enum PrCmd { + /// List cached open PRs (run `vulp refresh` first for fresh data) + List { + #[arg(long)] repo: Option, + }, + /// Open a pull request on the provider + Create { + #[arg(long)] repo: String, + #[arg(long)] title: String, + #[arg(long)] head: String, + #[arg(long, default_value = "main")] base: String, + }, +} + +#[derive(Subcommand)] +pub enum IssueCmd { + /// List cached open issues (run `vulp refresh` first for fresh data) + List { + #[arg(long)] repo: Option, + }, + /// Open an issue on the provider + Create { + #[arg(long)] repo: String, + #[arg(long)] title: String, + #[arg(long, default_value = "")] body: String, + }, +} + +#[derive(Subcommand)] +pub enum ProjectCmd { + /// List projects with their repos + List, + /// Create a project + Create { + name: String, + /// Hex color, e.g. "#e74c3c" + #[arg(long, default_value = "#3da7ff")] color: String, + }, + /// Assign repos to a project + Assign { name: String, repos: Vec }, + /// Delete a project (repos stay on disk) + Rm { name: String }, +} + +#[derive(Subcommand)] +pub enum RepoCmd { + /// Print the repo's remote URL + Url { repo: String }, + /// Print the repo's absolute path + Path { repo: String }, + /// Open the repo page in the browser + Open { repo: String }, + /// Set/clear the repo's display label (empty = reset) + Label { repo: String, #[arg(default_value = "")] name: String }, + /// Toggle bookmark + Bookmark { repo: String }, + /// Toggle pin + Pin { repo: String }, + /// Invite a collaborator + Collab { + repo: String, + user: String, + /// pull | push | admin + #[arg(long, default_value = "push")] role: String, + }, +} #[derive(Args)] pub struct AuthArgs { @@ -47,6 +216,10 @@ pub struct AuthArgs { pub action: Option, } +/// `vulp auth` subcommands. Account state is a flat list of credentials +/// (one per provider account); `Switch` changes which one is the default +/// rather than removing the others, so multiple accounts can stay configured +/// at once. #[derive(Subcommand)] pub enum AuthAction { Add { @@ -61,246 +234,3 @@ pub enum AuthAction { Switch { account: String }, Test { account: Option }, } - - -#[derive(Args)] -pub struct InitArgs { - pub name: Option, - #[arg(long)] pub provider: Option, - #[arg(long, value_delimiter = ',')] pub tags: Vec, - #[arg(long, default_value = "private")] pub status: String, - #[arg(long, default_value = "main")] pub branch: String, - #[arg(long)] pub batch: Option, -} - - -#[derive(Args)] -pub struct StatusArgs { - pub repo: Option, - #[arg(long)] pub workspace: Option, - #[arg(long)] pub filter: Option, -} - - -#[derive(Args)] -pub struct SyncArgs { - #[command(subcommand)] - pub action: Option, -} - -#[derive(Subcommand)] -pub enum SyncAction { - Pull { - #[arg(long)] all: bool, - #[arg(long)] workspace: Option, - }, - Commit { - #[arg(short = 'm', long)] message: Option, - #[arg(long)] all: bool, - }, - Push { - #[arg(long)] all: bool, - #[arg(long)] force_with_lease: bool, - /// If set, also stage+commit dirty repos with this message before pushing - #[arg(short = 'm', long)] message: Option, - }, - Branch(BranchArgs), - Pr(PrArgs), -} - -#[derive(Args)] -pub struct PrArgs { - #[command(subcommand)] - pub action: PrAction, -} - -#[derive(Subcommand)] -pub enum PrAction { - Create { - #[arg(long)] title: Option, - #[arg(long)] base: Option, - #[arg(long)] head: Option, - #[arg(long, value_delimiter = ',')] reviewers: Vec, - #[arg(long, value_delimiter = ',')] labels: Vec, - #[arg(long)] repo: Option, - }, -} - -#[derive(Args)] -pub struct BranchArgs { - #[command(subcommand)] - pub action: BranchAction, -} - -#[derive(Subcommand)] -pub enum BranchAction { - Create { name: String, #[arg(long, value_delimiter = ',')] repos: Vec }, - Delete { name: String }, - Switch { name: String, #[arg(long)] all: bool }, -} - - -#[derive(Args)] -pub struct ReleaseArgs { - #[command(subcommand)] - pub action: Option, -} - -#[derive(Subcommand)] -pub enum ReleaseAction { - Create { - version: Option, - #[arg(long, default_value = "patch")] release_type: String, - #[arg(long)] generate_changelog: bool, - }, - List, - Tag { version: String, #[arg(long, value_delimiter = ',')] repos: Vec }, -} - - -#[derive(Args)] -pub struct RefactorArgs { - #[command(subcommand)] - pub action: Option, -} - -#[derive(Subcommand)] -pub enum RefactorAction { - Rename { - kind: String, from: String, to: String, - #[arg(long, value_delimiter = ',')] repos: Vec, - #[arg(long)] dry_run: bool, - }, - Clean { target: String }, - Find { - pattern: String, - #[arg(long)] repos: Option, - #[arg(long = "type")] find_type: Option, - }, - Generate { target: String }, -} - - -#[derive(Args)] -pub struct StatsArgs { - #[command(subcommand)] - pub action: Option, - #[arg(long)] pub repo: Option, - #[arg(long)] pub workspace: Option, - /// Save output to /.fxg/stats.md - #[arg(long)] pub save: bool, -} - -#[derive(Subcommand)] -pub enum StatsAction { - Time { - #[arg(long)] repo: Option, - #[arg(long)] since: Option, - }, - Lines, Languages, Functions, Security, -} - -#[derive(Args)] -pub struct WorkspaceArgs { - #[command(subcommand)] - pub action: Option, -} - -#[derive(Subcommand)] -pub enum WorkspaceAction { - Create { name: String }, - Add { workspace: String, repos: Vec }, - Remove { name: String }, - List, -} - -#[derive(Args)] -pub struct SummaryArgs { - /// Repository name (defaults to current directory) - pub repo: Option, - #[arg(long)] pub workspace: Option, - /// Print to stdout instead of saving to /.fxg/summary.md - #[arg(long)] pub print: bool, -} - -#[derive(Args)] -pub struct AdoptArgs { - /// Folder name inside workspace_root to adopt (omit for interactive TUI) - pub name: Option, - /// Adopt all non-git folders without prompting - #[arg(long)] pub all: bool, - /// Provider to use (default: from config) - #[arg(long)] pub provider: Option, - /// Visibility: private (default) or public - #[arg(long, default_value = "private")] pub visibility: String, - /// Initial commit message - #[arg(long, short = 'm', default_value = "chore: init")] pub message: String, -} - - -#[derive(Args)] -pub struct ManageArgs { - #[command(subcommand)] - pub action: Option, -} - -#[derive(Subcommand)] -pub enum ManageAction { - /// Delete remote (and optionally local) repository - Delete { - pub_repo: String, - /// Also delete local directory - #[arg(long)] local: bool, - }, - /// Rename repository on remote (and locally if found) - Rename { - pub_repo: String, - new_name: String, - }, - /// Change repository visibility (interactive picker) - Visibility { - /// Repository name (omit to pick from list) - pub_repo: Option, - }, - /// Set repository topics (interactive picker) - Topics { - /// Repository name (omit to pick from list) - pub_repo: Option, - /// Topics to set (comma-separated; omit to enter interactively) - #[arg(long, value_delimiter = ',')] set: Vec, - }, - /// Archive repository (makes it read-only on remote) - Archive { - pub_repo: String, - }, - /// Interactive wizard: assign local category folder + GitHub topics to all repos - Categorize, -} - - -#[derive(Args)] -pub struct UtilArgs { - #[command(subcommand)] - pub action: Option, -} - -#[derive(Subcommand)] -pub enum UtilAction { - /// Show unified chronological commit log across all repos - Log { - /// Max commits to show in total - #[arg(long, default_value = "30")] limit: usize, - }, - /// Scan git history for common secret patterns - FindSecrets { - /// Repo names to scan (comma-separated; omit for all) - #[arg(value_delimiter = ',')] repos: Vec, - }, - /// Create a new document (CHANGELOG, README, TODO) in a repo - NewDoc { - /// Repository name (omit to pick from list) - pub_repo: Option, - /// Document type (omit to pick interactively) - #[arg(long = "type")] doc_type: Option, - }, -} diff --git a/src/cli_cmds/items.rs b/src/cli_cmds/items.rs new file mode 100644 index 0000000..95fb98c --- /dev/null +++ b/src/cli_cmds/items.rs @@ -0,0 +1,149 @@ +//! Todo and project CLI commands — pure `~/.vulp/*.toml` state, no network. + +use super::{die, out_json}; +use crate::cli::{ProjectCmd, TodoCmd}; +use crate::config::AppConfig; +use crate::todos::{self, Todo, TodoStatus}; +use anyhow::Result; + +fn parse_status(s: &str) -> TodoStatus { + match s.to_lowercase().as_str() { + "open" => TodoStatus::Open, + "in-progress" | "inprogress" | "in_progress" => TodoStatus::InProgress, + "done" => TodoStatus::Done, + "cancelled" | "canceled" => TodoStatus::Cancelled, + other => die(2, &format!("unknown status '{other}' (open|in-progress|done|cancelled)")), + } +} + +fn find_todo(all: &[Todo], id: u64) -> usize { + all.iter().position(|t| t.id == id) + .unwrap_or_else(|| die(4, &format!("todo #{id} not found"))) +} + +fn set_status(id: u64, status: TodoStatus) -> Result<()> { + let mut all = todos::load(); + let i = find_todo(&all, id); + all[i].status = status; + all[i].touch(); + todos::save(&all)?; + println!("todo #{id}: {}", all[i].status.label()); + Ok(()) +} + +pub fn todo(json: bool, cmd: TodoCmd) -> Result<()> { + match cmd { + TodoCmd::List { status } => { + let mut all = todos::load(); + if let Some(s) = status { + let want = parse_status(&s); + all.retain(|t| t.status == want); + } + all.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + if json { + // Todo nie deriviuje Serialize (kolory ratatui w impl) — ręczna projekcja. + #[derive(serde::Serialize)] + struct T<'a> { + id: u64, title: &'a str, description: &'a str, + status: &'a str, project: Option<&'a str>, repo: Option<&'a str>, + created_at: i64, updated_at: i64, + } + let view: Vec = all.iter().map(|t| T { + id: t.id, title: &t.title, description: &t.description, + status: t.status.label(), project: t.project.as_deref(), repo: t.repo.as_deref(), + created_at: t.created_at, updated_at: t.updated_at, + }).collect(); + out_json(&view); + } else if all.is_empty() { + println!("no todos"); + } else { + for t in &all { + println!("#{:<4} {:<12} {}", t.id, t.status.label(), t.title); + } + } + Ok(()) + } + TodoCmd::Add { title, desc, project } => { + let mut all = todos::load(); + let id = todos::next_id(&all); + all.push(Todo::new(id, title.clone(), desc, TodoStatus::Open, project)); + todos::save(&all)?; + super::log_activity(crate::activity::ActivityKind::Todo, "", format!("added: {title}")); + println!("todo #{id} added"); + Ok(()) + } + TodoCmd::Done { id } => set_status(id, TodoStatus::Done), + TodoCmd::Start { id } => set_status(id, TodoStatus::InProgress), + TodoCmd::Cancel { id } => set_status(id, TodoStatus::Cancelled), + TodoCmd::Rm { id } => { + let mut all = todos::load(); + let i = find_todo(&all, id); + let t = all.remove(i); + todos::save(&all)?; + println!("todo #{} removed ({})", id, t.title); + Ok(()) + } + } +} + +pub fn project(cfg: &AppConfig, json: bool, cmd: ProjectCmd) -> Result<()> { + match cmd { + ProjectCmd::List => { + let projects = crate::projects::load(); + if json { + out_json(&projects); + } else if projects.is_empty() { + println!("no projects"); + } else { + for p in &projects { + println!("{} ({} repos)", p.name, p.repos.len()); + for r in &p.repos { println!(" · {r}"); } + } + } + Ok(()) + } + ProjectCmd::Create { name, color } => { + let mut projects = crate::projects::load(); + if projects.iter().any(|p| p.name.eq_ignore_ascii_case(&name)) { + die(1, &format!("project '{name}' already exists")); + } + projects.push(crate::projects::Project { name: name.clone(), color, repos: vec![] }); + crate::projects::save(&projects)?; + println!("project '{name}' created"); + Ok(()) + } + ProjectCmd::Assign { name, repos } => { + if repos.is_empty() { die(2, "specify at least one repo"); } + let mut projects = crate::projects::load(); + let Some(proj) = projects.iter_mut().find(|p| p.name.eq_ignore_ascii_case(&name)) else { + die(4, &format!("project '{name}' not found")); + }; + // Zamień nazwy na ścieżki względne od workspace_root — tak trzyma je TUI. + let scanned = super::scan_repos(cfg); + for repo_name in &repos { + let r = scanned.iter().find(|r| { + let needle = repo_name.to_lowercase(); + r.name.to_lowercase() == needle + || r.path.file_name().map(|b| b.to_string_lossy().to_lowercase() == needle).unwrap_or(false) + }).unwrap_or_else(|| die(4, &format!("repo '{repo_name}' not found"))); + let rel = r.path.strip_prefix(&cfg.workspace_root) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| r.path.to_string_lossy().to_string()); + if !proj.repos.contains(&rel) { proj.repos.push(rel); } + } + let count = proj.repos.len(); + crate::projects::save(&projects)?; + println!("project '{name}': {count} repos"); + Ok(()) + } + ProjectCmd::Rm { name } => { + let mut projects = crate::projects::load(); + let before = projects.len(); + projects.retain(|p| !p.name.eq_ignore_ascii_case(&name)); + if projects.len() == before { die(4, &format!("project '{name}' not found")); } + crate::projects::save(&projects)?; + println!("project '{name}' removed"); + Ok(()) + } + } +} diff --git a/src/cli_cmds/mod.rs b/src/cli_cmds/mod.rs new file mode 100644 index 0000000..fcb7754 --- /dev/null +++ b/src/cli_cmds/mod.rs @@ -0,0 +1,108 @@ +//! Implementations of every non-TUI subcommand (`vulp list/sync/todo/...`). +//! Shared contract (see `docs/cli.md`): `--json` prints machine-readable +//! JSON on stdout; human output otherwise; progress/errors go to stderr. +//! Nothing here ever prompts interactively — a missing argument is an error. +//! +//! Exit codes: 0 ok · 1 execution error · 2 bad usage (clap) · +//! 3 auth missing/invalid · 4 not found. + +mod items; +mod remote; +mod workspace; + +use crate::cli::Commands; +use crate::config::AppConfig; +use anyhow::Result; + +/// Dispatches one parsed subcommand. Only called for commands that aren't +/// `Config`/`Auth`/the bare TUI (those keep their existing paths in `app.rs`). +pub async fn run(cmd: Commands, json: bool, cfg: &AppConfig) -> Result<()> { + match cmd { + Commands::List { dirty, behind, ahead, lang, project } => + workspace::list(cfg, json, dirty, behind, ahead, lang, project), + Commands::Status { repo } => workspace::status(cfg, json, &repo), + Commands::Scan => workspace::scan_cmd(cfg, json), + Commands::Sync { repos, all, project, action, message } => + workspace::sync(cfg, repos, all, project, &action, message), + Commands::Release { repo, tag, notes } => + remote::release(cfg, &repo, &tag, ¬es).await, + Commands::Stats { period } => workspace::stats(cfg, json, &period), + Commands::Activity { limit, kind } => workspace::activity(json, limit, kind), + + Commands::Todo { action } => items::todo(json, action), + Commands::Project { action } => items::project(cfg, json, action), + + Commands::Pr { action } => remote::pr(cfg, json, action).await, + Commands::Issue { action } => remote::issue(cfg, json, action).await, + Commands::Repo { action } => remote::repo_cmd(cfg, action).await, + Commands::Init { name, private, description } => + remote::init(cfg, &name, private, &description).await, + Commands::Accounts => remote::accounts(cfg, json), + Commands::Refresh => remote::refresh(cfg, json).await, + + Commands::Config | Commands::Auth(_) => unreachable!("handled in app.rs"), + } +} + +// ── shared helpers ──────────────────────────────────────────────────────────── + +/// Prints `msg` to stderr and exits with `code` (1 error / 3 auth / 4 not found). +pub(crate) fn die(code: i32, msg: &str) -> ! { + eprintln!("error: {msg}"); + std::process::exit(code); +} + +/// Serializes `v` as pretty JSON on stdout. +pub(crate) fn out_json(v: &T) { + match serde_json::to_string_pretty(v) { + Ok(s) => println!("{s}"), + Err(e) => die(1, &format!("JSON serialization failed: {e}")), + } +} + +/// Scans the workspace synchronously (labels already applied by the scan). +pub(crate) fn scan_repos(cfg: &AppConfig) -> Vec { + match crate::status::scan_workspace(&cfg.workspace_root, &[]) { + Ok(r) => r, + Err(e) => die(1, &format!("workspace scan failed: {e:#}")), + } +} + +/// Finds one repo by (case-insensitive) display name or directory basename; +/// exits 4 when nothing matches. +pub(crate) fn find_repo(cfg: &AppConfig, name: &str) -> crate::status::RepoSummary { + let repos = scan_repos(cfg); + let needle = name.to_lowercase(); + let hit = repos.iter().find(|r| { + r.name.to_lowercase() == needle + || r.path.file_name().map(|b| b.to_string_lossy().to_lowercase() == needle).unwrap_or(false) + }); + match hit { + Some(r) => r.clone(), + None => die(4, &format!("repo '{name}' not found in {}", cfg.workspace_root.display())), + } +} + +/// The default (or first) configured account plus its provider client. +/// Exits 3 when no account or token is usable. +pub(crate) fn default_provider(cfg: &AppConfig) -> (crate::auth::Account, Box) { + let accounts = crate::auth::load_accounts(cfg).unwrap_or_default(); + let Some(acc) = accounts.iter().find(|a| a.is_default).or(accounts.first()).cloned() else { + die(3, "no accounts configured — run: vulp auth import"); + }; + match crate::providers::make_provider(&acc) { + Ok(p) => (acc, p), + Err(e) => die(3, &format!("provider auth failed: {e:#}")), + } +} + +/// Logs one vulp-performed action to `~/.vulp/activity.log` (same log the +/// TUI writes, so CLI actions show up in the Overview feed too). +pub(crate) fn log_activity(kind: crate::activity::ActivityKind, repo: &str, detail: String) { + crate::activity::log(&crate::activity::ActivityEntry { + ts: chrono::Utc::now().timestamp(), + kind, + repo: repo.to_string(), + detail, + }); +} diff --git a/src/cli_cmds/remote.rs b/src/cli_cmds/remote.rs new file mode 100644 index 0000000..717c59d --- /dev/null +++ b/src/cli_cmds/remote.rs @@ -0,0 +1,274 @@ +//! Provider-API CLI commands: PRs, issues, releases, repo utilities, +//! `vulp init`, account listing and the blocking `vulp refresh`. + +use super::{die, default_provider, find_repo, log_activity, out_json}; +use crate::activity::ActivityKind; +use crate::cli::{IssueCmd, PrCmd, RepoCmd}; +use crate::config::AppConfig; +use crate::util::git::{git_run, git_run_ok}; +use anyhow::Result; + +pub async fn pr(cfg: &AppConfig, json: bool, cmd: PrCmd) -> Result<()> { + match cmd { + PrCmd::List { repo } => { + let cache = crate::cache::load(); + let mut prs: Vec = + cache.all_prs().into_iter().cloned().collect(); + if let Some(r) = repo { + prs.retain(|p| p.repo.eq_ignore_ascii_case(&r)); + } + prs.sort_by_key(|p| std::cmp::Reverse(p.created_ts)); + if json { + out_json(&prs); + } else if prs.is_empty() { + println!("no cached open PRs (run: vulp refresh)"); + } else { + for p in &prs { + println!("#{:<5} {:<24} {} ({} → {})", p.number, p.repo, p.title, p.head_branch, p.base_branch); + } + } + Ok(()) + } + PrCmd::Create { repo, title, head, base } => { + let (_, provider) = default_provider(cfg); + match provider.create_pull_request(&repo, &title, "", &head, &base).await { + Ok(info) => { + log_activity(ActivityKind::Pr, &repo, format!("#{} {}", info.number, title)); + println!("{}", info.url); + Ok(()) + } + Err(e) => die(1, &format!("PR creation failed: {e:#}")), + } + } + } +} + +pub async fn issue(cfg: &AppConfig, json: bool, cmd: IssueCmd) -> Result<()> { + match cmd { + IssueCmd::List { repo } => { + let cache = crate::cache::load(); + let mut issues: Vec = + cache.all_issues().into_iter().cloned().collect(); + if let Some(r) = repo { + issues.retain(|i| i.repo.eq_ignore_ascii_case(&r)); + } + issues.sort_by_key(|i| std::cmp::Reverse(i.created_ts)); + if json { + out_json(&issues); + } else if issues.is_empty() { + println!("no cached open issues (run: vulp refresh)"); + } else { + for i in &issues { + println!("#{:<5} {:<24} {} ({} comments)", i.number, i.repo, i.title, i.comments); + } + } + Ok(()) + } + IssueCmd::Create { repo, title, body } => { + let (_, provider) = default_provider(cfg); + match provider.create_issue(&repo, &title, &body).await { + Ok(info) => { + log_activity(ActivityKind::Issue, &repo, format!("#{} {}", info.number, title)); + println!("{}", info.url); + Ok(()) + } + Err(e) => die(1, &format!("issue creation failed: {e:#}")), + } + } + } +} + +pub async fn release(cfg: &AppConfig, repo: &str, tag: &str, notes: &str) -> Result<()> { + let r = find_repo(cfg, repo); + let msg = if notes.is_empty() { tag } else { notes }; + git_run(&r.path, &["tag", "-a", tag, "-m", msg]); + if !git_run_ok(&r.path, &["push", "origin", tag]) { + die(1, &format!("pushing tag '{tag}' failed")); + } + // Publikacja release'a na providerze — best effort: tag już wypchnięty, + // brak konta/tokena nie powinien cofać release'a gitowego. + let accounts = crate::auth::load_accounts(cfg).unwrap_or_default(); + if let Some(acc) = accounts.iter().find(|a| a.is_default).or(accounts.first()) { + if let Ok(provider) = crate::providers::make_provider(acc) { + let repo_basename = r.path.file_name().map(|b| b.to_string_lossy().to_string()) + .unwrap_or_else(|| r.name.clone()); + match provider.create_release(&repo_basename, tag, tag, notes, false).await { + Ok(url) => println!("{url}"), + Err(e) => eprintln!("warning: tag pushed, but provider release failed: {e:#}"), + } + } + } + log_activity(ActivityKind::Release, &r.name, tag.to_string()); + println!("released {tag} on {}", r.name); + Ok(()) +} + +pub async fn repo_cmd(cfg: &AppConfig, cmd: RepoCmd) -> Result<()> { + match cmd { + RepoCmd::Url { repo } => { + let r = find_repo(cfg, &repo); + match crate::hub::actions::get_remote_url(&r.path) { + Some(url) => { println!("{url}"); Ok(()) } + None => die(4, "repo has no 'origin' remote"), + } + } + RepoCmd::Path { repo } => { + let r = find_repo(cfg, &repo); + println!("{}", r.path.display()); + Ok(()) + } + RepoCmd::Open { repo } => { + let r = find_repo(cfg, &repo); + match crate::hub::actions::get_remote_url(&r.path) { + Some(url) => { + let web = crate::util::url::to_web_url(&url); + crate::util::open_path(&web); + println!("{web}"); + Ok(()) + } + None => die(4, "repo has no 'origin' remote"), + } + } + RepoCmd::Label { repo, name } => { + let r = find_repo(cfg, &repo); + crate::labels::save_label(&r.path, &name, &cfg.workspace_root)?; + if name.is_empty() { + println!("label cleared for {}", r.path.display()); + } else { + println!("label '{name}' set"); + } + Ok(()) + } + RepoCmd::Bookmark { repo } => { + let r = find_repo(cfg, &repo); + let mut bk = crate::hub::actions::load_bookmarks(); + let added = bk.insert(r.path.clone()); + if !added { bk.remove(&r.path); } + crate::hub::actions::save_bookmarks(&bk); + println!("{} {}", if added { "bookmarked" } else { "unbookmarked" }, r.name); + Ok(()) + } + RepoCmd::Pin { repo } => { + let r = find_repo(cfg, &repo); + let mut pins = crate::hub::actions::load_pins(); + let added = pins.insert(r.path.clone()); + if !added { pins.remove(&r.path); } + crate::hub::actions::save_pins(&pins); + println!("{} {}", if added { "pinned" } else { "unpinned" }, r.name); + Ok(()) + } + RepoCmd::Collab { repo, user, role } => { + if !["pull", "push", "admin"].contains(&role.as_str()) { + die(2, "role must be pull | push | admin"); + } + let r = find_repo(cfg, &repo); + let (_, provider) = default_provider(cfg); + let repo_basename = r.path.file_name().map(|b| b.to_string_lossy().to_string()) + .unwrap_or_else(|| r.name.clone()); + match provider.add_collaborator(&repo_basename, &user, &role).await { + Ok(()) => { println!("invited {user} to {repo_basename} ({role})"); Ok(()) } + Err(e) => die(1, &format!("invite failed: {e:#}")), + } + } + } +} + +pub async fn init(cfg: &AppConfig, name: &str, private: bool, description: &str) -> Result<()> { + let path = cfg.workspace_root.join(name); + if path.exists() { + die(1, &format!("{} already exists", path.display())); + } + let (_, provider) = default_provider(cfg); + + std::fs::create_dir_all(&path)?; + git_run(&path, &["init", "-b", "main"]); + std::fs::write(path.join("README.md"), format!("# {name}\n"))?; + git_run(&path, &["add", "-A"]); + git_run(&path, &["commit", "-m", "Initial commit"]); + + let info = match provider.create_repo(name, private, description, None).await { + Ok(i) => i, + Err(e) => die(1, &format!("provider repo creation failed: {e:#}")), + }; + git_run(&path, &["remote", "add", "origin", &info.ssh_url]); + if !git_run_ok(&path, &["push", "-u", "origin", "main"]) { + die(1, "initial push failed (repo created on provider, local push didn't go through)"); + } + log_activity(ActivityKind::NewRepo, name, format!("Pushed to {}", info.url)); + println!("{}", info.url); + Ok(()) +} + +pub fn accounts(cfg: &AppConfig, json: bool) -> Result<()> { + let accounts = crate::auth::load_accounts(cfg).unwrap_or_default(); + if json { + out_json(&accounts); + } else if accounts.is_empty() { + println!("no accounts (run: vulp auth import)"); + } else { + for a in &accounts { + println!("{}{} @{} ({})", a.id(), if a.is_default { " *" } else { "" }, a.username, a.provider); + } + } + Ok(()) +} + +pub async fn refresh(cfg: &AppConfig, json: bool) -> Result<()> { + use futures::stream::{self, StreamExt}; + + let (account, provider) = default_provider(cfg); + let provider: std::sync::Arc = std::sync::Arc::from(provider); + let _ = account; + let repos = super::scan_repos(cfg); + + // Org-owned repos are reachable through the personal token — refresh + // everything with an owner, addressed by full "owner/basename" slug. + let targets: Vec<(String, String)> = repos.iter() + .filter_map(|r| { + let basename = r.path.file_name().map(|b| b.to_string_lossy().to_string()) + .unwrap_or_else(|| r.name.clone()); + r.owner.as_ref().map(|o| (r.name.clone(), format!("{}/{}", o, basename))) + }) + .collect(); + + let results: Vec<_> = stream::iter(targets.into_iter().map(|(name, slug)| { + let provider = provider.clone(); + async move { + eprintln!("refreshing {slug}..."); + let meta = provider.repo_meta(&slug).await.ok(); + let p = provider.list_pull_requests(&slug).await.ok(); + let i = provider.list_issues(&slug).await.ok(); + (name, meta, p, i) + } + })).buffer_unordered(6).collect().await; + + let mut cache = crate::cache::load(); + let mut fetched_meta = 0usize; + let mut fetched_prs = 0usize; + let mut fetched_iss = 0usize; + for (name, meta, p, i) in results { + if let Some(meta) = meta { + cache.set_meta(&name, crate::cache::RepoMeta { + stars: Some(meta.stars), + open_issues: Some(meta.open_issues), + open_prs: Some(meta.open_prs), + visibility: None, + ci_ok: None, + fetched_at: chrono::Utc::now().timestamp(), + }); + fetched_meta += 1; + } + if let Some(p) = p { fetched_prs += p.len(); cache.set_prs(&name, p); } + if let Some(i) = i { fetched_iss += i.len(); cache.set_issues(&name, i); } + } + let _ = crate::cache::save(&cache); + + if json { + #[derive(serde::Serialize)] + struct Out { repos_refreshed: usize, open_prs: usize, open_issues: usize } + out_json(&Out { repos_refreshed: fetched_meta, open_prs: fetched_prs, open_issues: fetched_iss }); + } else { + println!("refreshed {fetched_meta} repos: {fetched_prs} PRs, {fetched_iss} issues"); + } + Ok(()) +} diff --git a/src/cli_cmds/workspace.rs b/src/cli_cmds/workspace.rs new file mode 100644 index 0000000..71ba411 --- /dev/null +++ b/src/cli_cmds/workspace.rs @@ -0,0 +1,223 @@ +//! Local-workspace CLI commands: list/status/scan, sync, stats, activity — +//! everything that works off the on-disk repos without a provider API. + +use super::{die, find_repo, log_activity, out_json, scan_repos}; +use crate::config::AppConfig; +use anyhow::Result; + +#[allow(clippy::too_many_arguments)] +pub fn list( + cfg: &AppConfig, json: bool, + dirty: bool, behind: bool, ahead: bool, + lang: Option, project: Option, +) -> Result<()> { + let mut repos = scan_repos(cfg); + + if dirty { repos.retain(|r| r.is_dirty); } + if behind { repos.retain(|r| r.behind > 0); } + if ahead { repos.retain(|r| r.ahead > 0); } + if let Some(l) = lang { + let l = l.to_lowercase(); + repos.retain(|r| r.lang.as_deref().map(|x| x.to_lowercase().contains(&l)).unwrap_or(false)); + } + if let Some(p) = &project { + let projects = crate::projects::load(); + let Some(proj) = projects.iter().find(|x| x.name.eq_ignore_ascii_case(p)) else { + die(4, &format!("project '{p}' not found")); + }; + let members: Vec = + proj.repos.iter().map(|rel| cfg.workspace_root.join(rel)).collect(); + repos.retain(|r| members.contains(&r.path)); + } + + if json { + out_json(&repos); + return Ok(()); + } + if repos.is_empty() { + println!("no repos match"); + return Ok(()); + } + let name_w = repos.iter().map(|r| r.name.chars().count()).max().unwrap_or(4).max(4); + for r in &repos { + let mut flags = String::new(); + if r.is_dirty { flags.push('⚠'); } + if r.behind > 0 { flags.push('↓'); } + if r.ahead > 0 { flags.push('↑'); } + println!( + "{: Result<()> { + let r = find_repo(cfg, repo); + if json { + out_json(&r); + return Ok(()); + } + println!("name: {}", r.name); + println!("path: {}", r.path.display()); + println!("branch: {}", r.branch); + println!("dirty: {}", r.is_dirty); + println!("ahead: {}", r.ahead); + println!("behind: {}", r.behind); + println!("owner: {}", r.owner.as_deref().unwrap_or("-")); + println!("language: {}", r.lang.as_deref().unwrap_or("-")); + println!("license: {}", if r.has_license { "yes" } else { "no" }); + println!("last commit: {} ({})", r.last_commit_msg, r.last_commit_age); + if let Some(tag) = &r.last_tag { println!("last tag: {tag}"); } + Ok(()) +} + +pub fn scan_cmd(cfg: &AppConfig, json: bool) -> Result<()> { + let repos = scan_repos(cfg); + let dirty = repos.iter().filter(|r| r.is_dirty).count(); + let behind = repos.iter().filter(|r| r.behind > 0).count(); + let ahead = repos.iter().filter(|r| r.ahead > 0).count(); + if json { + #[derive(serde::Serialize)] + struct Summary { total: usize, dirty: usize, behind: usize, ahead: usize } + out_json(&Summary { total: repos.len(), dirty, behind, ahead }); + } else { + println!("{} repos · {} dirty · {} behind · {} ahead", repos.len(), dirty, behind, ahead); + } + Ok(()) +} + +pub fn sync( + cfg: &AppConfig, + repos: Vec, all: bool, project: Option, + action: &str, message: Option, +) -> Result<()> { + use crate::activity::ActivityKind; + use crate::util::git::{git_run, git_run_ok}; + + let scanned = scan_repos(cfg); + let targets: Vec<&crate::status::RepoSummary> = if all { + scanned.iter().collect() + } else if let Some(p) = &project { + let projects = crate::projects::load(); + let Some(proj) = projects.iter().find(|x| x.name.eq_ignore_ascii_case(p)) else { + die(4, &format!("project '{p}' not found")); + }; + let members: Vec = + proj.repos.iter().map(|rel| cfg.workspace_root.join(rel)).collect(); + scanned.iter().filter(|r| members.contains(&r.path)).collect() + } else if !repos.is_empty() { + repos.iter().map(|name| { + let needle = name.to_lowercase(); + scanned.iter() + .find(|r| r.name.to_lowercase() == needle + || r.path.file_name().map(|b| b.to_string_lossy().to_lowercase() == needle).unwrap_or(false)) + .unwrap_or_else(|| die(4, &format!("repo '{name}' not found"))) + }).collect() + } else { + die(2, "specify repo names, --all, or --project"); + }; + + let action = action.replace('-', "_"); // commit-push → commit_push + if action == "commit_push" && message.is_none() { + die(2, "--message is required for commit-push"); + } + let msg = message.unwrap_or_default(); + + let mut failed = 0usize; + for r in &targets { + eprintln!("── {} [{}]", r.name, action); + let ok = match action.as_str() { + "pull" => { + let ok = git_run_ok(&r.path, &["pull"]); + if ok { log_activity(ActivityKind::Pull, &r.name, "vulp sync (pull)".into()); } + ok + } + "commit_push" => { + git_run(&r.path, &["add", "-A"]); + git_run(&r.path, &["commit", "-m", &msg]); + let ok = git_run_ok(&r.path, &["push"]); + if ok { log_activity(ActivityKind::Push, &r.name, format!("vulp sync (commit+push: {msg})")); } + ok + } + "push" => { + let ok = git_run_ok(&r.path, &["push"]); + if ok { log_activity(ActivityKind::Push, &r.name, "vulp sync (push)".into()); } + ok + } + other => die(2, &format!("unknown action '{other}' (pull | push | commit-push)")), + }; + if !ok { eprintln!(" FAILED"); failed += 1; } + } + println!("synced {}/{} repos", targets.len() - failed, targets.len()); + if failed > 0 { std::process::exit(1); } + Ok(()) +} + +pub fn stats(cfg: &AppConfig, json: bool, period: &str) -> Result<()> { + use crate::detail::StatsPeriod; + let p = match period { + "1d" => StatsPeriod::Day1, + "7d" => StatsPeriod::Day7, + "30d" => StatsPeriod::Day30, + "90d" => StatsPeriod::Day90, + "1y" => StatsPeriod::Year1, + "all" => StatsPeriod::AllTime, + other => die(2, &format!("unknown period '{other}' (1d|7d|30d|90d|1y|all)")), + }; + let repos = scan_repos(cfg); + let mut per_repo: Vec<(String, usize)> = Vec::new(); + let mut total = 0usize; + for r in &repos { + let activity = match p { + StatsPeriod::Day1 => crate::detail::activity_1d(&r.path), + StatsPeriod::Day7 => crate::detail::activity_7d(&r.path), + StatsPeriod::Day30 => crate::detail::activity_30d(&r.path), + StatsPeriod::Day90 => crate::detail::activity_90d(&r.path), + StatsPeriod::Year1 => crate::detail::activity_1y(&r.path), + StatsPeriod::AllTime => crate::detail::activity_all(&r.path), + }; + let n: usize = activity.iter().map(|(_, c)| c).sum(); + total += n; + if n > 0 { per_repo.push((r.name.clone(), n)); } + } + per_repo.sort_by(|a, b| b.1.cmp(&a.1)); + + if json { + #[derive(serde::Serialize)] + struct Stats<'a> { + period: &'a str, + total_commits: usize, + repos_active: usize, + per_repo: &'a [(String, usize)], + } + out_json(&Stats { period, total_commits: total, repos_active: per_repo.len(), per_repo: &per_repo }); + } else { + println!("period: {period} commits: {total} active repos: {}", per_repo.len()); + for (name, n) in per_repo.iter().take(15) { + println!(" {:>5} {}", n, name); + } + } + Ok(()) +} + +pub fn activity(json: bool, limit: usize, kind: Option) -> Result<()> { + let mut entries = crate::activity::load_logged(); + entries.sort_by(|a, b| b.ts.cmp(&a.ts)); + if let Some(k) = &kind { + entries.retain(|e| e.kind.label().eq_ignore_ascii_case(k)); + } + entries.truncate(limit); + + if json { + out_json(&entries); + } else if entries.is_empty() { + println!("no matching activity"); + } else { + for e in &entries { + println!("{} {:<8} {:<24} {}", crate::status::format_age_pub(e.ts), e.kind.label(), e.repo, e.detail); + } + } + Ok(()) +} diff --git a/src/codeintel/lang.rs b/src/codeintel/lang.rs new file mode 100644 index 0000000..6ee45b7 --- /dev/null +++ b/src/codeintel/lang.rs @@ -0,0 +1,96 @@ +//! Per-language tree-sitter wiring: which grammar handles which extension, +//! and the query used to find function/method definitions in each one. +//! Adding a language means adding one match arm here — `mod.rs` never needs +//! to know about grammar internals. + +/// A source language with tree-sitter support wired up. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lang { + Rust, + Python, + JavaScript, + TypeScript, + Tsx, + Go, +} + +impl Lang { + /// Display name shown in repo-stats language breakdowns. + pub fn label(&self) -> &'static str { + match self { + Self::Rust => "Rust", + Self::Python => "Python", + Self::JavaScript => "JavaScript", + Self::TypeScript => "TypeScript", + Self::Tsx => "TSX", + Self::Go => "Go", + } + } +} + +/// Maps a lowercased file extension to its `Lang`, if supported. +pub fn lang_for_ext(ext: &str) -> Option { + match ext { + "rs" => Some(Lang::Rust), + "py" | "pyw" => Some(Lang::Python), + "js" | "mjs" | "cjs" | "jsx" => Some(Lang::JavaScript), + "ts" => Some(Lang::TypeScript), + "tsx" => Some(Lang::Tsx), + "go" => Some(Lang::Go), + _ => None, + } +} + +/// The tree-sitter grammar for `lang`. +pub fn ts_language(lang: Lang) -> tree_sitter::Language { + match lang { + Lang::Rust => tree_sitter_rust::LANGUAGE.into(), + Lang::Python => tree_sitter_python::LANGUAGE.into(), + Lang::JavaScript => tree_sitter_javascript::LANGUAGE.into(), + Lang::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + Lang::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(), + Lang::Go => tree_sitter_go::LANGUAGE.into(), + } +} + +/// Query that captures every function/method definition: `@function` for the +/// whole node (used for line span + body text), `@name` for its identifier, +/// `@params` for its parameter list. +pub fn function_query(lang: Lang) -> &'static str { + match lang { + Lang::Rust => { + "(function_item name: (identifier) @name parameters: (parameters) @params) @function" + } + Lang::Python => { + "(function_definition name: (identifier) @name parameters: (parameters) @params) @function" + } + Lang::JavaScript => { + "[ + (function_declaration name: (identifier) @name parameters: (formal_parameters) @params) @function + (method_definition name: (property_identifier) @name parameters: (formal_parameters) @params) @function + (variable_declarator name: (identifier) @name value: (arrow_function parameters: (formal_parameters) @params)) @function + ]" + } + Lang::TypeScript | Lang::Tsx => { + "[ + (function_declaration name: (identifier) @name parameters: (formal_parameters) @params) @function + (method_definition name: (property_identifier) @name parameters: (formal_parameters) @params) @function + (variable_declarator name: (identifier) @name value: (arrow_function parameters: (formal_parameters) @params)) @function + ]" + } + Lang::Go => { + "[ + (function_declaration name: (identifier) @name parameters: (parameter_list) @params) @function + (method_declaration name: (field_identifier) @name parameters: (parameter_list) @params) @function + ]" + } + } +} + +/// Query that captures every plain identifier reference/declaration node — +/// deliberately excludes member/property-name node kinds (e.g. JS's +/// `property_identifier` used for `obj.foo`), so a rename of a function +/// named `foo` won't also touch an unrelated object property called `foo`. +pub fn identifier_query(_lang: Lang) -> &'static str { + "(identifier) @id" +} diff --git a/src/codeintel/mod.rs b/src/codeintel/mod.rs new file mode 100644 index 0000000..ffd6a42 --- /dev/null +++ b/src/codeintel/mod.rs @@ -0,0 +1,415 @@ +//! Tree-sitter-based code intelligence: list functions, find usages, rename +//! an identifier, convert naming convention, and aggregate repo stats. +//! +//! Tree-sitter (not regex/naive find-replace) is the explicit choice here: +//! it parses real syntax trees, so a rename only ever touches genuine +//! identifier nodes — never matches inside strings/comments, and never +//! confuses a function name with an unrelated object property of the same +//! name (see `lang::identifier_query`). Every walk also skips +//! `crate::util::EXCLUDED_DIRS` (`.git`, `target`, `node_modules`, `vendor`, +//! ...), so rename/find-usages never reach into dependency code. + +pub mod lang; + +use lang::{function_query, identifier_query, lang_for_ext, ts_language, Lang}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use tree_sitter::{Parser, Query, QueryCursor, StreamingIterator}; + +/// Verbosity level for `list_functions`' display lines. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FuncFormat { + NamesOnly, + WithArgs, + WithMeta, +} + +/// One function/method definition found in a repo. +#[derive(Debug, Clone)] +pub struct FunctionInfo { + pub name: String, + pub args: Vec, + pub file: String, + pub line: usize, + pub length_lines: usize, + pub ref_count: usize, +} + +/// One place an identifier is referenced, found by `find_usages`. +#[derive(Debug, Clone)] +pub struct UsageLocation { + pub file: String, + pub line: usize, + pub col: usize, + pub context: String, +} + +/// One identifier-node rewrite: the byte range to replace in `file` and +/// what to replace it with. `start_byte`/`end_byte` are what `apply_rename_plan` +/// actually splices on; `line`/`col` are only for the human-readable preview. +#[derive(Debug, Clone)] +pub struct RenameEdit { + pub file: PathBuf, + pub line: usize, + pub col: usize, + pub start_byte: usize, + pub end_byte: usize, + pub old_text: String, + pub new_text: String, +} + +/// A dry-run set of rename edits, shown to the user as a preview before +/// `apply_rename_plan` writes anything to disk. +#[derive(Debug, Clone, Default)] +pub struct RenamePlan { + pub edits: Vec, +} + +impl RenamePlan { + /// Number of distinct files this plan touches. + pub fn file_count(&self) -> usize { + let mut files: Vec<&PathBuf> = self.edits.iter().map(|e| &e.file).collect(); + files.sort(); + files.dedup(); + files.len() + } +} + +/// A function-name casing convention, used as the target for +/// `build_convention_plan`'s bulk rename. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NamingConvention { + Snake, + Camel, + Pascal, +} + +impl NamingConvention { + /// Display label shown in the convention picker. + pub fn label(&self) -> &'static str { + match self { + Self::Snake => "snake_case", + Self::Camel => "camelCase", + Self::Pascal => "PascalCase", + } + } + + /// Guesses a name's current convention from its shape (contains `_` → + /// snake_case; starts uppercase → PascalCase; starts lowercase → + /// camelCase). Returns `None` for ambiguous/empty names, so + /// `build_convention_plan` can skip them rather than mis-convert. + fn detect(name: &str) -> Option { + if name.is_empty() { return None; } + if name.contains('_') { return Some(Self::Snake); } + match name.chars().next() { + Some(c) if c.is_uppercase() => Some(Self::Pascal), + Some(c) if c.is_lowercase() => Some(Self::Camel), + _ => None, + } + } + + /// Splits `name` into words (via `split_words`) and rejoins them in this + /// convention. + fn convert(&self, name: &str) -> String { + let words = split_words(name); + match self { + Self::Snake => words.join("_"), + Self::Camel => join_camel_pascal(&words, false), + Self::Pascal => join_camel_pascal(&words, true), + } + } +} + +fn split_words(name: &str) -> Vec { + if name.contains('_') { + return name.split('_').filter(|s| !s.is_empty()).map(|s| s.to_lowercase()).collect(); + } + let mut words = vec![]; + let mut cur = String::new(); + for ch in name.chars() { + if ch.is_uppercase() && !cur.is_empty() { + words.push(std::mem::take(&mut cur).to_lowercase()); + } + cur.push(ch); + } + if !cur.is_empty() { words.push(cur.to_lowercase()); } + words +} + +fn join_camel_pascal(words: &[String], pascal: bool) -> String { + let mut out = String::new(); + for (i, w) in words.iter().enumerate() { + if w.is_empty() { continue; } + let mut chars = w.chars(); + let first = chars.next().unwrap(); + if i == 0 && !pascal { + out.push(first.to_ascii_lowercase()); + } else { + out.push(first.to_ascii_uppercase()); + } + out.push_str(chars.as_str()); + } + out +} + +/// Reads and parses `path` with the tree-sitter grammar matching its +/// extension. Returns `None` for unsupported extensions or unreadable/ +/// unparseable files, rather than an error — every caller is a best-effort +/// repo-wide walk where one bad file shouldn't abort the whole scan. +fn parse_file(path: &Path) -> Option<(Lang, String, tree_sitter::Tree)> { + let ext = path.extension()?.to_str()?.to_lowercase(); + let lang = lang_for_ext(&ext)?; + let source = std::fs::read_to_string(path).ok()?; + let mut parser = Parser::new(); + parser.set_language(&ts_language(lang)).ok()?; + let tree = parser.parse(&source, None)?; + Some((lang, source, tree)) +} + +fn rel_path(repo_path: &Path, path: &Path) -> String { + path.strip_prefix(repo_path).unwrap_or(path).to_string_lossy().to_string() +} + +/// Every function/method definition found in `repo_path`. `ref_count` is the +/// number of identifier-reference nodes across the whole repo matching that +/// function's name (a coarse cross-reference count — not full call-graph +/// resolution, but enough to flag "this is referenced nowhere" or "this is +/// referenced everywhere"). +pub fn list_functions(repo_path: &Path, _format: FuncFormat) -> Vec { + let mut out: Vec = Vec::new(); + crate::util::walk_files(repo_path, &mut |path| { + let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return }; + if lang_for_ext(&ext.to_lowercase()).is_none() { return; } + let Some((lang, source, tree)) = parse_file(path) else { return }; + let query_src = function_query(lang); + let Ok(query) = Query::new(&ts_language(lang), query_src) else { return }; + let name_idx = query.capture_index_for_name("name"); + let params_idx = query.capture_index_for_name("params"); + let func_idx = query.capture_index_for_name("function"); + let mut cursor = QueryCursor::new(); + let src_bytes = source.as_bytes(); + let mut matches = cursor.matches(&query, tree.root_node(), src_bytes); + let rel = rel_path(repo_path, path); + while let Some(m) = matches.next() { + let mut name = None; + let mut params_node = None; + let mut func_node = None; + for cap in m.captures { + if Some(cap.index) == name_idx { name = cap.node.utf8_text(src_bytes).ok().map(str::to_string); } + if Some(cap.index) == params_idx { params_node = Some(cap.node); } + if Some(cap.index) == func_idx { func_node = Some(cap.node); } + } + let (Some(name), Some(fnode)) = (name, func_node) else { continue }; + let args = params_node.map(|p| { + let mut tc = p.walk(); + p.named_children(&mut tc) + .filter_map(|c| c.utf8_text(src_bytes).ok().map(|t| t.trim().to_string())) + .collect::>() + }).unwrap_or_default(); + let start_line = fnode.start_position().row + 1; + let end_line = fnode.end_position().row + 1; + out.push(FunctionInfo { + name, args, + file: rel.clone(), + line: start_line, + length_lines: end_line.saturating_sub(start_line) + 1, + ref_count: 0, + }); + } + }); + + let mut counts: HashMap = HashMap::new(); + crate::util::walk_files(repo_path, &mut |path| { + let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return }; + let Some(lang) = lang_for_ext(&ext.to_lowercase()) else { return }; + let Some((_, source, tree)) = parse_file(path) else { return }; + let Ok(query) = Query::new(&ts_language(lang), identifier_query(lang)) else { return }; + let id_idx = query.capture_index_for_name("id"); + let mut cursor = QueryCursor::new(); + let src_bytes = source.as_bytes(); + let mut matches = cursor.matches(&query, tree.root_node(), src_bytes); + while let Some(m) = matches.next() { + for cap in m.captures { + if Some(cap.index) != id_idx { continue; } + if let Ok(text) = cap.node.utf8_text(src_bytes) { + *counts.entry(text.to_string()).or_insert(0) += 1; + } + } + } + }); + for f in &mut out { + f.ref_count = counts.get(&f.name).copied().unwrap_or(0); + } + + out.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line))); + out +} + +/// Every place `identifier` appears as a plain identifier reference or +/// declaration (not a string, comment, or unrelated member-property name). +pub fn find_usages(repo_path: &Path, identifier: &str) -> Vec { + let mut out = Vec::new(); + crate::util::walk_files(repo_path, &mut |path| { + let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return }; + let Some(lang) = lang_for_ext(&ext.to_lowercase()) else { return }; + let Some((_, source, tree)) = parse_file(path) else { return }; + let Ok(query) = Query::new(&ts_language(lang), identifier_query(lang)) else { return }; + let id_idx = query.capture_index_for_name("id"); + let mut cursor = QueryCursor::new(); + let src_bytes = source.as_bytes(); + let mut matches = cursor.matches(&query, tree.root_node(), src_bytes); + let rel = rel_path(repo_path, path); + let lines: Vec<&str> = source.lines().collect(); + while let Some(m) = matches.next() { + for cap in m.captures { + if Some(cap.index) != id_idx { continue; } + let Ok(text) = cap.node.utf8_text(src_bytes) else { continue }; + if text != identifier { continue; } + let pos = cap.node.start_position(); + let context: String = lines.get(pos.row).map(|s| s.trim()).unwrap_or("").chars().take(100).collect(); + out.push(UsageLocation { file: rel.clone(), line: pos.row + 1, col: pos.column + 1, context }); + } + } + }); + out.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line))); + out +} + +/// Single pass over the repo collecting a `RenameEdit` for every identifier +/// node whose text is a key of `renames`. Shared by `build_rename_plan` +/// (one old→new pair) and `build_convention_plan` (many pairs at once) so +/// converting an entire repo's naming convention costs one walk, not one +/// walk per renamed identifier. +fn build_rename_edits(repo_path: &Path, renames: &HashMap) -> Vec { + let mut edits = Vec::new(); + crate::util::walk_files(repo_path, &mut |path| { + let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return }; + let Some(lang) = lang_for_ext(&ext.to_lowercase()) else { return }; + let Some((_, source, tree)) = parse_file(path) else { return }; + let Ok(query) = Query::new(&ts_language(lang), identifier_query(lang)) else { return }; + let id_idx = query.capture_index_for_name("id"); + let mut cursor = QueryCursor::new(); + let src_bytes = source.as_bytes(); + let mut matches = cursor.matches(&query, tree.root_node(), src_bytes); + while let Some(m) = matches.next() { + for cap in m.captures { + if Some(cap.index) != id_idx { continue; } + let Ok(text) = cap.node.utf8_text(src_bytes) else { continue }; + let Some(new_name) = renames.get(text) else { continue }; + let pos = cap.node.start_position(); + edits.push(RenameEdit { + file: path.to_path_buf(), + line: pos.row + 1, + col: pos.column + 1, + start_byte: cap.node.start_byte(), + end_byte: cap.node.end_byte(), + old_text: text.to_string(), + new_text: new_name.clone(), + }); + } + } + }); + edits.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line))); + edits +} + +/// Builds a dry-run plan for renaming `old` to `new`. Does not touch disk — +/// call `apply_rename_plan` after the caller has shown the preview and +/// gotten an explicit confirmation. +pub fn build_rename_plan(repo_path: &Path, old: &str, new: &str) -> RenamePlan { + let mut renames = HashMap::new(); + renames.insert(old.to_string(), new.to_string()); + RenamePlan { edits: build_rename_edits(repo_path, &renames) } +} + +/// Builds a dry-run plan converting every function name in `repo_path` that +/// doesn't already match `target` to that convention. Scoped to function +/// definitions found by `list_functions` (not every variable/binding in the +/// repo) — a deliberately bounded, predictable scope for a bulk rename. +pub fn build_convention_plan(repo_path: &Path, target: NamingConvention) -> RenamePlan { + let funcs = list_functions(repo_path, FuncFormat::NamesOnly); + let mut renames: HashMap = HashMap::new(); + for f in &funcs { + if renames.contains_key(&f.name) { continue; } + if NamingConvention::detect(&f.name) == Some(target) { continue; } + let new_name = target.convert(&f.name); + if new_name != f.name && !new_name.is_empty() { + renames.insert(f.name.clone(), new_name); + } + } + RenamePlan { edits: build_rename_edits(repo_path, &renames) } +} + +/// Applies a previously-built plan: groups edits by file, applies each +/// file's edits back-to-front by byte offset (so earlier offsets in the same +/// file stay valid even when old/new names differ in length), and writes +/// each file once. Returns the number of edits actually applied. +pub fn apply_rename_plan(plan: &RenamePlan) -> std::io::Result { + use std::collections::BTreeMap; + let mut by_file: BTreeMap<&PathBuf, Vec<&RenameEdit>> = BTreeMap::new(); + for e in &plan.edits { + by_file.entry(&e.file).or_default().push(e); + } + let mut applied = 0usize; + for (file, mut edits) in by_file { + edits.sort_by(|a, b| b.start_byte.cmp(&a.start_byte)); + let mut bytes = std::fs::read(file)?; + for e in edits { + if e.start_byte > e.end_byte || e.end_byte > bytes.len() { continue; } + bytes.splice(e.start_byte..e.end_byte, e.new_text.bytes()); + applied += 1; + } + std::fs::write(file, bytes)?; + } + Ok(applied) +} + +/// Aggregate stats over a repo's source files, returned by `repo_stats`. +#[derive(Debug, Clone, Default)] +pub struct RepoCodeStats { + pub total_functions: usize, + pub total_files: usize, + pub lang_counts: Vec<(String, usize)>, + pub avg_fn_len: f64, + pub longest_functions: Vec<(String, String, usize)>, // file, name, length_lines + pub largest_files: Vec<(String, usize)>, // file, loc +} + +/// Read-only aggregate stats over every tree-sitter-supported source file in +/// `repo_path`. Unlike rename/find-usages, this is safe to run repo-wide +/// without any confirmation step — nothing is mutated. +pub fn repo_stats(repo_path: &Path) -> RepoCodeStats { + let funcs = list_functions(repo_path, FuncFormat::NamesOnly); + + let mut lang_counts: HashMap = HashMap::new(); + let mut file_loc: HashMap = HashMap::new(); + let mut total_files = 0usize; + crate::util::walk_files(repo_path, &mut |path| { + let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return }; + let Some(lang) = lang_for_ext(&ext.to_lowercase()) else { return }; + total_files += 1; + *lang_counts.entry(lang.label().to_string()).or_insert(0) += 1; + if let Ok(content) = std::fs::read_to_string(path) { + file_loc.insert(rel_path(repo_path, path), content.lines().count()); + } + }); + + let total_functions = funcs.len(); + let avg_fn_len = if total_functions > 0 { + funcs.iter().map(|f| f.length_lines).sum::() as f64 / total_functions as f64 + } else { 0.0 }; + + let mut longest_functions: Vec<(String, String, usize)> = funcs.iter() + .map(|f| (f.file.clone(), f.name.clone(), f.length_lines)).collect(); + longest_functions.sort_by(|a, b| b.2.cmp(&a.2)); + longest_functions.truncate(10); + + let mut largest_files: Vec<(String, usize)> = file_loc.into_iter().collect(); + largest_files.sort_by(|a, b| b.1.cmp(&a.1)); + largest_files.truncate(10); + + let mut lang_counts: Vec<(String, usize)> = lang_counts.into_iter().collect(); + lang_counts.sort_by(|a, b| b.1.cmp(&a.1)); + + RepoCodeStats { total_functions, total_files, lang_counts, avg_fn_len, longest_functions, largest_files } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 90ef056..15642b1 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,9 +1,16 @@ +//! Persistent application settings (`~/.vulp/config.toml`): workspace +//! location, defaults for new repos, editor/clipboard preferences, and the +//! one-time migration from the legacy `~/.fxg` data directory. + pub mod ui; use anyhow::Result; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +/// All persisted user settings. Loaded once at startup and threaded through +/// the rest of the app; the hub's HubSettings tab edits a copy and calls +/// `save_config` to persist changes. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { pub workspace_root: PathBuf, @@ -32,6 +39,9 @@ pub struct AppConfig { pub show_locs: bool, } +/// Picks the first available clipboard tool on PATH as the default +/// `clipboard_cmd`, so a fresh install works without manual configuration +/// on most systems. fn default_clipboard_cmd() -> String { for cmd in &["wl-copy", "xclip", "pbcopy"] { if std::process::Command::new("which").arg(cmd).output().map(|o| o.status.success()).unwrap_or(false) { @@ -46,7 +56,7 @@ impl Default for AppConfig { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")); Self { workspace_root: home.join("projects"), - data_dir: home.join(".fxg"), + data_dir: home.join(".vulp"), default_provider: "github".into(), default_branch: "main".into(), default_visibility: "private".into(), @@ -57,7 +67,7 @@ impl Default for AppConfig { readme_template: "heartbyte-default".into(), share_stats: false, timezone: "Europe/Warsaw".into(), - vsc_data_dir: home.join(".fxg/vsc-data"), + vsc_data_dir: home.join(".vulp/vsc-data"), workspace_groups: vec![], clipboard_cmd: default_clipboard_cmd(), show_locs: false, @@ -66,6 +76,8 @@ impl Default for AppConfig { } impl AppConfig { + /// Lists every editable setting as a `(key, display value)` pair, in + /// the order shown by the Config TUI's field list. pub fn fields(&self) -> Vec<(&'static str, String)> { vec![ ("workspace_root", self.workspace_root.to_string_lossy().into()), @@ -85,6 +97,9 @@ impl AppConfig { ] } + /// Sets one field by its `fields()` key name from a freeform string + /// (as typed into the Config TUI's edit prompt). Errors on an unknown + /// key rather than silently ignoring a typo. pub fn set_field(&mut self, key: &str, value: String) -> Result<()> { match key { "workspace_root" => self.workspace_root = PathBuf::from(&value), @@ -113,13 +128,72 @@ impl AppConfig { } } +/// Path to the config file, `~/.vulp/config.toml`. pub fn config_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from("~")) - .join(".fxg") + .join(".vulp") .join("config.toml") } +/// The pre-rename data directory, kept only so `migrate_legacy_config` can +/// detect and import it once. +fn legacy_data_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("~")) + .join(".fxg") +} + +/// One-time migration from the old `fxg` data directory: if `~/.vulp` doesn't +/// exist yet but `~/.fxg` does, copy it over wholesale (config, accounts, +/// tokens, projects, workspaces, cache) so upgrading users keep their setup. +/// The old directory is left untouched as a safety fallback. +pub fn migrate_legacy_config() -> Result> { + let new_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")).join(".vulp"); + let old_dir = legacy_data_dir(); + if new_dir.exists() || !old_dir.exists() { + return Ok(None); + } + copy_dir_recursive(&old_dir, &new_dir)?; + + // The copied config.toml still has data_dir/vsc_data_dir pointing at the + // old ~/.fxg paths — repoint them at the new location so accounts.toml + // and friends (which read cfg.data_dir) don't silently keep writing there. + let cfg_path = new_dir.join("config.toml"); + if cfg_path.exists() { + if let Ok(raw) = std::fs::read_to_string(&cfg_path) { + if let Ok(mut cfg) = toml::from_str::(&raw) { + if cfg.data_dir == old_dir { + cfg.data_dir = new_dir.clone(); + } + if cfg.vsc_data_dir == old_dir.join("vsc-data") { + cfg.vsc_data_dir = new_dir.join("vsc-data"); + } + let _ = std::fs::write(&cfg_path, toml::to_string_pretty(&cfg)?); + } + } + } + Ok(Some(old_dir)) +} + +/// Recursively copies every file and subdirectory from `src` into `dst`. +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + let target = dst.join(entry.file_name()); + if ty.is_dir() { + copy_dir_recursive(&entry.path(), &target)?; + } else { + std::fs::copy(entry.path(), &target)?; + } + } + Ok(()) +} + +/// Loads the config file, or `AppConfig::default()` if it doesn't exist yet +/// (first run). pub fn load_config() -> Result { let path = config_path(); if path.exists() { @@ -130,6 +204,7 @@ pub fn load_config() -> Result { } } +/// Writes `cfg` to the config file, creating `~/.vulp/` if needed. pub fn save_config(cfg: &AppConfig) -> Result<()> { let path = config_path(); ensure_parent(&path)?; @@ -137,6 +212,7 @@ pub fn save_config(cfg: &AppConfig) -> Result<()> { Ok(()) } +/// Creates `cfg.data_dir` and `cfg.vsc_data_dir` if they don't already exist. #[allow(dead_code)] pub fn ensure_data_dir(cfg: &AppConfig) -> Result<()> { std::fs::create_dir_all(&cfg.data_dir)?; @@ -151,6 +227,8 @@ fn ensure_parent(p: &Path) -> Result<()> { Ok(()) } +/// Entry point for `vulp config`: opens the interactive settings TUI and +/// persists whatever the user changed. pub async fn run(cfg: AppConfig) -> Result<()> { let updated = ui::run_config_tui(cfg)?; save_config(&updated)?; diff --git a/src/config/ui.rs b/src/config/ui.rs index cdc2c28..7c565b0 100644 --- a/src/config/ui.rs +++ b/src/config/ui.rs @@ -1,3 +1,7 @@ +//! Standalone TUI for `vulp config`: a flat editable field list, switching +//! out to `inquire::Select` (outside ratatui's raw mode) for fields that +//! must be chosen from a fixed list rather than typed freely. + use crate::config::AppConfig; use crate::tui; use anyhow::Result; @@ -19,12 +23,16 @@ const ENUM_FIELDS: &[(&str, &[&str])] = &[ ("gitignore_template", &["node", "rust", "python", "blank"]), ]; +/// Looks up the fixed option list for `key`, if it's an enum field. fn enum_options(key: &str) -> Option<&'static [&'static str]> { ENUM_FIELDS.iter().find(|(k, _)| *k == key).map(|(_, opts)| *opts) } // ── TUI state ───────────────────────────────────────────────────────────────── +/// Signal from the ratatui inner loop to the outer loop in `run_config_tui`: +/// either stop entirely, or briefly leave raw mode to show an `inquire` +/// picker for one enum field. enum Action { Quit, OpenPicker { field_idx: usize, options: Vec<&'static str> }, @@ -49,6 +57,9 @@ impl State { } } + /// Applies every edited field back onto `base`, returning the updated + /// config. Starts from the original `AppConfig` (rather than building + /// one from scratch) so any field not exposed in `fields()` is preserved. fn to_config(&self, base: AppConfig) -> AppConfig { let mut cfg = base; let labels: Vec<&str> = self.fields.iter().map(|(k, _)| *k).collect(); @@ -61,10 +72,12 @@ impl State { // ── render ──────────────────────────────────────────────────────────────────── +/// Draws the field list, with enum fields tagged `[pick]` and the +/// currently-edited field (if any) showing its live edit buffer. fn render(f: &mut Frame, state: &State) { let area = f.area(); let outer = Block::default() - .title(" fxg — Configuration ") + .title(" vulp — Configuration ") .borders(Borders::ALL); let inner = outer.inner(area); f.render_widget(outer, area); @@ -126,6 +139,8 @@ fn render(f: &mut Frame, state: &State) { // ── key handling ────────────────────────────────────────────────────────────── +/// Routes one key event to either free-text edit-buffer editing or +/// navigation/action handling, depending on `state.editing`. fn handle_key(state: &mut State, key: KeyEvent) { if state.editing { match key.code { @@ -178,6 +193,9 @@ fn handle_key(state: &mut State, key: KeyEvent) { // ── ratatui inner loop ──────────────────────────────────────────────────────── +/// Runs the ratatui draw/poll/input loop until `handle_key` sets an +/// `Action`, then returns it (defaulting to `Quit` if the loop somehow +/// exits without one). fn run_ratatui_loop(state: &mut State) -> Result { tui::run_loop(|terminal| { terminal.draw(|f| render(f, state))?; @@ -191,6 +209,10 @@ fn run_ratatui_loop(state: &mut State) -> Result { // ── public entry point ──────────────────────────────────────────────────────── +/// Runs the settings TUI until the user quits, alternating between the +/// ratatui field list and (for enum fields) an `inquire` picker shown after +/// briefly leaving raw mode. Returns the fully updated config for the +/// caller to persist. pub fn run_config_tui(cfg: AppConfig) -> Result { let mut state = State::new(&cfg); @@ -203,13 +225,13 @@ pub fn run_config_tui(cfg: AppConfig) -> Result { let current = &state.fields[field_idx].1; let start = options.iter().position(|o| *o == current.as_str()).unwrap_or(0); let key_name = state.fields[field_idx].0; - match Select::new(&format!("{}:", key_name), options) + // Esc (None) keeps the old value. + if let Some(chosen) = Select::new(&format!("{}:", key_name), options) .with_starting_cursor(start) .with_help_message("Use ↑/↓ to select, Enter to confirm, Esc to cancel") .prompt_skippable()? { - Some(chosen) => { state.fields[field_idx].1 = chosen.to_string(); } - None => {} // Esc — keep old value + state.fields[field_idx].1 = chosen.to_string(); } // Loop continues — re-enter ratatui } diff --git a/src/debug.rs b/src/debug.rs index 0bec9bd..0771d4b 100644 --- a/src/debug.rs +++ b/src/debug.rs @@ -1,21 +1,31 @@ +//! Timing debug log shown in the hub's bottom debug panel (`--debug` flag). +//! Backed by thread-locals rather than a field threaded through every +//! function, since logging is opt-in and call sites are scattered across +//! the codebase wherever something worth timing happens. + use std::cell::RefCell; use std::time::Instant; thread_local! { - static ENABLED: RefCell = RefCell::new(false); - static LOG: RefCell> = RefCell::new(Vec::new()); - static T0: RefCell> = RefCell::new(None); + static ENABLED: RefCell = const { RefCell::new(false) }; + static LOG: RefCell> = const { RefCell::new(Vec::new()) }; + static T0: RefCell> = const { RefCell::new(None) }; } +/// Turns on debug logging and starts the elapsed-time clock used to +/// timestamp subsequent `log` entries. pub fn enable() { ENABLED.with(|e| *e.borrow_mut() = true); T0.with(|t| *t.borrow_mut() = Some(Instant::now())); } +/// Whether debug logging is currently on. pub fn is_enabled() -> bool { ENABLED.with(|e| *e.borrow()) } +/// Appends a timestamped entry to the debug log. A no-op when logging isn't +/// enabled, so call sites don't need to guard every call with `is_enabled()`. pub fn log(msg: impl Into) { if !is_enabled() { return; } let ms = T0.with(|t| t.borrow().map(|i| i.elapsed().as_millis()).unwrap_or(0)); diff --git a/src/detail/actions.rs b/src/detail/actions.rs new file mode 100644 index 0000000..8656cdc --- /dev/null +++ b/src/detail/actions.rs @@ -0,0 +1,241 @@ +//! `exec_action` — the repo-detail view's dispatcher for actions that need +//! to leave the TUI's alternate screen (interactive `inquire` prompts, +//! launching `$EDITOR`, blocking git subprocesses with visible output). +//! `run_detail_loop` in `mod.rs` calls this between `leave_tui`/`enter_tui`, +//! so everything here runs with the terminal back in normal/cooked mode. + +use super::git::{build_history, checkout_branch, git_run, list_branches, push_branch}; +use super::settings_io::{try_load_settings, try_make_provider}; +use super::types::{RepoSettings, St}; +use anyhow::Result; + +/// Runs the action identified by `idx` (one of the numeric codes set on +/// `st.run_action`/passed in from a modal). Numeric codes rather than an enum +/// because they're produced from several different call sites (key handlers, +/// modal confirms, settings-tab rows) that don't otherwise share a type. +pub async fn exec_action(st: &mut St, idx: usize) -> Result<()> { + let path = st.summary.path.clone(); + match idx { + // ── 0: sync ────────────────────────────────────────────────────────── + 0 => { + println!("\n── Sync: {} ──\n", st.summary.name); + let mode = st.pending_sync_mode.take().unwrap_or_else(|| "pull".into()); + let commit_msg = st.pending_commit_msg.take().unwrap_or_default(); + match mode.as_str() { + "pull" => { git_run(&path, &["pull"]); } + "commit_push" => { + git_run(&path, &["add", "-A"]); + git_run(&path, &["commit", "-m", &commit_msg]); + git_run(&path, &["push"]); + } + _ => { git_run(&path, &["push"]); } // push_only + } + println!("\n Press Enter to return..."); + let _ = std::io::stdin().lines().next(); + } + + // ── 1: release ─────────────────────────────────────────────────────── + 1 => { + println!("\n── Release: {} ──\n", st.summary.name); + let tag_name = st.pending_tag_name.take().unwrap_or_default(); + let tag_msg = st.pending_tag_msg.take().unwrap_or_else(|| tag_name.clone()); + let do_push = std::mem::replace(&mut st.pending_push_tag, false); + if !tag_name.is_empty() { + git_run(&path, &["tag", "-a", &tag_name, "-m", &tag_msg]); + if do_push { + git_run(&path, &["push", "origin", &tag_name]); + } + st.history = build_history(&path, 200); + st.total_tags += 1; + } + println!("\n Press Enter to return..."); + let _ = std::io::stdin().lines().next(); + } + + // ── 30: checkout branch ────────────────────────────────────────────── + 30 => { + if let Some(b) = st.branches.get(st.branch_cursor) { + let name = b.name.clone(); + if b.is_current { + st.status_msg = Some(format!("Already on '{}'", name)); + return Ok(()); + } + let ok = checkout_branch(&path, &name); + if ok { + st.summary.branch = name.clone(); + st.branches = list_branches(&path); + st.branch_cursor = st.branches.iter().position(|b| b.is_current).unwrap_or(0); + st.status_msg = Some(format!("Switched to '{}'", name)); + } else { + st.status_msg = Some("Checkout failed (uncommitted changes?)".into()); + } + } + } + + // ── 31: delete branch (handled in-TUI via ConfirmAction modal) ────── + 31 => {} + + // ── 106-109: open in editor (leave_tui is handled by run_detail_loop) ─ + 106 => { + let editor = st.cfg_editor.clone(); + if let Err(e) = std::process::Command::new(&editor).arg(&path).status() { + st.status_msg = Some(format!("Couldn't launch editor '{}': {}", editor, e)); + } + } + 107 => { + let editor = st.cfg_editor.clone(); + if let Err(e) = std::process::Command::new(&editor).arg(path.join(".gitignore")).status() { + st.status_msg = Some(format!("Couldn't launch editor '{}': {}", editor, e)); + } + } + 108 => { + let editor = st.cfg_editor.clone(); + if let Err(e) = std::process::Command::new(&editor).arg(path.join("LICENSE")).status() { + st.status_msg = Some(format!("Couldn't launch editor '{}': {}", editor, e)); + } + } + 109 => { + let editor = st.cfg_editor.clone(); + let readme = ["README.md", "README.rst", "README"].iter() + .map(|f| path.join(f)).find(|p| p.exists()) + .unwrap_or_else(|| path.join("README.md")); + if let Err(e) = std::process::Command::new(&editor).arg(&readme).status() { + st.status_msg = Some(format!("Couldn't launch editor '{}': {}", editor, e)); + } + } + + // ── 110: open pending_file in editor ──────────────────────────────── + 110 => { + if let Some(file) = st.pending_file.take() { + let editor = st.cfg_editor.clone(); + if let Err(e) = std::process::Command::new(&editor).arg(&file).status() { + st.status_msg = Some(format!("Couldn't launch editor '{}': {}", editor, e)); + } + } + } + + // ── 32: push branch ────────────────────────────────────────────────── + 32 => { + if let Some(b) = st.branches.get(st.branch_cursor) { + let name = b.name.clone(); + println!("\n── Push '{}' ──\n", name); + let ok = push_branch(&path, &name); + if ok { + st.branches = list_branches(&path); + st.status_msg = Some(format!("Pushed '{}'", name)); + } else { + st.status_msg = Some("Push failed".into()); + } + } + println!("\n Press Enter to return..."); + let _ = std::io::stdin().lines().next(); + } + + // ── 50: load settings from GitHub API ─────────────────────────────── + 50 => { + let local_visibility = st.summary.visibility.clone().unwrap_or_else(|| "unknown".into()); + match try_load_settings(st).await { + Ok(_) => {} + Err(_) => { + st.settings = Some(RepoSettings { + visibility: local_visibility, + description: String::new(), + topics: vec![], + gh_auth_ok: false, + }); + } + } + } + + // ── 60: toggle visibility ──────────────────────────────────────────── + 60 => { + println!("\n── Visibility: {} ──\n", st.summary.name); + let choice = inquire::Select::new("Set visibility:", vec!["public", "private"]) + .prompt_skippable()?; + if let Some(vis) = choice { + let private = vis == "private"; + match try_make_provider(st).await { + Ok(provider) => match provider.update_visibility(&st.summary.name, private).await { + Ok(_) => { + let v = vis.to_string(); + st.summary.visibility = Some(v.clone()); + if let Some(s) = &mut st.settings { s.visibility = v; } + st.status_msg = Some(format!("Visibility → {}", vis)); + } + Err(e) => { println!("API error: {}", e); } + } + Err(e) => { println!("Auth error: {}", e); } + } + } + println!("\n Press Enter to return..."); + let _ = std::io::stdin().lines().next(); + } + + // ── 61: edit description ───────────────────────────────────────────── + 61 => { + println!("\n── Description ──\n"); + let current = st.settings.as_ref().map(|s| s.description.as_str()).unwrap_or("").to_string(); + if let Some(desc) = inquire::Text::new("Description:").with_default(¤t).prompt_skippable()? { + match try_make_provider(st).await { + Ok(provider) => match provider.update_description(&st.summary.name, &desc).await { + Ok(_) => { + if let Some(s) = &mut st.settings { s.description = desc; } + st.status_msg = Some("Description updated".into()); + } + Err(e) => { println!("API error: {}", e); } + } + Err(e) => { println!("Auth error: {}", e); } + } + } + println!("\n Press Enter to return..."); + let _ = std::io::stdin().lines().next(); + } + + // ── 62: edit topics ────────────────────────────────────────────────── + 62 => { + println!("\n── Topics ──\n"); + let current = st.settings.as_ref().map(|s| s.topics.join(", ")).unwrap_or_default(); + if let Some(raw) = inquire::Text::new("Topics (comma-separated):").with_default(¤t).prompt_skippable()? { + let topics: Vec = raw.split(',').map(|s| s.trim().to_lowercase().to_string()) + .filter(|s| !s.is_empty()).collect(); + match try_make_provider(st).await { + Ok(provider) => match provider.set_topics(&st.summary.name, &topics).await { + Ok(_) => { + if let Some(s) = &mut st.settings { s.topics = topics; } + st.status_msg = Some("Topics updated".into()); + } + Err(e) => { println!("API error: {}", e); } + } + Err(e) => { println!("Auth error: {}", e); } + } + } + println!("\n Press Enter to return..."); + let _ = std::io::stdin().lines().next(); + } + + // ── 63: add collaborator ───────────────────────────────────────────── + 63 => { + println!("\n── Add Collaborator ──\n"); + if let Some(user) = inquire::Text::new("GitHub username:").prompt_skippable()? { + if !user.trim().is_empty() { + let role = inquire::Select::new("Role:", vec!["pull", "push", "admin"]) + .prompt_skippable()?; + if let Some(role) = role { + match try_make_provider(st).await { + Ok(provider) => match provider.add_collaborator(&st.summary.name, user.trim(), role).await { + Ok(_) => { st.status_msg = Some(format!("Invited {}", user.trim())); } + Err(e) => { println!("API error: {}", e); } + } + Err(e) => { println!("Auth error: {}", e); } + } + } + } + } + println!("\n Press Enter to return..."); + let _ = std::io::stdin().lines().next(); + } + + _ => {} + } + Ok(()) +} diff --git a/src/detail/git.rs b/src/detail/git.rs index 6c7d0c8..36312fa 100644 --- a/src/detail/git.rs +++ b/src/detail/git.rs @@ -1,3 +1,11 @@ +//! Git data access for the repo Detail view: commit/tag history, branch +//! listing with ahead/behind counts, contributors, per-period commit +//! activity (for the bar charts), the file tree, file preview, and the +//! branch checkout/delete/push actions. Mixes `git2` for structured reads +//! with shelling out to `git`/`sh` for the handful of queries (shortlog, +//! line counts, date-bucketed log) that are far simpler as a one-line CLI +//! pipe than as hand-rolled `git2` traversal. + use super::{BranchEntry, ContribEntry, HistEntry, TreeNode}; use crate::status::format_age_pub; use git2::{BranchType, Repository}; @@ -9,6 +17,11 @@ pub use crate::util::clipboard::copy_auto as copy_to_clipboard; // ── commit history ──────────────────────────────────────────────────────────── +/// Returns up to `limit` recent commits merged with all tags, newest first. +/// Tags are appended and re-sorted alongside commits (rather than queried +/// separately by the caller) so the history view can show them inline at +/// their true chronological position; the `limit + 20` truncation leaves +/// room for tags that would otherwise push real commits out of the window. pub fn build_history(path: &std::path::Path, limit: usize) -> Vec { let repo = match Repository::open(path) { Ok(r) => r, Err(_) => return vec![] }; @@ -50,11 +63,12 @@ pub fn build_history(path: &std::path::Path, limit: usize) -> Vec { } commits.extend(tags); - commits.sort_by(|a, b| b.ts().cmp(&a.ts())); + commits.sort_by_key(|c| std::cmp::Reverse(c.ts())); commits.truncate(limit + 20); commits } +/// Total commit count on the current branch (`git rev-list --count HEAD`). pub fn total_commits(path: &std::path::Path) -> usize { std::process::Command::new("git") .arg("-C").arg(path).args(["rev-list", "--count", "HEAD"]) @@ -65,6 +79,7 @@ pub fn total_commits(path: &std::path::Path) -> usize { .unwrap_or(0) } +/// Number of tags in the repo. pub fn count_tags(path: &std::path::Path) -> usize { std::process::Command::new("git") .arg("-C").arg(path).args(["tag", "-l"]) @@ -73,6 +88,7 @@ pub fn count_tags(path: &std::path::Path) -> usize { .unwrap_or(0) } +/// Number of stash entries. pub fn count_stashes(path: &std::path::Path) -> usize { std::process::Command::new("git") .arg("-C").arg(path).args(["stash", "list"]) @@ -83,6 +99,7 @@ pub fn count_stashes(path: &std::path::Path) -> usize { // ── files ───────────────────────────────────────────────────────────────────── +/// All tracked file paths, relative to the repo root (`git ls-files`). pub fn list_all_files(path: &std::path::Path) -> Vec { let out = std::process::Command::new("git") .arg("-C").arg(path).args(["ls-files"]) @@ -94,6 +111,9 @@ pub fn list_all_files(path: &std::path::Path) -> Vec { .lines().map(|l| l.to_string()).collect() } +/// Total line count across tracked files (capped at the first 5000 files, +/// since `wc -l` over an unbounded file list on a huge repo would be slow +/// for a number that's only used as a rough stat). pub fn count_lines_of_code(path: &std::path::Path) -> usize { let out = std::process::Command::new("sh") .arg("-c") @@ -103,12 +123,14 @@ pub fn count_lines_of_code(path: &std::path::Path) -> usize { match out { Ok(o) => { let s = String::from_utf8_lossy(&o.stdout); - s.trim().split_whitespace().next().and_then(|n| n.parse().ok()).unwrap_or(0) + s.split_whitespace().next().and_then(|n| n.parse().ok()).unwrap_or(0) } Err(_) => 0, } } +/// The `n` largest tracked files by line count, descending, for the repo +/// stats view's "largest files" list. pub fn largest_files(path: &std::path::Path, n: usize) -> Vec<(String, usize)> { let out = std::process::Command::new("sh") .arg("-c") @@ -134,6 +156,7 @@ pub fn largest_files(path: &std::path::Path, n: usize) -> Vec<(String, usize)> { // ── remote URL ──────────────────────────────────────────────────────────────── +/// The `origin` remote's URL, normalized to a browsable `https://` web URL. pub fn remote_url(path: &std::path::Path) -> Option { let repo = Repository::open(path).ok()?; let remote = repo.find_remote("origin").ok()?; @@ -143,6 +166,8 @@ pub fn remote_url(path: &std::path::Path) -> Option { // ── branches ───────────────────────────────────────────────────────────────── +/// Lists local branches with their upstream ahead/behind counts and last +/// commit info, current branch first then alphabetically. pub fn list_branches(path: &std::path::Path) -> Vec { let repo = match Repository::open(path) { Ok(r) => r, Err(_) => return vec![] }; let mut out = vec![]; @@ -183,6 +208,7 @@ pub fn list_branches(path: &std::path::Path) -> Vec { // ── contributors ───────────────────────────────────────────────────────────── +/// Contributors by commit count across all branches (`git shortlog -sn --all`). pub fn list_contributors(path: &std::path::Path) -> Vec { let out = std::process::Command::new("git") .arg("-C").arg(path).args(["shortlog", "-sn", "--all"]) @@ -203,12 +229,19 @@ pub fn list_contributors(path: &std::path::Path) -> Vec { // ── commit activity (per period) ───────────────────────────────────────────── +/// Commit counts bucketed by `date_fmt` (a `git log --date=format:` string), +/// optionally restricted to the last `since_days` days. The shared engine +/// behind all `activity_*` functions below, which differ only in bucket +/// width and how many buckets they show. fn git_activity_raw(path: &std::path::Path, since_days: Option, date_fmt: &str) -> HashMap { let since_arg = since_days.map(|d| format!("--since={} days ago", d)); let mut cmd = std::process::Command::new("git"); cmd.arg("-C").arg(path).arg("log"); if let Some(ref s) = since_arg { cmd.arg(s); } - cmd.args([&format!("--date=format:{}", date_fmt), "--pretty=format:%ad"]); + // format-local: buckets by the *local* timezone — plain `format:` uses + // whatever offset is embedded in each commit, so a commit from CI/UTC + // (or GitHub web UI) would land in the wrong day. + cmd.args([&format!("--date=format-local:{}", date_fmt), "--pretty=format:%ad"]); let out = cmd.output().unwrap_or_else(|_| std::process::Output { status: std::process::ExitStatus::default(), stdout: vec![], stderr: vec![] }); @@ -220,6 +253,7 @@ fn git_activity_raw(path: &std::path::Path, since_days: Option, date_fmt: & counts } +/// Commits per hour over the last day, as 24 `"00"`–`"23"` buckets. pub fn activity_1d(path: &std::path::Path) -> Vec<(String, usize)> { let raw = git_activity_raw(path, Some(1), "%H"); (0u32..24).map(|h| { @@ -229,9 +263,11 @@ pub fn activity_1d(path: &std::path::Path) -> Vec<(String, usize)> { }).collect() } +/// Commits per day over the last 7 days, as `"MM-DD"` buckets. pub fn activity_7d(path: &std::path::Path) -> Vec<(String, usize)> { let raw = git_activity_raw(path, Some(7), "%Y-%m-%d"); - let now = chrono::Utc::now(); + // Local, not Utc — labels must line up with format-local buckets. + let now = chrono::Local::now(); (0i64..7).rev().map(|d| { let dt = now - chrono::Duration::days(d); let label = dt.format("%Y-%m-%d").to_string(); @@ -241,9 +277,11 @@ pub fn activity_7d(path: &std::path::Path) -> Vec<(String, usize)> { }).collect() } +/// Commits per day over the last 30 days, as `"DD"` buckets. pub fn activity_30d(path: &std::path::Path) -> Vec<(String, usize)> { let raw = git_activity_raw(path, Some(30), "%Y-%m-%d"); - let now = chrono::Utc::now(); + // Local, not Utc — labels must line up with format-local buckets. + let now = chrono::Local::now(); (0i64..30).rev().map(|d| { let dt = now - chrono::Duration::days(d); let label = dt.format("%Y-%m-%d").to_string(); @@ -253,9 +291,12 @@ pub fn activity_30d(path: &std::path::Path) -> Vec<(String, usize)> { }).collect() } +/// Commits over the last 90 days, grouped into 13 weekly buckets labeled by +/// each week's start date. pub fn activity_90d(path: &std::path::Path) -> Vec<(String, usize)> { let raw = git_activity_raw(path, Some(90), "%Y-%m-%d"); - let now = chrono::Utc::now(); + // Local, not Utc — labels must line up with format-local buckets. + let now = chrono::Local::now(); // Group into 7-day buckets (13 weeks) (0usize..13).rev().map(|w| { let end_d = (w * 7) as i64; @@ -274,18 +315,26 @@ pub fn activity_90d(path: &std::path::Path) -> Vec<(String, usize)> { }).collect() } +/// Commits per month over the last year, as 3-letter month buckets. pub fn activity_1y(path: &std::path::Path) -> Vec<(String, usize)> { + use chrono::Datelike; let raw = git_activity_raw(path, Some(365), "%Y-%m"); - let now = chrono::Utc::now(); - (0i64..12).rev().map(|m| { - let dt = now - chrono::Duration::days(m * 30); - let label = dt.format("%Y-%m").to_string(); - let short = dt.format("%b").to_string(); + // Real month arithmetic — "now − m·30 days" drifts by a month within a + // year (months are 28-31 days) and duplicates/skips labels. + let now = chrono::Local::now(); + let (y0, m0) = (now.year(), now.month() as i32); + (0i32..12).rev().map(|m| { + let total = y0 * 12 + (m0 - 1) - m; + let (y, mon) = (total.div_euclid(12), total.rem_euclid(12) + 1); + let label = format!("{:04}-{:02}", y, mon); + const MONTHS: [&str; 12] = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + let short = MONTHS[(mon - 1) as usize].to_string(); let count = raw.get(&label).copied().unwrap_or(0); (short, count) }).collect() } +/// Commits per year across the repo's full history, oldest first. pub fn activity_all(path: &std::path::Path) -> Vec<(String, usize)> { let raw = git_activity_raw(path, None, "%Y"); let mut result: Vec<(String, usize)> = raw.into_iter().collect(); @@ -295,6 +344,8 @@ pub fn activity_all(path: &std::path::Path) -> Vec<(String, usize)> { // ── repo age ───────────────────────────────────────────────────────────────── +/// Timestamp of the repo's very first commit, found by walking `HEAD`'s +/// history to its end — used to compute repo age. pub fn repo_first_commit_ts(path: &std::path::Path) -> Option { let repo = Repository::open(path).ok()?; let mut rw = repo.revwalk().ok()?; @@ -307,6 +358,10 @@ pub fn repo_first_commit_ts(path: &std::path::Path) -> Option { // ── file tree ──────────────────────────────────────────────────────────────── +/// Builds a flat, depth-ordered tree (directories before their children, +/// each subtree alphabetical) from a flat list of repo-relative file paths +/// — the shape the file-tree view renders directly without needing to walk +/// a real nested structure at render time. pub fn build_tree(all_files: &[String]) -> Vec { let mut dirs: BTreeSet = BTreeSet::new(); for f in all_files { @@ -343,6 +398,7 @@ pub fn build_tree(all_files: &[String]) -> Vec { result } +/// Recursively appends `prefix`'s children to `out` in display order, depth-first. fn flatten_tree( prefix: &str, dir_children: &BTreeMap>, @@ -366,6 +422,9 @@ fn flatten_tree( // ── file preview ───────────────────────────────────────────────────────────── +/// Reads the first `max_lines` lines of `rel` for the file-tree preview +/// pane, detecting binary content via a null-byte check on the first 512 +/// bytes rather than rendering it as garbled text. pub fn read_file_preview(repo_path: &std::path::Path, rel: &str, max_lines: usize) -> Vec { let full = repo_path.join(rel); let bytes = match std::fs::read(&full) { @@ -381,14 +440,17 @@ pub fn read_file_preview(repo_path: &std::path::Path, rel: &str, max_lines: usiz // ── branch actions ─────────────────────────────────────────────────────────── +/// Checks out `name`, returning whether the command succeeded. pub fn checkout_branch(path: &std::path::Path, name: &str) -> bool { git_run_ok(path, &["checkout", name]) } +/// Deletes local branch `name` (`git branch -d`, fails if unmerged). pub fn delete_branch(path: &std::path::Path, name: &str) -> bool { git_run_ok(path, &["branch", "-d", name]) } +/// Pushes `name` to `origin`, setting it as the upstream (`git push -u`). pub fn push_branch(path: &std::path::Path, name: &str) -> bool { git_run_ok(path, &["push", "-u", "origin", name]) } diff --git a/src/detail/keys.rs b/src/detail/keys.rs new file mode 100644 index 0000000..345a7ad --- /dev/null +++ b/src/detail/keys.rs @@ -0,0 +1,522 @@ +//! Key handling for the repo-detail view: validating new-branch names and +//! dispatching key events either to the active modal (`handle_modal_key`) or +//! to plain tab navigation and per-tab shortcuts (`handle_key`). + +use super::git::{copy_to_clipboard, delete_branch, git_run_ok, list_branches, to_web_url}; +use super::settings_io::{open_settings_modal, spawn_settings_op}; +use super::types::{DetailModal, SettingOp, St, Tab}; +use super::{maybe_load_prs_issues, maybe_load_settings, rebuild_tree_visible}; +use crossterm::event::KeyCode; +use git2::Repository; + +// ── branch name validation ──────────────────────────────────────────────────── + +/// Checks a candidate branch name against the subset of git's ref-naming +/// rules that are easy to violate by typing — full validation still happens +/// in git itself when the `checkout -b` actually runs. +pub fn validate_branch_name(name: &str) -> Option { + if name.is_empty() { return Some("Name cannot be empty".into()); } + if name.contains(' ') { return Some("No spaces allowed".into()); } + if name.contains("..") { return Some("Cannot contain '..'".into()); } + if name.starts_with('/') || name.ends_with('/') { return Some("Cannot start or end with '/'".into()); } + if name.chars().any(|c| matches!(c, '~' | '^' | ':' | '?' | '*' | '[' | '\\')) { + return Some("Invalid character (~^:?*[\\)".into()); + } + None +} + +// ── modal key handling ──────────────────────────────────────────────────────── + +/// Routes `code` to whichever modal variant is currently open. Returns +/// `false` (and does nothing) when there is no modal, so the caller can fall +/// through to `handle_key` for normal tab/key handling. +pub fn handle_modal_key(code: KeyCode, st: &mut St) -> bool { + match &mut st.modal { + DetailModal::None => false, + + DetailModal::NewBranch { input, error } => { + match code { + KeyCode::Esc => { st.modal = DetailModal::None; } + KeyCode::Enter => { + let name = input.trim().to_string(); + if let Some(err) = validate_branch_name(&name) { + *error = Some(err); + } else { + let ok = git_run_ok(&st.summary.path, &["checkout", "-b", &name]); + st.status_msg = Some(if ok { + st.summary.branch = name.clone(); + st.branches = list_branches(&st.summary.path); + st.branch_count = st.branches.len(); + st.branch_cursor = st.branches.iter().position(|b| b.is_current).unwrap_or(0); + format!("Created & switched to '{}'", name) + } else { + "Failed to create branch".into() + }); + st.modal = DetailModal::None; + } + } + KeyCode::Backspace => { input.pop(); *error = None; } + KeyCode::Char(c) if input.len() < 80 => { input.push(c); *error = None; } + _ => {} + } + true + } + + DetailModal::SelectOption { options, cursor, tag, .. } => { + let max = options.len().saturating_sub(1); + match code { + KeyCode::Esc => { st.modal = DetailModal::None; } + KeyCode::Up => { *cursor = cursor.saturating_sub(1); } + KeyCode::Down => { *cursor = (*cursor + 1).min(max); } + KeyCode::Enter => { + let sel = options[*cursor].clone(); + let tag = *tag; + st.modal = DetailModal::None; + match tag { + 60 => { + let private = sel == "private"; + let label = sel.clone(); + if let Some(s) = &mut st.settings { s.visibility = label.clone(); } + st.summary.visibility = Some(label); + spawn_settings_op(st, SettingOp::Visibility { private }); + } + 90 => { + match sel.as_str() { + "Pull" => { + st.pending_sync_mode = Some("pull".into()); + st.run_action = Some(0); + } + "Commit + Push" => { + st.modal = DetailModal::EditText { + title: "Commit Message".into(), + prompt: "Message (stages all changes):".into(), + input: "chore: update".into(), + tag: 91, + }; + } + _ => { + st.pending_sync_mode = Some("push_only".into()); + st.run_action = Some(0); + } + } + } + _ => {} + } + } + _ => {} + } + true + } + + DetailModal::EditText { input, tag, .. } => { + match code { + KeyCode::Esc => { st.modal = DetailModal::None; } + KeyCode::Backspace => { input.pop(); } + KeyCode::Char(c) if input.len() < 200 => { input.push(c); } + KeyCode::Enter => { + let text = input.trim().to_string(); + let tag = *tag; + st.modal = DetailModal::None; + match tag { + 61 => { + if let Some(s) = &mut st.settings { s.description = text.clone(); } + spawn_settings_op(st, SettingOp::Description { text }); + } + 62 => { + let topics: Vec = text.split(',') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect(); + let invalid = topics.iter().find(|t| t.contains(' ')); + if let Some(bad) = invalid { + st.status_msg = Some(format!("Invalid topic '{}': no spaces within words", bad)); + } else { + if let Some(s) = &mut st.settings { s.topics = topics.clone(); } + spawn_settings_op(st, SettingOp::Topics { topics }); + } + } + 63 => { // collab name → step 2: pick role + if !text.is_empty() { + st.modal = DetailModal::SelectRole { collab_name: text, cursor: 0 }; + } + } + 65 => { // rename + if !text.is_empty() { + spawn_settings_op(st, SettingOp::Rename { new_name: text }); + } + } + 70 => { // commit — stage all and commit with message + if !text.is_empty() { + let path = st.summary.path.clone(); + let add_ok = git_run_ok(&path, &["add", "-A"]); + let commit_ok = add_ok && git_run_ok(&path, &["commit", "-m", &text]); + st.status_msg = Some(if commit_ok { + st.summary.ahead += 1; + st.summary.is_dirty = false; + format!("Committed: {}", text.chars().take(50).collect::()) + } else if !add_ok { + "git add -A failed".into() + } else { + "Commit failed — nothing staged?".into() + }); + } + } + 91 => { // commit message for commit+push → trigger exec_action(0) + st.pending_sync_mode = Some("commit_push".into()); + st.pending_commit_msg = Some(text); + st.run_action = Some(0); + } + 92 => { // tag name → ask for tag message + if !text.is_empty() { + let default_msg = text.clone(); + st.pending_tag_name = Some(text); + st.modal = DetailModal::EditText { + title: "Release — Tag Message".into(), + prompt: "Tag message (blank = tag name):".into(), + input: default_msg, + tag: 93, + }; + } + } + 93 => { // tag message → confirm push? + let tag_msg = if text.is_empty() { + st.pending_tag_name.clone().unwrap_or_default() + } else { text }; + st.pending_tag_msg = Some(tag_msg); + let tag_display = st.pending_tag_name.as_deref().unwrap_or("").to_string(); + st.modal = DetailModal::ConfirmAction { + message: format!("Push tag '{}' to remote?", tag_display), + tag: 94, + }; + } + _ => {} + } + } + _ => {} + } + true + } + + DetailModal::SelectRole { collab_name, cursor } => { + const ROLES: &[&str] = &["pull", "push", "maintain", "admin"]; + match code { + KeyCode::Esc => { st.modal = DetailModal::None; } + KeyCode::Up => { *cursor = cursor.saturating_sub(1); } + KeyCode::Down => { *cursor = (*cursor + 1).min(ROLES.len() - 1); } + KeyCode::Enter => { + let name = collab_name.clone(); + let role = ROLES[*cursor].to_string(); + st.modal = DetailModal::None; + spawn_settings_op(st, SettingOp::AddCollaborator { name, role }); + } + _ => {} + } + true + } + + DetailModal::ConfirmAction { tag, .. } => { + match code { + KeyCode::Esc | KeyCode::Char('n') => { st.modal = DetailModal::None; } + KeyCode::Enter | KeyCode::Char('y') => { + let tag = *tag; + st.modal = DetailModal::None; + match tag { + 31 => { // delete branch + let path = st.summary.path.clone(); + if let Some(b) = st.branches.get(st.branch_cursor) { + let name = b.name.clone(); + let ok = delete_branch(&path, &name); + if ok { + st.branches = list_branches(&path); + st.branch_count = st.branches.len(); + st.branch_cursor = st.branch_cursor.min(st.branches.len().saturating_sub(1)); + st.status_msg = Some(format!("Deleted '{}'", name)); + } else { + st.status_msg = Some("Delete failed — unmerged changes?".into()); + } + } + } + 64 => { spawn_settings_op(st, SettingOp::Archive); } + 66 => { spawn_settings_op(st, SettingOp::Delete); } + 94 => { // push tag confirmed + st.pending_push_tag = true; + st.run_action = Some(1); + } + _ => {} + } + } + _ => {} + } + true + } + } +} + +// ── main key handling ───────────────────────────────────────────────────────── + +/// Dispatches a non-modal key event: tab switching, per-tab vertical/page +/// navigation, and the global shortcuts (sync, release, open-in-browser, +/// clipboard copies, stash) listed in the detail view's help text. +pub fn handle_key(code: KeyCode, st: &mut St) { + st.status_msg = None; + match code { + KeyCode::Char('q') | KeyCode::Esc => st.quit = true, + + // Tab switching: Tab, arrows, number keys + KeyCode::Tab | KeyCode::Right => { + st.tab = st.tab.next(); + maybe_load_settings(st); + maybe_load_prs_issues(st); + } + KeyCode::Left => { + st.tab = st.tab.prev(); + maybe_load_settings(st); + maybe_load_prs_issues(st); + } + KeyCode::Char('1') => { st.tab = Tab::Overview; } + KeyCode::Char('2') => { st.tab = Tab::Commits; } + KeyCode::Char('3') => { st.tab = Tab::Branches; } + KeyCode::Char('4') => { st.tab = Tab::Files; } + KeyCode::Char('5') => { st.tab = Tab::Stats; } + KeyCode::Char('6') => { + st.tab = Tab::Settings; + maybe_load_settings(st); + } + KeyCode::Char('7') => { + st.tab = Tab::PRs; + maybe_load_prs_issues(st); + } + KeyCode::Char('8') => { + st.tab = Tab::Issues; + maybe_load_prs_issues(st); + } + KeyCode::Char('9') => { st.tab = Tab::Todos; } + + // Vertical navigation + KeyCode::Down | KeyCode::Char('j') => match st.tab { + Tab::Overview => st.ov_off = st.ov_off.saturating_add(1), + Tab::Commits => { + let max = st.history.len().saturating_sub(1); + st.hist_off = (st.hist_off + 1).min(max); + } + Tab::Branches => { + let max = st.branches.len().saturating_sub(1); + st.branch_cursor = (st.branch_cursor + 1).min(max); + } + Tab::Files => { + if st.tree_cursor + 1 < st.tree_visible.len() { + st.tree_cursor += 1; + st.run_action = Some(40); + } + } + Tab::Stats => st.stats_off = st.stats_off.saturating_add(1), + Tab::Settings => st.settings_cursor = (st.settings_cursor + 1).min(14), + Tab::PRs => st.pr_cursor = (st.pr_cursor + 1).min(st.repo_prs.len().saturating_sub(1)), + Tab::Issues => st.issue_cursor = (st.issue_cursor + 1).min(st.repo_issues.len().saturating_sub(1)), + Tab::Todos => st.todo_cursor = (st.todo_cursor + 1).min(st.repo_todos.len().saturating_sub(1)), + }, + KeyCode::Up | KeyCode::Char('k') => match st.tab { + Tab::Overview => st.ov_off = st.ov_off.saturating_sub(1), + Tab::Commits => st.hist_off = st.hist_off.saturating_sub(1), + Tab::Branches => { st.branch_cursor = st.branch_cursor.saturating_sub(1); } + Tab::Files => { + if st.tree_cursor > 0 { + st.tree_cursor -= 1; + st.run_action = Some(40); + } + } + Tab::Stats => st.stats_off = st.stats_off.saturating_sub(1), + Tab::Settings => st.settings_cursor = st.settings_cursor.saturating_sub(1), + Tab::PRs => st.pr_cursor = st.pr_cursor.saturating_sub(1), + Tab::Issues => st.issue_cursor = st.issue_cursor.saturating_sub(1), + Tab::Todos => st.todo_cursor = st.todo_cursor.saturating_sub(1), + }, + KeyCode::PageDown => match st.tab { + Tab::Commits => { + let max = st.history.len().saturating_sub(1); + st.hist_off = (st.hist_off + 10).min(max); + } + Tab::Files => { + let max = st.tree_visible.len().saturating_sub(1); + st.tree_cursor = (st.tree_cursor + 10).min(max); + st.run_action = Some(40); + } + Tab::PRs => st.pr_cursor = (st.pr_cursor + 10).min(st.repo_prs.len().saturating_sub(1)), + Tab::Issues => st.issue_cursor = (st.issue_cursor + 10).min(st.repo_issues.len().saturating_sub(1)), + Tab::Todos => st.todo_cursor = (st.todo_cursor + 10).min(st.repo_todos.len().saturating_sub(1)), + _ => {} + }, + KeyCode::PageUp => match st.tab { + Tab::Commits => st.hist_off = st.hist_off.saturating_sub(10), + Tab::Files => { + st.tree_cursor = st.tree_cursor.saturating_sub(10); + st.run_action = Some(40); + } + Tab::PRs => st.pr_cursor = st.pr_cursor.saturating_sub(10), + Tab::Issues => st.issue_cursor = st.issue_cursor.saturating_sub(10), + Tab::Todos => st.todo_cursor = st.todo_cursor.saturating_sub(10), + _ => {} + }, + + // Enter + KeyCode::Enter => match st.tab { + Tab::Files => { + if let Some(&ni) = st.tree_visible.get(st.tree_cursor) { + let node = &mut st.tree_nodes[ni]; + if node.is_dir { + node.expanded = !node.expanded; + let new_vis = rebuild_tree_visible(&st.tree_nodes); + st.tree_visible = new_vis; + st.tree_cursor = st.tree_cursor.min(st.tree_visible.len().saturating_sub(1)); + st.file_preview = vec![]; + } + } + } + Tab::Branches => { st.run_action = Some(30); } + Tab::Settings => open_settings_modal(st), + Tab::PRs => { + if let Some(pr) = st.repo_prs.get(st.pr_cursor) { + let url = pr.url.clone(); + crate::util::open_path(&url); + st.status_msg = Some("Opening PR in browser...".into()); + } + } + Tab::Issues => { + if let Some(issue) = st.repo_issues.get(st.issue_cursor) { + let url = issue.url.clone(); + crate::util::open_path(&url); + st.status_msg = Some("Opening issue in browser...".into()); + } + } + Tab::Todos => { + use crate::todos::TodoStatus; + if let Some(t) = st.repo_todos.get_mut(st.todo_cursor) { + t.status = match t.status { + TodoStatus::Open => TodoStatus::InProgress, + TodoStatus::InProgress => TodoStatus::Done, + TodoStatus::Done => TodoStatus::Cancelled, + TodoStatus::Cancelled => TodoStatus::Open, + }; + t.touch(); + let all = crate::todos::load_manual(); + let updated_t = t.clone(); + let new_todos: Vec<_> = all.into_iter() + .map(|x| if x.id == updated_t.id { updated_t.clone() } else { x }) + .collect(); + let _ = crate::todos::save(&new_todos); + } + } + _ => {} + }, + + // Branch-specific keys + KeyCode::Char('D') if st.tab == Tab::Branches => { + if let Some(b) = st.branches.get(st.branch_cursor) { + if b.is_current { + st.status_msg = Some("Cannot delete the current branch".into()); + } else { + let name = b.name.clone(); + st.modal = DetailModal::ConfirmAction { + message: format!("Delete branch '{}'?", name), + tag: 31, + }; + } + } + } + KeyCode::Char('P') if st.tab == Tab::Branches => { st.run_action = Some(32); } + KeyCode::Char('n') if st.tab == Tab::Branches => { + st.modal = DetailModal::NewBranch { input: String::new(), error: None }; + } + + // Stats period change + KeyCode::Char('p') if st.tab == Tab::Stats => { + st.stats_period = st.stats_period.next(); + st.stats_off = 0; + st.run_action = Some(41); + } + KeyCode::Char('<') if st.tab == Tab::Stats => { + st.stats_period = st.stats_period.prev(); + st.stats_off = 0; + st.run_action = Some(41); + } + KeyCode::Char('>') if st.tab == Tab::Stats => { + st.stats_period = st.stats_period.next(); + st.stats_off = 0; + st.run_action = Some(41); + } + + // Preview scroll (Files tab, right panel) + KeyCode::Char(']') if st.tab == Tab::Files => { + st.preview_off = st.preview_off.saturating_add(5); + } + KeyCode::Char('[') if st.tab == Tab::Files => { + st.preview_off = st.preview_off.saturating_sub(5); + } + // Open selected file in editor (Files tab) + KeyCode::Char('e') if st.tab == Tab::Files => { + if let Some(&ni) = st.tree_visible.get(st.tree_cursor) { + let node = &st.tree_nodes[ni]; + if !node.is_dir { + st.pending_file = Some(st.summary.path.join(&node.rel_path)); + st.run_action = Some(110); + st.status_msg = Some("Opening file...".into()); + } + } + } + + // Global actions + KeyCode::Char('s') => { + st.modal = DetailModal::SelectOption { + title: "Sync".into(), + options: vec!["Pull".into(), "Commit + Push".into(), "Push only".into()], + cursor: 0, + tag: 90, + }; + } + KeyCode::Char('r') => { + st.modal = DetailModal::EditText { + title: "Release — Tag Name".into(), + prompt: "Tag name (e.g. v1.0.0):".into(), + input: String::new(), + tag: 92, + }; + } + KeyCode::Char('W') => { + if let Ok(repo) = Repository::open(&st.summary.path) { + if let Ok(remote) = repo.find_remote("origin") { + if let Some(url) = remote.url() { + let web = to_web_url(url); + crate::util::open_path(&web); + st.status_msg = Some("Opening in browser...".into()); + } + } + } + } + KeyCode::Char('o') => { st.run_action = Some(106); } + KeyCode::Char('y') => { + let path = st.summary.path.to_string_lossy().to_string(); + if copy_to_clipboard(&path) { st.status_msg = Some("Path copied!".into()); } + } + KeyCode::Char('u') => { + if let Some(ref url) = st.remote_url_str.clone() { + if copy_to_clipboard(url) { st.status_msg = Some("URL copied!".into()); } + } + } + KeyCode::Char('g') => { + st.run_action = Some(107); + st.status_msg = Some("Opening .gitignore...".into()); + } + KeyCode::Char('S') => { + let ok = git_run_ok(&st.summary.path, &["stash"]); + st.status_msg = Some(if ok { "Stashed.".into() } else { "Stash failed.".into() }); + if ok { st.stash_count += 1; } + } + KeyCode::Char('A') => { + let ok = git_run_ok(&st.summary.path, &["stash", "pop"]); + st.status_msg = Some(if ok { "Stash applied.".into() } else { "No stash / error.".into() }); + if ok { st.stash_count = st.stash_count.saturating_sub(1); } + } + + _ => {} + } +} diff --git a/src/detail/mod.rs b/src/detail/mod.rs index 70120d5..d4ed514 100644 --- a/src/detail/mod.rs +++ b/src/detail/mod.rs @@ -1,281 +1,44 @@ +//! Repo-detail view: the full-screen TUI shown after selecting a single +//! repo from the hub. Owns the loop that polls background fetches, draws +//! the current tab, and routes key events — the actual state shape lives in +//! `types.rs`, key handling in `keys.rs`, background I/O in `settings_io.rs`, +//! out-of-TUI actions in `actions.rs`, and rendering in `render.rs`. + +mod actions; mod git; +mod keys; mod render; +mod settings_io; +mod types; pub use git::{activity_1d, activity_7d, activity_30d, activity_90d, activity_1y, activity_all}; +pub use types::*; +/// Builds the full commit/tag history for `path`, exposed at the crate root +/// so the project-detail view can reuse the same history-building logic +/// without depending on `detail`'s private `git` submodule directly. pub fn build_history_pub(path: &std::path::Path, limit: usize) -> Vec { git::build_history(path, limit) } use crate::config::AppConfig; use crate::status::{count_tracked_files, repo_detail, repo_recent_commits, RepoSummary}; +use actions::exec_action; use anyhow::Result; -use crossterm::event::{Event, KeyCode}; -use git2::Repository; +use crossterm::event::Event; use git::{ - build_history, build_tree, checkout_branch, copy_to_clipboard, count_lines_of_code, - count_stashes, count_tags, delete_branch, git_run, git_run_ok, largest_files, - list_all_files, list_branches, list_contributors, push_branch, read_file_preview, - remote_url, repo_first_commit_ts, to_web_url, total_commits, + build_history, build_tree, count_lines_of_code, count_stashes, count_tags, largest_files, + list_all_files, list_branches, list_contributors, read_file_preview, remote_url, + repo_first_commit_ts, total_commits, }; -use render::draw; -use std::path::PathBuf; - -// ── history entry ───────────────────────────────────────────────────────────── - -#[derive(Clone)] -pub enum HistEntry { - Commit { sha: String, age: String, ts: i64, author: String, msg: String }, - Tag { name: String, age: String, ts: i64, msg: String }, -} - -impl HistEntry { - pub fn ts(&self) -> i64 { match self { Self::Commit { ts, .. } | Self::Tag { ts, .. } => *ts } } -} - -// ── new data structures ─────────────────────────────────────────────────────── - -#[derive(Clone)] -pub struct BranchEntry { - pub name: String, - pub is_current: bool, - pub upstream: Option, - pub ahead: usize, - pub behind: usize, - pub last_commit_age: String, - pub last_commit_msg: String, -} - -#[derive(Clone)] -pub struct TreeNode { - pub name: String, - pub rel_path: String, - pub is_dir: bool, - pub depth: usize, - pub expanded: bool, -} - -#[derive(Clone)] -pub struct ContribEntry { - pub name: String, - pub count: usize, -} - -#[derive(Clone)] -pub struct MonthlyCommits { - pub label: String, - pub count: usize, -} - -pub struct RepoSettings { - pub visibility: String, - pub description: String, - pub topics: Vec, - pub gh_auth_ok: bool, -} - -// ── stats period ────────────────────────────────────────────────────────────── - -#[derive(PartialEq, Clone, Copy)] -pub enum StatsPeriod { Day1, Day7, Day30, Day90, Year1, AllTime } - -impl StatsPeriod { - pub fn label(self) -> &'static str { - match self { - Self::Day1 => "1d", - Self::Day7 => "7d", - Self::Day30 => "30d", - Self::Day90 => "90d", - Self::Year1 => "1y", - Self::AllTime => "all", - } - } - pub fn next(self) -> Self { - match self { - Self::Day1 => Self::Day7, - Self::Day7 => Self::Day30, - Self::Day30 => Self::Day90, - Self::Day90 => Self::Year1, - Self::Year1 => Self::AllTime, - Self::AllTime => Self::Day1, - } - } - pub fn prev(self) -> Self { - match self { - Self::Day1 => Self::AllTime, - Self::Day7 => Self::Day1, - Self::Day30 => Self::Day7, - Self::Day90 => Self::Day30, - Self::Year1 => Self::Day90, - Self::AllTime => Self::Year1, - } - } -} - -// ── Settings API operations (sent to background thread) ────────────────────── - -pub enum SettingOp { - Visibility { private: bool }, - Description { text: String }, - Topics { topics: Vec }, - AddCollaborator { name: String, role: String }, - Archive, - Rename { new_name: String }, - Delete, -} - -// ── TUI modals ──────────────────────────────────────────────────────────────── - -pub enum DetailModal { - None, - NewBranch { input: String, error: Option }, - // generic option selector (tag identifies the action) - SelectOption { title: String, options: Vec, cursor: usize, tag: u8 }, - // generic text input (tag identifies the action) - EditText { title: String, prompt: String, input: String, tag: u8 }, - // add-collaborator step 2: name collected, now pick role - SelectRole { collab_name: String, cursor: usize }, - // dangerous confirm (archive, delete) - ConfirmAction { message: String, tag: u8 }, -} - -// ── tabs ────────────────────────────────────────────────────────────────────── - -#[derive(PartialEq, Clone, Copy)] -pub enum Tab { Overview, Commits, Branches, Files, Stats, Settings, PRs, Issues, Todos } - -impl Tab { - fn next(self) -> Self { - match self { - Self::Overview => Self::Commits, - Self::Commits => Self::Branches, - Self::Branches => Self::Files, - Self::Files => Self::Stats, - Self::Stats => Self::Settings, - Self::Settings => Self::PRs, - Self::PRs => Self::Issues, - Self::Issues => Self::Todos, - Self::Todos => Self::Overview, - } - } - fn prev(self) -> Self { - match self { - Self::Overview => Self::Todos, - Self::Commits => Self::Overview, - Self::Branches => Self::Commits, - Self::Files => Self::Branches, - Self::Stats => Self::Files, - Self::Settings => Self::Stats, - Self::PRs => Self::Settings, - Self::Issues => Self::PRs, - Self::Todos => Self::Issues, - } - } -} - -// ── state ───────────────────────────────────────────────────────────────────── - -pub struct St { - pub summary: RepoSummary, - pub tab: Tab, - pub pending_file: Option, - - // Commits tab - pub history: Vec, - pub hist_off: usize, - pub total_commits: usize, - - // Files (changed) - pub files: Vec<(char, String)>, - pub file_count: usize, - - // Branches tab - pub branches: Vec, - pub branch_cursor: usize, - pub branch_off: usize, - - // Files tab (tree) - pub all_files: Vec, - pub tree_nodes: Vec, - pub tree_visible: Vec, - pub tree_cursor: usize, - pub tree_off: usize, - pub file_preview: Vec, - pub preview_off: usize, - - // Stats tab - pub contributors: Vec, - pub repo_age_days: u64, - pub stats_off: usize, - pub stats_period: StatsPeriod, - pub activity_data: Vec<(String, usize)>, // current period's activity - pub loc_total: usize, - pub top_files: Vec<(String, usize)>, // largest files by LOC - - // Tags/stashes (shared) - pub total_tags: usize, - pub stash_count: usize, - - // Overview tab - pub ov_recent: Vec<(String, String)>, - pub ov_scroll: usize, - pub remote_url_str: Option, - - // Settings tab - pub settings: Option, - pub settings_cur: usize, - pub settings_rx: Option>>, - pub settings_err: Option, - pub api_rx: Option>>, - - // PRs / Issues / Todos tabs - pub repo_prs: Vec, - pub repo_issues: Vec, - pub repo_todos: Vec, - pub pr_cursor: usize, - pub issue_cursor: usize, - pub todo_cursor: usize, - pub prs_loaded: bool, - pub prs_issues_rx: Option, Vec)>>, - - // Shared - pub branch_count: usize, - pub quit: bool, - pub run_action: Option, - pub status_msg: Option, - pub cfg_data_dir: PathBuf, - - // Pending values collected from in-TUI modals (sync / release) - pub pending_sync_mode: Option, - pub pending_commit_msg: Option, - pub pending_tag_name: Option, - pub pending_tag_msg: Option, - pub pending_push_tag: bool, - - // Modal - pub modal: DetailModal, -} - -// ── tree visibility rebuild ─────────────────────────────────────────────────── - -pub fn rebuild_tree_visible(nodes: &[TreeNode]) -> Vec { - let mut visible = vec![]; - let mut collapsed_depth: Option = None; - for (i, node) in nodes.iter().enumerate() { - if let Some(cd) = collapsed_depth { - if node.depth > cd { continue; } - else { collapsed_depth = None; } - } - visible.push(i); - if node.is_dir && !node.expanded { - collapsed_depth = Some(node.depth); - } - } - visible -} +use keys::{handle_key, handle_modal_key}; +use settings_io::{compute_activity, poll_api_rx, poll_prs_issues_rx, poll_settings_rx, spawn_prs_issues_fetch, spawn_settings_fetch}; // ── entry point ─────────────────────────────────────────────────────────────── +/// Opens the detail view for `summary`. Reuses an already-live terminal +/// handed over by the hub when given one (avoids a flash of the alternate +/// screen toggling off and back on), otherwise sets up a fresh one. pub async fn run( summary: RepoSummary, cfg: &AppConfig, @@ -333,12 +96,10 @@ pub async fn run( total_commits: total, branches, branch_cursor: 0, - branch_off: 0, all_files, tree_nodes, tree_visible, tree_cursor: 0, - tree_off: 0, file_preview: vec![], preview_off: 0, contributors, @@ -349,10 +110,10 @@ pub async fn run( loc_total: loc, top_files: top_f, ov_recent: repo_recent_commits(&path, 6), - ov_scroll: 0, + ov_off: 0, remote_url_str: url_str, settings: None, - settings_cur: 0, + settings_cursor: 0, settings_rx: None, settings_err: None, api_rx: None, @@ -370,9 +131,11 @@ pub async fn run( prs_loaded: false, prs_issues_rx: None, quit: false, + quit_after_op: false, run_action: None, status_msg: None, cfg_data_dir: cfg.data_dir.clone(), + cfg_editor: cfg.editor.clone(), pending_sync_mode: None, pending_commit_msg: None, pending_tag_name: None, @@ -404,13 +167,19 @@ async fn run_detail_loop(st: &mut St, terminal: &mut crate::tui::Tty) -> Result< Ok(()) } +/// Runs draw/poll/input frames until a key handler requests an out-of-TUI +/// action (returned as `Some(idx)`) or the view quits (returned as `None`). +/// `idx` values 40/41/50/80 are handled here rather than handed up to +/// `exec_action`, since they're cheap, non-interactive follow-ups (refresh a +/// file preview, recompute a stats chart, kick off a background fetch) that +/// don't need to leave the alternate screen. fn tui_phase_inner(st: &mut St, terminal: &mut crate::tui::Tty) -> Result> { let path = st.summary.path.clone(); loop { poll_settings_rx(st); poll_api_rx(st); poll_prs_issues_rx(st); - terminal.draw(|f| draw(f, st))?; + terminal.draw(|f| render::draw(f, st))?; let event = crate::tui::next_event(250)?; let key = match event { Some(Event::Key(k)) => k, _ => continue }; @@ -473,1061 +242,18 @@ fn tui_phase_inner(st: &mut St, terminal: &mut crate::tui::Tty) -> Result